| 1 | /** |
| 2 | * A preset's display metadata: the name and description a picker shows. |
| 3 | * |
| 4 | * It lives in its own file because the composition is a top-level list of |
| 5 | * plugin rows — YAML cannot carry sibling keys beside it, and faking a |
| 6 | * metadata row would hand the Loader something to load. Keeping it separate |
| 7 | * also keeps the composition exactly what its name says: a Cordis file the |
| 8 | * loader owns and the cordis preset can author. |
| 9 | * |
| 10 | * The file carries display text ONLY. `id` is the directory name and `trust` |
| 11 | * comes from the root a preset was discovered under, so neither is writable |
| 12 | * here — otherwise a locally authored preset could claim to be a shipped one. |
| 13 | * |
| 14 | * Every read failure degrades to no metadata. A preset whose display text is |
| 15 | * missing, malformed, or unreadable still mounts: presentation is not a |
| 16 | * capability, and a broken name must never become an agent that cannot start. |
| 17 | * @module @deepseek-ai/dsh-agent-presets/metadata |
| 18 | */ |
| 19 | |
| 20 | import { join } from 'node:path' |
| 21 | import yaml from 'js-yaml' |
| 22 | |
| 23 | /** The optional display-metadata file beside a preset's composition. */ |
| 24 | export const METADATA_FILE = 'preset.yml' |
| 25 | |
| 26 | /** Display text a preset may publish about itself. */ |
| 27 | export interface PresetMetadata { |
| 28 | /** Human-facing name; falls back to the preset id when absent. */ |
| 29 | readonly name?: string |
| 30 | /** One sentence on what this preset is for. */ |
| 31 | readonly description?: string |
| 32 | /** |
| 33 | * Position within its group; lower comes first. A preset that declares |
| 34 | * none sorts after every preset that does, then by id — so the shipped set |
| 35 | * can read in capability order while authored ones stay alphabetical. |
| 36 | */ |
| 37 | readonly order?: number |
| 38 | } |
| 39 | |
| 40 | /** A non-empty trimmed string, or undefined for anything else. */ |
| 41 | function text(value: unknown): string | undefined { |
| 42 | if (typeof value !== 'string') return undefined |
| 43 | const trimmed = value.trim() |
| 44 | return trimmed === '' ? undefined : trimmed |
| 45 | } |
| 46 | |
| 47 | /** |
| 48 | * Read one preset directory's display metadata. |
| 49 | * |
| 50 | * Absent, unparsable, and wrongly-shaped files are all the same answer — |
| 51 | * empty metadata — because the caller renders a picker, not a diagnostic. |
| 52 | * @param directory - the preset directory. |
| 53 | * @returns the display text the preset published, possibly empty. |
| 54 | */ |
| 55 | export async function readPresetMetadata(directory: string, readFile: (path: string, encoding: string) => Promise<string>): Promise<PresetMetadata> { |
| 56 | let raw: string |
| 57 | try { |
| 58 | raw = await readFile(join(directory, METADATA_FILE), 'utf8') |
| 59 | } catch { |
| 60 | // Absent is the common case: metadata is optional and most presets, |
| 61 | // including every one authored by duplicating another, carry none. |
| 62 | return {} |
| 63 | } |
| 64 | let parsed: unknown |
| 65 | try { |
| 66 | parsed = yaml.load(raw) |
| 67 | } catch { |
| 68 | // Malformed display text is not worth failing discovery over; the picker |
| 69 | // falls back to the id, and the composition still mounts. |
| 70 | return {} |
| 71 | } |
| 72 | if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return {} |
| 73 | const record = parsed as Record<string, unknown> |
| 74 | const name = text(record.name) |
| 75 | const description = text(record.description) |
| 76 | const order = typeof record.order === 'number' && Number.isFinite(record.order) |
| 77 | ? record.order |
| 78 | : undefined |
| 79 | return { |
| 80 | ...name === undefined ? {} : { name }, |
| 81 | ...description === undefined ? {} : { description }, |
| 82 | ...order === undefined ? {} : { order }, |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | /** |
| 87 | * Render display metadata as the file's contents. |
| 88 | * |
| 89 | * Absent fields are omitted rather than written empty, so a preset with no |
| 90 | * description does not ship a key that reads as an intentional blank. |
| 91 | * @param metadata - the display text to store. |
| 92 | * @returns the YAML document, or undefined when there is nothing to store. |
| 93 | */ |
| 94 | export function renderPresetMetadata(metadata: PresetMetadata): string | undefined { |
| 95 | const name = text(metadata.name) |
| 96 | const description = text(metadata.description) |
| 97 | const { order } = metadata |
| 98 | if (name === undefined && description === undefined && order === undefined) return undefined |
| 99 | return yaml.dump({ |
| 100 | ...name === undefined ? {} : { name }, |
| 101 | ...description === undefined ? {} : { description }, |
| 102 | ...order === undefined ? {} : { order }, |
| 103 | }, { lineWidth: -1 }) |
| 104 | } |
| 105 |