second half of the backend. GET /command-hq/plugin-commanders lists what the
installed plugins offer; POST /commanders takes {plugin, template, config} and
builds one from it.
the list is SEPARATE from GET /commanders, which returns what exists. merging a
thing that runs with a thing that could be made, distinguished by a flag, means
every consumer has to remember which it is holding.
`watch_dir` doubles as the reference path when a template supplies one. asking for
a reference path AND a watch directory is asking the same question twice and
inviting the two answers to disagree.
THREE FILES, EACH A COPY rather than a reference into the plugin:
state/config.env what the owner answered, in the form the watcher sources
watch.sh copied, so plugins:update cannot change the script a commander
is already running — a watcher whose code moves under it while
armed is a thing nobody could debug
AGENTS.md the template's prose, appended to the brief already written
config.env is generated with every value SINGLE-QUOTED, and that is not decoration:
WATCH_DIR is a path somebody typed, and this fleet has directories called `ABOUT US`
and `70´S`. unquoted, the first space ends the assignment and the watcher watches
the wrong folder — with `rm` downstream of that. verified by sourcing both that path
and one containing an apostrophe in a real shell.
THE ARM GUARD LIVES IN armCommandHqWatcher, not in a route. arming is not a button:
it happens when the agent starts a tracked background job and the platform notices.
a check anywhere else is one the agent routes around by simply starting its loop,
which is exactly what it does. a row with no template is unaffected.
verified end to end on the running server: the template listed, a commander created
from it, watch.sh + config.env (mode 600) + the brief on disk, config.env sourcing
cleanly, and the watcher refusing to start when config.env is absent — which is the
configure-before-running rule enforcing itself at the last line of defence.
still to come: the Command HQ section that shows the offers and draws the form.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
171 lines
7.0 KiB
TypeScript
171 lines
7.0 KiB
TypeScript
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/<name>/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<string, CommanderInput>;
|
||
/** 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<string, unknown>; 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<string, unknown>) ?? {}, 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<PluginCommanderTemplate[]> {
|
||
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<string, CommanderInput>) ?? {},
|
||
body,
|
||
dir,
|
||
});
|
||
}
|
||
}
|
||
|
||
return found.sort((a, b) => a.name.localeCompare(b.name));
|
||
}
|
||
|
||
export async function getPluginCommander(plugin: string, dirName: string): Promise<PluginCommanderTemplate | null> {
|
||
// 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<string, CommanderInput>,
|
||
config: Record<string, string> | 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<string, CommanderInput>,
|
||
config: Record<string, string> | 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';
|
||
}
|