import { readdir } from 'node:fs/promises'; import { join } from 'node:path'; import { capabilitySources } from '../../capability-sources'; // Bun's own YAML, the same one `task-files.ts` uses. Not the `yaml` package — it is not a dependency, // and adding one to read four lines of frontmatter is not a trade worth making. const YAML = (Bun as unknown as { YAML: { parse(input: string): unknown } }).YAML; /** * Commanders a plugin ships, ready to be created but not running. * * ── Presence is the declaration ── * * A plugin with a `commanders//COMMANDER.md` offers one. Nothing is added to `manifest.ts`, for * the same reason `tasks/` and `skills/` are not declared there: the directory already says it, and a * second place to say it is a second place for the two to disagree. * * Read IN PLACE, and only from installed-and-enabled plugins — `capabilitySources` handles both. So a * disabled plugin stops offering its commander, and `plugins:update` changes the template without * anything having to be republished. * * ── A template is not a commander ── * * What is here is an OFFER. Nothing runs, nothing is scheduled, and no directory exists until the * owner creates one from it and fills in the inputs. That is the whole shape the owner asked for: * a plugin may propose recurring work; only a person may start it. * * ── Why the inputs reuse TASK.md's vocabulary ── * * `inputs:` here is the same structure a task declares, so the same form renderer draws it and the * same validation applies. A commander asking "which folder, which extensions, what output" is asking * exactly what a task asks, and inventing a second schema for it would mean two form renderers to * keep in step for no gain. */ export type CommanderInput = { type: string; description?: string; default?: string; options?: string[]; /** Unset and required means the commander cannot arm. See `missingInputs`. */ required?: boolean; }; export type PluginCommanderTemplate = { /** Directory name, unique within its plugin — `heic-to-webp`. */ dirName: string; /** The plugin that ships it. Never null: an owner's own commanders do not come from a template. */ plugin: string; name: string; description: string; /** What it watches, in a sentence, for the card. */ watches?: string; inputs: Record; /** The prose that becomes the commander's `CLAUDE.md`. */ body: string; /** Absolute path to the template directory, so the watcher script can be read at creation. */ dir: string; }; function parse(raw: string): { fm: Record; body: string } { const match = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/); if (!match) return { fm: {}, body: raw }; try { return { fm: (YAML.parse(match[1]!) as Record) ?? {}, body: match[2]! }; } catch { // A template with unparseable frontmatter is skipped by the caller rather than crashing the list — // one plugin's bad YAML must not take away every other plugin's commanders. return { fm: {}, body: match[2]! }; } } export async function listPluginCommanders(): Promise { const sources = await capabilitySources('commanders'); const found: PluginCommanderTemplate[] = []; for (const source of sources) { // Only plugins offer these. The owner's own `capabilities/commanders/` is not a thing that exists: // a commander somebody makes themselves is made in the UI, not from a template on disk. if (!source.plugin) continue; let entries; try { entries = await readdir(source.dir, { withFileTypes: true }); } catch { continue; // most plugins ship none — ordinary, not a fault } for (const entry of entries) { if (!entry.isDirectory()) continue; const dir = join(source.dir, entry.name); const file = Bun.file(join(dir, 'COMMANDER.md')); if (!(await file.exists())) continue; const { fm, body } = parse(await file.text()); if (!fm.name) continue; // no name means the frontmatter did not parse — skip it quietly found.push({ dirName: entry.name, plugin: source.plugin, name: String(fm.name), description: String(fm.description ?? ''), watches: fm.watches ? String(fm.watches) : undefined, inputs: (fm.inputs as Record) ?? {}, body, dir, }); } } return found.sort((a, b) => a.name.localeCompare(b.name)); } export async function getPluginCommander(plugin: string, dirName: string): Promise { // Both halves reach this from a request body and are joined onto a path below. const unsafe = (v: string) => !v || v.includes('/') || v.includes('\\') || v === '.' || v === '..'; if (unsafe(plugin) || unsafe(dirName)) return null; const all = await listPluginCommanders(); return all.find((t) => t.plugin === plugin && t.dirName === dirName) ?? null; } /** * Which required inputs have no answer. * * This is the "configure before running" rule, and it is enforced at ARM time rather than at creation. * Creating a half-configured commander is fine and useful — it exists, it is visible, and it can be * finished later. Arming one is what would run somebody's disk against a blank setting. */ export function missingInputs( inputs: Record, config: Record | null | undefined, ): string[] { return Object.entries(inputs) .filter(([key, spec]) => spec.required && !(config?.[key] ?? spec.default ?? '').trim()) .map(([key]) => key); } /** * Everything a created commander needs on disk beyond its `CLAUDE.md`. * * ── `config.env`, not a JSON file ── * * The watcher is a shell script and this is what it sources. A JSON config would mean the script * parsing JSON in bash, which is either `jq` (another dependency for four values) or something * hand-rolled that breaks on the first path with a space in it. The database row remains the source * of truth; this is a projection of it that bash can read. * * ── Every value is quoted, and that is not decoration ── * * `WATCH_DIR` is a path the owner typed, and this fleet has directories called `ABOUT US` and * `70´S`. Unquoted, the first space ends the assignment and the watcher silently watches the wrong * folder — or, with `rm` involved downstream, the wrong folder's contents. */ export function configEnv( inputs: Record, config: Record | null | undefined, ): string { const lines = ['# Written by Officer when this commander was created. Edit through the UI, not here.']; for (const [key, spec] of Object.entries(inputs)) { const value = config?.[key] ?? spec.default ?? ''; // Single quotes, with any embedded single quote closed and re-opened around an escaped one — the // standard shell trick, and the only form that is safe for arbitrary text. const quoted = `'${String(value).replace(/'/g, `'\\''`)}'`; lines.push(`${key.toUpperCase()}=${quoted}`); } return lines.join('\n') + '\n'; }