diff --git a/src/databases/officer_db/src/command-hq/schema.ts b/src/databases/officer_db/src/command-hq/schema.ts index b40ad79b..87f5b1bf 100644 --- a/src/databases/officer_db/src/command-hq/schema.ts +++ b/src/databases/officer_db/src/command-hq/schema.ts @@ -3,6 +3,7 @@ import { doublePrecision, index, integer, + jsonb, pgTable, serial, text, @@ -117,10 +118,7 @@ export const commandHqSessions = pgTable( * its own, and an array supports appending in place (`array_append`) without reading the whole column * back to modify it — `jsonb` would buy nothing here but a heavier read-modify-write for the same list. */ - referencePaths: text('reference_paths') - .array() - .notNull() - .default([]), + referencePaths: text('reference_paths').array().notNull().default([]), /** * What the owner called it, held here rather than only in the transcript's summary record. * @@ -137,6 +135,29 @@ export const commandHqSessions = pgTable( categoryId: integer('category_id').references(() => commandHqCategories.id, { onDelete: 'set null' }), /** Should the platform keep re-arming this commander's watcher on every fire? See the file docstring. */ watcherArmed: boolean('watcher_armed').notNull().default(false), + + /** + * The plugin that shipped this commander's template, or null for one somebody made themselves. + * + * A COLUMN rather than a reserved category name, for the reason tasks carry the same field: + * provenance should be structural. A category called "av-club" is a string an owner can rename, + * merge or delete, and the day they do, a plugin's commanders quietly become ordinary ones — + * with nothing on disk saying otherwise. + * + * It also survives the plugin being uninstalled, which is the point: the commander is still there, + * still in its directory, and the UI can say what it came from rather than pretending it was + * always hand-made. + */ + plugin: text('plugin'), + + /** + * The owner's answers to the template's `inputs`, or null when there was no template. + * + * Held here rather than only in the generated watcher script because the form has to be + * re-openable: somebody changing which directory is watched should see what it is watching now, + * not an empty form. The script is generated FROM this, so this is the source of truth. + */ + pluginConfig: jsonb('plugin_config').$type>(), /** * The task id of the watcher currently believed live for this commander — set when armed (first arm, * and again on every automatic re-arm), read to correlate a `task:notification` back to "this is the diff --git a/src/servers/api/command-hq/plugin-commanders.ts b/src/servers/api/command-hq/plugin-commanders.ts new file mode 100644 index 00000000..b8ac92d6 --- /dev/null +++ b/src/servers/api/command-hq/plugin-commanders.ts @@ -0,0 +1,137 @@ +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); +} diff --git a/src/servers/capability-sources.ts b/src/servers/capability-sources.ts index 878c8dea..2d171b70 100644 --- a/src/servers/capability-sources.ts +++ b/src/servers/capability-sources.ts @@ -43,7 +43,9 @@ export type CapabilitySource = { plugin: string | null; }; -export async function capabilitySources(type: ItemType | 'affordances' | 'notifications'): Promise { +export async function capabilitySources( + type: ItemType | 'affordances' | 'notifications' | 'commanders', +): Promise { const sources: CapabilitySource[] = [{ dir: itemsDir(type as ItemType), plugin: null }]; let states: Awaited>['states'] = [];