|
|
@@ -4,18 +4,92 @@
|
|
|
* its display text; the directory name is the preset id. Discovery
|
|
|
* re-reads the roots on every call so a preset authored while the process is
|
|
|
* running is visible without a restart.
|
|
|
+ *
|
|
|
+ * Discovery also owns preset HEALTH: a directory whose composition is
|
|
|
+ * missing or unloadable is reported as a broken roster row rather than
|
|
|
+ * skipped. A skipped directory would still occupy its id on disk — the copy
|
|
|
+ * path refuses the name while no surface shows anything to delete — and a
|
|
|
+ * malformed composition would otherwise read as an ordinary preset until the
|
|
|
+ * first session fails to mount it.
|
|
|
* @module @deepseek-ai/dsh-agent-presets/discovery
|
|
|
*/
|
|
|
|
|
|
-import { readdir, stat } from 'node:fs/promises'
|
|
|
+import { readdir, readFile, stat } from 'node:fs/promises'
|
|
|
import { join, resolve } from 'node:path'
|
|
|
+import { load } from 'js-yaml'
|
|
|
+import { entryListSchema } from '@cordisjs/plugin-include'
|
|
|
import { expandHomePath } from '@deepseek-ai/dsh-paths'
|
|
|
import { readPresetMetadata } from './metadata.ts'
|
|
|
-import type { AgentPreset, PresetRoot } from './types.ts'
|
|
|
+import { PRESET_ID, type AgentPreset, type PresetRoot } from './types.ts'
|
|
|
|
|
|
/** The composition file that makes a directory a preset. */
|
|
|
export const COMPOSITION_FILE = 'agent.cordis.yml'
|
|
|
|
|
|
+/**
|
|
|
+ * Why `rows` cannot be an entry list, or undefined when it can.
|
|
|
+ *
|
|
|
+ * A shallow shape check, deliberately short of the loader's work: it does not
|
|
|
+ * resolve plugin names or apply configs. What it catches is the hand-edit
|
|
|
+ * that produces a file the loader cannot even begin with — and it must accept
|
|
|
+ * everything the loader accepts, which is why rows are only required to be
|
|
|
+ * maps carrying a plugin `name` (groups recurse into their own lists).
|
|
|
+ * @param rows - the parsed composition document.
|
|
|
+ * @param at - row-path prefix for nested diagnostics, empty at the top level.
|
|
|
+ * @returns one human-readable reason, or undefined when the shape holds.
|
|
|
+ */
|
|
|
+function entryListProblem(rows: unknown, at = ''): string | undefined {
|
|
|
+ if (!Array.isArray(rows)) {
|
|
|
+ return at === ''
|
|
|
+ ? 'the composition must be a top-level list of plugin rows'
|
|
|
+ : `group ${at} must hold a list of plugin rows`
|
|
|
+ }
|
|
|
+ for (const [index, row] of rows.entries()) {
|
|
|
+ const label = at === '' ? `row ${String(index + 1)}` : `${at} row ${String(index + 1)}`
|
|
|
+ if (typeof row !== 'object' || row === null || Array.isArray(row)) {
|
|
|
+ return `${label} is not a plugin row (expected a map with a "name")`
|
|
|
+ }
|
|
|
+ const { name, group, config } = row as { name?: unknown; group?: unknown; config?: unknown }
|
|
|
+ if (typeof name !== 'string' || name === '') {
|
|
|
+ return `${label} names no plugin (a "name" string is required)`
|
|
|
+ }
|
|
|
+ if (group === true) {
|
|
|
+ const nested = entryListProblem(config, label)
|
|
|
+ if (nested !== undefined) return nested
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return undefined
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Why the composition at `path` cannot mount, or undefined when it looks
|
|
|
+ * loadable. Parsed with the loader's own YAML dialect ({@link entryListSchema},
|
|
|
+ * the one carrying `!!js`), so health can never call a composition broken
|
|
|
+ * that the loader would accept.
|
|
|
+ * @param path - absolute path of the composition file.
|
|
|
+ * @returns one human-readable reason, or undefined when the file is loadable.
|
|
|
+ */
|
|
|
+async function compositionProblem(path: string): Promise<string | undefined> {
|
|
|
+ let content: string
|
|
|
+ try {
|
|
|
+ content = await readFile(path, 'utf8')
|
|
|
+ } catch {
|
|
|
+ // The caller statted this file moments ago; any read failure now —
|
|
|
+ // deleted in between, permissions — is the same answer as unparsable.
|
|
|
+ return `the composition file ${COMPOSITION_FILE} cannot be read`
|
|
|
+ }
|
|
|
+ let rows: unknown
|
|
|
+ try {
|
|
|
+ rows = load(content, { schema: entryListSchema })
|
|
|
+ } catch (error) {
|
|
|
+ /* v8 ignore next -- js-yaml throws YAMLException (an Error) for every parse failure; the fallback keeps a hostile value readable */
|
|
|
+ const full = error instanceof Error ? error.message : String(error)
|
|
|
+ // First line only: js-yaml appends a multi-line code-frame snippet, and
|
|
|
+ // the reason is displayed on a roster card, not in a terminal.
|
|
|
+ return `the composition is not valid YAML: ${full.replace(/\n[\s\S]*$/, '')}`
|
|
|
+ }
|
|
|
+ return entryListProblem(rows)
|
|
|
+}
|
|
|
+
|
|
|
/**
|
|
|
* Whether `path` names an existing regular file.
|
|
|
* @param path - absolute path to test.
|
|
|
@@ -38,6 +112,12 @@ async function isFile(path: string): Promise<boolean> {
|
|
|
* An absent root yields no presets rather than throwing: the user root does
|
|
|
* not exist until the first locally authored preset, and naming a default
|
|
|
* that no root supplies already fails loud at resolution.
|
|
|
+ *
|
|
|
+ * Every directory whose name is a usable preset id is a roster row — broken
|
|
|
+ * when its composition is missing or unloadable. A directory named outside
|
|
|
+ * {@link PRESET_ID} is skipped instead: no copy could ever claim that name,
|
|
|
+ * so it blocks nothing, and reporting `.DS_Store`-grade residue as broken
|
|
|
+ * presets would teach users to ignore the marker.
|
|
|
* @param root - the directory and the trust its presets inherit.
|
|
|
* @returns the root's presets ordered by id.
|
|
|
*/
|
|
|
@@ -52,14 +132,19 @@ export async function scanRoot(root: PresetRoot): Promise<AgentPreset[]> {
|
|
|
}
|
|
|
const found: AgentPreset[] = []
|
|
|
for (const child of children) {
|
|
|
- if (!child.isDirectory()) continue
|
|
|
+ if (!child.isDirectory() || !PRESET_ID.test(child.name)) continue
|
|
|
const directory = join(dir, child.name)
|
|
|
const path = join(directory, COMPOSITION_FILE)
|
|
|
- if (!await isFile(path)) continue
|
|
|
+ const broken = await isFile(path)
|
|
|
+ ? await compositionProblem(path)
|
|
|
+ : `the composition file ${COMPOSITION_FILE} is missing — the directory still occupies the id; delete it or restore the file`
|
|
|
// Display text only, and never fatal: a preset with unreadable metadata
|
|
|
// still mounts, it just shows its id.
|
|
|
const metadata = await readPresetMetadata(directory)
|
|
|
- found.push({ id: child.name, trust: root.trust, path, ...metadata })
|
|
|
+ found.push({
|
|
|
+ id: child.name, trust: root.trust, path, ...metadata,
|
|
|
+ ...broken === undefined ? {} : { broken },
|
|
|
+ })
|
|
|
}
|
|
|
// Declared order first so the shipped set reads by capability; everything
|
|
|
// else falls back to the id, which keeps authored presets stable.
|