返回 CodeWhale
discovery.ts
1 /**
2 * Filesystem discovery of agent presets. A preset is a directory holding
3 * {@link COMPOSITION_FILE}, optionally beside a {@link METADATA_FILE} carrying
4 * its display text; the directory name is the preset id. Discovery
5 * re-reads the roots on every call so a preset authored while the process is
6 * running is visible without a restart.
7 *
8 * Discovery also owns preset HEALTH: a directory whose composition is
9 * missing or unloadable is reported as a broken roster row rather than
10 * skipped. A skipped directory would still occupy its id on disk — the copy
11 * path refuses the name while no surface shows anything to delete — and a
12 * malformed composition would otherwise read as an ordinary preset until the
13 * first session fails to mount it.
14 *
15 * Health is what every consumer reads before offering a preset — the pickers
16 * drop a broken row rather than defer the discovery to a failed session
17 * start — so it covers the way an authored preset actually rots: a row naming
18 * a package that was renamed or uninstalled. Resolving those names is a
19 * separate pass from the shape check and stops short of importing anything,
20 * so a composition is judged without running a line of plugin code.
21 * @module @deepseek-ai/dsh-agent-presets/discovery
22 */
23
24 import { isBuiltin } from 'node:module'
25 import { dirname, join, resolve } from 'node:path'
26 import { fileURLToPath, pathToFileURL } from 'node:url'
27 import { load } from 'js-yaml'
28 import { entryListSchema } from '../include/src/index.ts'
29 import { readPresetMetadata } from './metadata.ts'
30 import { PRESET_ID, type AgentPreset, type PresetRoot } from './preset.ts'
31 import { classifyRowSpecifier, type RowSpecifier } from './specifier.ts'
32
33 /** The composition file that makes a directory a preset. */
34 export const COMPOSITION_FILE = 'agent.cordis.yml'
35
36 /**
37 * Harness-home directory holding locally authored presets.
38 *
39 * This package owns the writable root the way `dsh-skill-filesystem` owns
40 * `<dshHome>/skills`: where a person's own presets go is the same place in
41 * every deployment that does not say otherwise, so a launcher that forgets to
42 * configure one still finds them.
43 *
44 * Package-internal on purpose: no consumer outside this package addresses the
45 * directory by name, and a test that imported it could not catch this value
46 * being wrong — the expected segment is spelled out where it is asserted.
47 */
48 export const USER_PRESET_DIR = '.agent-presets'
49
50 /**
51 * The shipped presets, bundled inside this package: the roster's built-in
52 * compositions travel with the machinery that mounts them, the way each
53 * preset's own skills travel inside its directory. Resolved relative to this
54 * module so both launch layouts work — `src/` under tsx and the bundled
55 * `lib/` sit one level below the package root.
56 */
57 export const SHIPPED_PRESET_ROOT = fileURLToPath(new URL('../presets/', import.meta.url))
58
59 /**
60 * Why `rows` cannot be an entry list, or undefined when it can.
61 *
62 * A shallow shape check, deliberately short of the loader's work: it does not
63 * resolve plugin names or apply configs. What it catches is the hand-edit
64 * that produces a file the loader cannot even begin with — and it must accept
65 * everything the loader accepts, which is why rows are only required to be
66 * maps carrying a plugin `name` (groups recurse into their own lists).
67 *
68 * Shared with the composition inventory, whose file reads race edits against
69 * the health verdict and must judge the raced content by the same rule.
70 * @param rows - the parsed composition document.
71 * @param at - row-path prefix for nested diagnostics, empty at the top level.
72 * @returns one human-readable reason, or undefined when the shape holds.
73 */
74 export function entryListProblem(rows: unknown, at = ''): string | undefined {
75 if (!Array.isArray(rows)) {
76 return at === ''
77 ? 'the composition must be a top-level list of plugin rows'
78 : `group ${at} must hold a list of plugin rows`
79 }
80 for (const [index, row] of rows.entries()) {
81 const label = at === '' ? `row ${String(index + 1)}` : `${at} row ${String(index + 1)}`
82 if (typeof row !== 'object' || row === null || Array.isArray(row)) {
83 return `${label} is not a plugin row (expected a map with a "name")`
84 }
85 const { name, group, config } = row as { name?: unknown; group?: unknown; config?: unknown }
86 if (typeof name !== 'string' || name === '') {
87 return `${label} names no plugin (a "name" string is required)`
88 }
89 if (group === true) {
90 const nested = entryListProblem(config, label)
91 if (nested !== undefined) return nested
92 }
93 }
94 return undefined
95 }
96
97 /** Package lookup injected into preset discovery. */
98 type PackageResolves = (specifier: string, base: string) => boolean
99
100 /** Receipt IO only: no filesystem walk, home expansion or package fallback. */
101 export interface DiscoveryIO {
102 readFile(path: string, encoding: string): Promise<string>
103 readdir(path: string, options: { withFileTypes: true }): Promise<{ name: string; isDirectory(): boolean }[]>
104 stat(path: string): Promise<{ isFile(): boolean }>
105 }
106
107 /**
108 * Whether one classified row names a module that exists, importing nothing.
109 *
110 * Package rows delegate to the injected lookup. Relative and `file:` rows use
111 * file metadata. No check evaluates the named module.
112 * @param row - the classified specifier, from {@link classifyRowSpecifier}.
113 * @param presetBase - directory URL a preset-relative specifier resolves against.
114 * @param harnessBase - base URL a package name resolves against.
115 * @param resolves - package lookup selected by the owning caller.
116 * @returns true when the row names something that can be imported.
117 */
118 async function rowResolves(
119 row: RowSpecifier, presetBase: string, harnessBase: string, resolves: PackageResolves, io: DiscoveryIO,
120 ): Promise<boolean> {
121 if (row.kind === 'builtin') return true
122 if (row.kind === 'package') return isBuiltin(row.specifier) || resolves(row.specifier, harnessBase)
123 const url = row.kind === 'file' ? new URL(row.specifier) : new URL(row.specifier, presetBase)
124 return await isFile(fileURLToPath(url), io)
125 }
126
127 /** One row that names a module no resolver can find. */
128 interface UnresolvableRow {
129 /** `row "id"`, or the row's position when it declares none. */
130 readonly label: string
131 /** The specifier exactly as the row wrote it. */
132 readonly name: string
133 }
134
135 /**
136 * Rows whose module cannot be resolved.
137 *
138 * Only rows that will certainly be started are checked, and the test is the
139 * Loader's own: it starts a row when `Boolean(options.disabled)` is false, so
140 * `disabled: 0` names a row that DOES start and must be checked. A `!!js`
141 * expression is an object and therefore truthy, which skips exactly the rows
142 * whose value only the loader context can decide. Skipping those trades a
143 * missed name for the failure that matters more: calling a usable preset
144 * broken makes it unselectable and uncopyable, which is worse than reporting
145 * the same stale row at mount time as before.
146 *
147 * Shape is the caller's precondition: {@link entryListProblem} has already
148 * proven every row is a map carrying a `name` string, and groups recurse the
149 * same way it does.
150 * @param rows - the parsed composition rows.
151 * @param presetBase - directory URL a preset-relative specifier resolves against.
152 * @param harnessBase - base URL a package name resolves against.
153 * @param at - row-path prefix for nested diagnostics, empty at the top level.
154 * @returns one entry per unresolvable row, in composition order.
155 */
156 async function unresolvableRows(
157 rows: readonly unknown[],
158 presetBase: string,
159 harnessBase: string,
160 resolves: PackageResolves, io: DiscoveryIO,
161 at = '',
162 ): Promise<UnresolvableRow[]> {
163 const found: UnresolvableRow[] = []
164 for (const [index, entry] of rows.entries()) {
165 const row = entry as { id?: unknown; name: string; group?: unknown; config?: unknown; disabled?: unknown }
166 if (Boolean(row.disabled)) continue
167 const positional = at === '' ? `row ${String(index + 1)}` : `${at} row ${String(index + 1)}`
168 if (row.group === true) {
169 found.push(...await unresolvableRows(row.config as readonly unknown[], presetBase, harnessBase, resolves, io, positional))
170 continue
171 }
172 if (await rowResolves(classifyRowSpecifier(row.name), presetBase, harnessBase, resolves, io)) continue
173 const label = typeof row.id === 'string' && row.id !== '' ? `row "${row.id}"` : positional
174 found.push({ label, name: row.name })
175 }
176 return found
177 }
178
179 /**
180 * Why the composition at `path` cannot mount, or undefined when it looks
181 * loadable. Parsed with the loader's own YAML dialect ({@link entryListSchema},
182 * the one carrying `!!js`), so health can never call a composition broken
183 * that the loader would accept.
184 * A package-lookup failure becomes this composition's broken reason, so one
185 * preset cannot abort discovery of the rest of the roster.
186 * @param path - absolute path of the composition file.
187 * @param harnessBase - base URL a row's package name resolves against.
188 * @returns one human-readable reason, or undefined when the file is loadable.
189 */
190 async function compositionProblem(
191 path: string, harnessBase: string, resolves: PackageResolves, io: DiscoveryIO,
192 ): Promise<string | undefined> {
193 let content: string
194 try {
195 content = await io.readFile(path, 'utf8')
196 } catch {
197 // The caller statted this file moments ago; any read failure now —
198 // deleted in between, permissions — is the same answer as unparsable.
199 return `the composition file ${COMPOSITION_FILE} cannot be read`
200 }
201 let rows: unknown
202 try {
203 rows = load(content, { schema: entryListSchema })
204 } catch (error) {
205 /* v8 ignore next -- js-yaml throws YAMLException (an Error) for every parse failure; the fallback keeps a hostile value readable */
206 const full = error instanceof Error ? error.message : String(error)
207 // First line only: js-yaml appends a multi-line code-frame snippet, and
208 // the reason is displayed on a roster card, not in a terminal.
209 return `the composition is not valid YAML: ${full.replace(/\n[\s\S]*$/, '')}`
210 }
211 const shape = entryListProblem(rows)
212 if (shape !== undefined) return shape
213 // The composition's own directory, exactly as `Include` derives it, so a
214 // row naming a file the preset ships resolves the way the mount will.
215 const presetBase = new URL('.', pathToFileURL(path)).href
216 let unresolvable: UnresolvableRow[]
217 try {
218 unresolvable = await unresolvableRows(rows as readonly unknown[], presetBase, harnessBase, resolves, io)
219 } catch (error) {
220 const full = error instanceof Error ? error.message : String(error)
221 return `the composition's plugins cannot be checked: ${full.replace(/\n[\s\S]*$/, '')}`
222 }
223 const [first] = unresolvable
224 if (first === undefined) return undefined
225 if (unresolvable.length === 1) {
226 return `${first.label} names a plugin that cannot be resolved: ${first.name}`
227 }
228 return `${String(unresolvable.length)} rows name plugins that cannot be resolved:\n`
229 + unresolvable.map(row => `- ${row.label}: ${row.name}`).join('\n')
230 }
231
232 /**
233 * Whether `path` names an existing regular file.
234 * @param path - absolute path to test.
235 * @returns true when the path resolves to a file.
236 */
237 async function isFile(path: string, io: DiscoveryIO): Promise<boolean> {
238 try {
239 return (await io.stat(path)).isFile()
240 } catch {
241 // Any stat failure — absent, unreadable, a dangling link — means this
242 // directory does not present a composition, which is not an error: the
243 // directory simply is not a preset.
244 return false
245 }
246 }
247
248 /**
249 * Scan one root for preset directories.
250 *
251 * An absent root yields no presets rather than throwing: the user root does
252 * not exist until the first locally authored preset, and naming a default
253 * that no root supplies already fails loud at resolution.
254 *
255 * Every directory whose name is a usable preset id is a roster row — broken
256 * when its composition is missing or unloadable. A directory named outside
257 * {@link PRESET_ID} is skipped instead: no copy could ever claim that name,
258 * so it blocks nothing, and reporting `.DS_Store`-grade residue as broken
259 * presets would teach users to ignore the marker.
260 * @param root - the directory and the trust its presets inherit.
261 * @param harnessBase - base URL a row's package name resolves against; the
262 * caller's own `ctx.baseUrl`, which is where the installed harness lives.
263 * @param resolves - package-presence lookup for the active runtime.
264 * @returns the root's presets ordered by id.
265 */
266 export async function scanRoot(
267 root: PresetRoot, harnessBase: string, resolves: PackageResolves, io: DiscoveryIO,
268 ): Promise<AgentPreset[]> {
269 const dir = resolve(root.path)
270 let children
271 try {
272 children = await io.readdir(dir, { withFileTypes: true })
273 } catch (error) {
274 if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []
275 throw new Error(`agent-presets: cannot read preset root ${dir}: ${String(error)}`, { cause: error })
276 }
277 const found: AgentPreset[] = []
278 for (const child of children) {
279 if (!child.isDirectory() || !PRESET_ID.test(child.name)) continue
280 const directory = join(dir, child.name)
281 const path = join(directory, COMPOSITION_FILE)
282 const broken = await isFile(path, io)
283 ? await compositionProblem(path, harnessBase, resolves, io)
284 : `the composition file ${COMPOSITION_FILE} is missing — the directory still occupies the id; delete it or restore the file`
285 // Display text only, and never fatal: a preset with unreadable metadata
286 // still mounts, it just shows its id.
287 const metadata = await readPresetMetadata(directory, io.readFile)
288 found.push({
289 id: child.name, trust: root.trust, path, ...metadata,
290 ...broken === undefined ? {} : { broken },
291 })
292 }
293 // Declared order first so the shipped set reads by capability; everything
294 // else falls back to the id, which keeps authored presets stable.
295 return found.sort((left, right) => {
296 const leftOrder = left.order ?? Number.POSITIVE_INFINITY
297 const rightOrder = right.order ?? Number.POSITIVE_INFINITY
298 const byOrder = leftOrder === rightOrder ? 0 : leftOrder - rightOrder
299 return byOrder === 0 ? left.id.localeCompare(right.id) : byOrder
300 })
301 }
302
303 /**
304 * Scan every root in precedence order.
305 * @param roots - roots in precedence order; an earlier root wins a duplicate id.
306 * @param harnessBase - base URL a row's package name resolves against.
307 * @param resolves - package-presence lookup for the active runtime.
308 * @returns every discovered preset, first-root-wins per id.
309 */
310 export async function discoverPresets(
311 roots: readonly PresetRoot[],
312 harnessBase: string,
313 resolves: PackageResolves, io: DiscoveryIO,
314 ): Promise<AgentPreset[]> {
315 const byId = new Map<string, AgentPreset>()
316 for (const root of roots) {
317 for (const preset of await scanRoot(root, harnessBase, resolves, io)) {
318 if (byId.has(preset.id)) continue
319 byId.set(preset.id, preset)
320 }
321 }
322 return [...byId.values()]
323 }
324
324 lines TYPESCRIPT