/** * schema.ts — Declarative command/flag registry for schema introspection. * * Every command and flag is described in a single registry. This registry is * the source of truth for: * 1. `woco describe ` — emits a JSON schema of accepted args * 2. Help text generation * 3. Shell completion scripts * * The registry data is now generated by the citty-bridge from citty * command definitions. See citty-registry.ts for the bridge mapping. * Types are defined in schema-types.ts to avoid circular dependencies. */ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; // Re-export types and GLOBAL_FLAGS from schema-types for backward compatibility. // All existing consumers import from schema.ts — these re-exports keep them working. export type { FlagDef, PositionalDef, CommandDef } from "./schema-types"; export { GLOBAL_FLAGS } from "./schema-types"; import type { CommandDef, FlagDef } from "./schema-types"; import { GLOBAL_FLAGS } from "./schema-types"; // --------------------------------------------------------------------------- // Dynamic version reader // --------------------------------------------------------------------------- function getVersion(): string { try { const pkgPath = resolve(import.meta.dir, "../../package.json"); const raw = readFileSync(pkgPath, "utf-8"); return JSON.parse(raw).version as string; } catch { return "unknown"; } } // --------------------------------------------------------------------------- // Alias derivation helpers // --------------------------------------------------------------------------- /** * Build a { alias → canonicalName } map from a list of CommandDefs. * For subcommands, extracts the short name (e.g. "list" from "tasks list"). */ export function buildAliasMap(commands: CommandDef[]): Record { const map: Record = {}; for (const cmd of commands) { // For subcommands like "tasks list", derive the short name "list" const shortName = cmd.name.includes(" ") ? cmd.name.split(" ").pop()! : cmd.name; for (const alias of cmd.aliases ?? []) { map[alias] = shortName; } } return map; } /** * Get all flags for a command: command-specific flags merged with global * flags, deduplicating by flag name (command-specific takes priority). */ export function getCommandFlags(cmd: CommandDef): FlagDef[] { const seen = new Set(cmd.flags.map((f) => f.name)); return [...cmd.flags, ...GLOBAL_FLAGS.filter((f) => !seen.has(f.name))]; } // --------------------------------------------------------------------------- // Command Registry — sourced from citty-bridge (lazy to break circular init) // --------------------------------------------------------------------------- // Lazy-initialized to break the circular dependency: // schema.ts → citty-registry.ts → describe.ts/help.ts → schema.ts // The registry is only resolved on first access, by which time all modules // have finished their top-level initialization. let _registry: CommandDef[] | null = null; function getRegistry(): CommandDef[] { if (!_registry) { // eslint-disable-next-line @typescript-eslint/no-require-imports const { BRIDGE_REGISTRY } = require("./citty-registry.js") as { BRIDGE_REGISTRY: CommandDef[]; }; _registry = BRIDGE_REGISTRY; } return _registry; } /** * The command registry, now generated from citty command definitions. * Uses a Proxy to lazily resolve the underlying BRIDGE_REGISTRY, avoiding * circular initialization errors at module load time. */ export const COMMAND_REGISTRY: CommandDef[] = new Proxy([] as CommandDef[], { get(_target, prop, receiver) { return Reflect.get(getRegistry(), prop, receiver); }, has(_target, prop) { return Reflect.has(getRegistry(), prop); }, ownKeys() { return Reflect.ownKeys(getRegistry()); }, getOwnPropertyDescriptor(_target, prop) { return Object.getOwnPropertyDescriptor(getRegistry(), prop); }, set(_target, prop, value, receiver) { return Reflect.set(getRegistry(), prop, value, receiver); }, }); // --------------------------------------------------------------------------- // Lookup helpers // --------------------------------------------------------------------------- /** * Find a command definition by name. Supports compound names like "tasks list". */ export function findCommandDef(name: string): CommandDef | undefined { // Lazy import to avoid circular dependency const { findBridgeCommandDef } = require("./citty-registry.js") as { findBridgeCommandDef: (name: string) => CommandDef | undefined; }; return findBridgeCommandDef(name); } /** * Produce the JSON schema object for a command, suitable for agent consumption. */ export function commandToSchema(cmd: CommandDef): Record { const schema: Record = { command: cmd.name, summary: cmd.summary, mutating: cmd.mutating, supports_dry_run: cmd.supportsDryRun, }; if (cmd.aliases?.length) { schema.aliases = cmd.aliases; } if (cmd.description) { schema.description = cmd.description; } if (cmd.positionals.length > 0) { schema.positionals = cmd.positionals.map((p) => ({ name: p.name, description: p.description, required: p.required ?? false, })); } const allFlags = [...cmd.flags, ...GLOBAL_FLAGS]; schema.flags = allFlags.map((f) => { const entry: Record = { name: f.name, type: f.type, description: f.description, }; if (f.alias) entry.alias = f.alias; if (f.default !== undefined) entry.default = f.default; if (f.enum) entry.enum = f.enum; if (f.required) entry.required = true; return entry; }); if (cmd.subcommands?.length) { schema.subcommands = cmd.subcommands.map((sc) => { const scEntry: Record = { name: sc.name }; if (sc.aliases?.length) scEntry.aliases = sc.aliases; return scEntry; }); } return schema; } /** * Produce a listing of all commands (for `woco describe` with no args). */ export function allCommandSchemas(): Record { const commands: Record[] = []; for (const cmd of COMMAND_REGISTRY) { const entry: Record = { name: cmd.name, summary: cmd.summary, mutating: cmd.mutating, supports_dry_run: cmd.supportsDryRun, }; if (cmd.subcommands) { entry.subcommands = cmd.subcommands.map((sc) => { const scEntry: Record = { name: sc.name, summary: sc.summary, mutating: sc.mutating, supports_dry_run: sc.supportsDryRun, }; if (sc.aliases?.length) { scEntry.aliases = sc.aliases; } return scEntry; }); } commands.push(entry); } return { tool: "wombo-combo", version: getVersion(), global_flags: GLOBAL_FLAGS.map((f) => ({ name: f.name, alias: f.alias, type: f.type, description: f.description, default: f.default, })), commands, }; } // --------------------------------------------------------------------------- // Global help renderer (for `woco help` / `woco -h`) // --------------------------------------------------------------------------- /** * Render the top-level help screen from the command registry. * Keeps the output in sync with registered commands and their aliases. */ export function renderGlobalHelp(): string { const lines: string[] = []; lines.push(""); lines.push("wombo-combo — AI Agent Orchestration System"); lines.push(""); lines.push(" WOMBO COMBO! Parallel feature development with AI agents."); lines.push(""); // Build display entries: name, alias hint, summary const entries = COMMAND_REGISTRY.map((cmd) => { const hint = aliasHint(cmd); let extra = ""; if (cmd.subcommands?.length) { const scNames = cmd.subcommands.map((sc) => { const short = sc.name.includes(" ") ? sc.name.split(" ").pop()! : sc.name; return short; }); extra = ` (subtopics: ${scNames.join(", ")})`; } return { name: cmd.name, hint, summary: cmd.summary + extra }; }); const maxNameLen = Math.max(...entries.map((e) => e.name.length)); const maxHintLen = Math.max(...entries.map((e) => e.hint.length)); lines.push(`Commands:${" ".repeat(maxNameLen + maxHintLen - 3)}(alias)`); for (const e of entries) { const paddedName = e.name.padEnd(maxNameLen + 2); const paddedHint = e.hint.padEnd(maxHintLen ? maxHintLen + 2 : 0); lines.push(` ${paddedName}${paddedHint}${e.summary}`); } lines.push(""); lines.push("Run 'woco -h' for details on a specific command."); lines.push(""); return lines.join("\n"); } // --------------------------------------------------------------------------- // Per-command help renderer (for `woco -h`) // --------------------------------------------------------------------------- /** * Render human-readable help text for a single command. * Returns a formatted string ready to console.log(). */ export function renderCommandHelp(cmdName: string, subcommand?: string): string | null { // Build lookup name: "tasks list", "quest create", etc. const lookupName = subcommand ? `${cmdName} ${subcommand}` : cmdName; const cmd = findCommandDef(lookupName); // If a parent command has subcommands, show the parent overview if (!subcommand && !cmd) return null; if (!cmd) return null; // If this is a parent command with subcommands, show subcommand listing if (cmd.subcommands?.length) { return renderParentHelp(cmd); } // Single command help return renderSingleCommandHelp(cmd); } /** Compact alias hint like "(ls)" or "(rm/del/d)". Returns "" if no aliases. */ function aliasHint(cmd: CommandDef): string { if (!cmd.aliases?.length) return ""; return `(${cmd.aliases.join("/")})`; } function renderParentHelp(cmd: CommandDef): string { const lines: string[] = []; const parentHint = aliasHint(cmd); lines.push(""); lines.push(`woco ${cmd.name}${parentHint ? ` ${parentHint}` : ""} — ${cmd.summary}`); if (cmd.description) { lines.push(""); lines.push(` ${cmd.description}`); } lines.push(""); lines.push("Subcommands:"); // Extract the short name from compound names ("tasks list" -> "list") const shortName = (sc: CommandDef) => { const parts = sc.name.split(" "); return parts[parts.length - 1]; }; // Build display entries: "name (alias) summary" with alignment const entries = cmd.subcommands!.map((sc) => { const name = shortName(sc); const hint = aliasHint(sc); return { name, hint, summary: sc.summary }; }); const maxNameLen = Math.max(...entries.map((e) => e.name.length)); const maxHintLen = Math.max(...entries.map((e) => e.hint.length)); for (const e of entries) { const paddedName = e.name.padEnd(maxNameLen + 2); const paddedHint = e.hint.padEnd(maxHintLen ? maxHintLen + 2 : 0); lines.push(` ${paddedName}${paddedHint}${e.summary}`); } lines.push(""); lines.push(`Run 'woco ${cmd.name} -h' for details on a subcommand.`); lines.push(""); return lines.join("\n"); } function renderSingleCommandHelp(cmd: CommandDef): string { const lines: string[] = []; // Usage line const positionalStr = cmd.positionals .map((p) => (p.required ? `<${p.name}>` : `[${p.name}]`)) .join(" "); const flagHint = cmd.flags.length > 0 ? " [options]" : ""; const hint = aliasHint(cmd); lines.push(""); lines.push(`woco ${cmd.name}${hint ? ` ${hint}` : ""}${positionalStr ? " " + positionalStr : ""}${flagHint}`); lines.push(""); lines.push(` ${cmd.summary}`); if (cmd.description) { lines.push(` ${cmd.description}`); } // Positionals if (cmd.positionals.length > 0) { lines.push(""); lines.push("Arguments:"); const maxPosLen = Math.max(...cmd.positionals.map((p) => p.name.length)); for (const p of cmd.positionals) { const req = p.required ? " (required)" : ""; const padded = p.name.padEnd(maxPosLen + 2); lines.push(` ${padded}${p.description}${req}`); } } // Flags (deduplicate: command-specific flags take priority over globals) const seenFlags = new Set(cmd.flags.map((f) => f.name)); const allFlags = [...cmd.flags, ...GLOBAL_FLAGS.filter((f) => !seenFlags.has(f.name))]; if (allFlags.length > 0) { lines.push(""); lines.push("Options:"); const maxFlagLen = Math.max( ...allFlags.map((f) => { const aliasStr = f.alias ? `, ${f.alias}` : ""; const typeStr = f.type !== "boolean" ? ` <${f.type === "string[]" ? "values" : f.type}>` : ""; return (f.name + aliasStr + typeStr).length; }) ); for (const f of allFlags) { const aliasStr = f.alias ? `, ${f.alias}` : ""; const typeStr = f.type !== "boolean" ? ` <${f.type === "string[]" ? "values" : f.type}>` : ""; const flagLabel = `${f.name}${aliasStr}${typeStr}`; const padded = flagLabel.padEnd(maxFlagLen + 2); const enumStr = f.enum ? ` [${f.enum.join("|")}]` : ""; const defaultStr = f.default !== undefined && f.default !== false ? ` (default: ${f.default})` : ""; lines.push(` ${padded}${f.description}${enumStr}${defaultStr}`); } } lines.push(""); return lines.join("\n"); }