| 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 |