newspaper_wedding/.aidlc/tools/aidlc-command.ts
Andrew Ridgway bec1eaac87
Some checks failed
Test / test (push) Has been cancelled
first pass at the newspaper builder
2026-09-14 11:57:22 +10:00

500 lines
14 KiB
TypeScript

import { readSync } from "node:fs";
import {
colorEnabled,
configureColor,
errorLabel,
fixLabel,
heading,
} from "./aidlc-color.ts";
export const ROUTE_NAMESPACE_DECLARATIONS = {
public: { trustedPrefix: false },
engine: { trustedPrefix: true },
system: { trustedPrefix: false },
} as const;
export type RouteNamespaceName = keyof typeof ROUTE_NAMESPACE_DECLARATIONS;
const trustedNamespaces = Object.entries(ROUTE_NAMESPACE_DECLARATIONS)
.filter(([, declaration]) => declaration.trustedPrefix)
.map(([namespace]) => namespace as RouteNamespaceName);
if (trustedNamespaces.length !== 1) {
throw new Error(
`route namespace declarations must identify exactly one trusted prefix; found ${trustedNamespaces.length}`,
);
}
export const TRUSTED_ROUTE_NAMESPACE = trustedNamespaces[0];
export const TRUSTED_COMMAND_PREFIX = `aidlc ${TRUSTED_ROUTE_NAMESPACE}`;
export const TRUSTED_COMMAND_TOKENS = ["aidlc", TRUSTED_ROUTE_NAMESPACE] as const;
export const UNTRUSTED_ROUTE_NAMESPACES = Object.keys(ROUTE_NAMESPACE_DECLARATIONS)
.filter((namespace) =>
namespace !== "public" && namespace !== TRUSTED_ROUTE_NAMESPACE
) as RouteNamespaceName[];
export function trustedCommand(suffix = ""): string {
return suffix ? `${TRUSTED_COMMAND_PREFIX} ${suffix}` : TRUSTED_COMMAND_PREFIX;
}
// Lightweight dispatcher grammar. aidlc-lib.ts retains the same public
// workspace helpers for methodology callers; keeping this copy in the existing
// command module avoids loading the full methodology graph at CLI startup.
export const PINNED_TOP_LEVEL_ROUTES = [
"next",
"continue",
"report",
"park",
"team-board",
"unit",
"compose",
"--claim",
"--release",
"--status",
"--resume",
"--scope",
] as const;
export const PINNED_SYSTEM_GROUPS = ["workspace-sync"] as const;
const LAUNCHER_FLAG_VALUES = new Set(["--project-dir"]);
const LAUNCHER_GLOBAL_FLAGS = new Set([
"--json",
"--quiet",
"--no-color",
"--yes",
"--offline",
"--verbose",
]);
export function launcherRouteUsesPin(argv: readonly string[]): boolean {
const route: string[] = [];
for (let index = 0; index < argv.length && route.length < 2; index++) {
const token = argv[index];
if (LAUNCHER_FLAG_VALUES.has(token)) {
index++;
continue;
}
if (LAUNCHER_GLOBAL_FLAGS.has(token)) continue;
route.push(token);
}
const [head, noun] = route;
if (head === "engine") {
return Boolean(noun && noun !== "--help" && noun !== "-h");
}
if (head === "system") {
return PINNED_SYSTEM_GROUPS.includes(
noun as (typeof PINNED_SYSTEM_GROUPS)[number],
);
}
return PINNED_TOP_LEVEL_ROUTES.includes(
head as (typeof PINNED_TOP_LEVEL_ROUTES)[number],
);
}
type DispatcherWorkspaceNoun = "intent" | "space";
const DISPATCHER_INTENT_VERBS = new Set(["list", "switch", "create"]);
const DISPATCHER_SPACE_VERBS = new Set(["list", "switch", "create"]);
const DISPATCHER_RESERVED_FUTURE = new Set([
"archive",
"rename",
"show",
"birth",
]);
export type DispatcherWorkspaceCommand =
| { kind: "list"; noun: DispatcherWorkspaceNoun; json: boolean }
| {
kind: "switch";
noun: DispatcherWorkspaceNoun;
name: string;
explicit: boolean;
}
| { kind: "create"; noun: "space"; name: string }
| { kind: "create-intent"; noun: "intent"; rest: string[] }
| { kind: "help"; noun: DispatcherWorkspaceNoun }
| {
kind: "error";
noun: DispatcherWorkspaceNoun;
message: string;
}
| { kind: "not-workspace" };
function missingDispatcherWorkspaceName(
noun: DispatcherWorkspaceNoun,
verb: "switch" | "create" | "space-create",
): DispatcherWorkspaceCommand {
const usage = verb === "space-create"
? "space-create <name>"
: `${noun} ${verb} <name>`;
return {
kind: "error",
noun,
message: `Usage: aidlc ${usage}`,
};
}
function isDispatcherWorkspaceNoun(
token: string | undefined,
): token is DispatcherWorkspaceNoun {
return token === "intent" || token === "space";
}
export function parseDispatcherWorkspaceCommand(
tokens: string[],
): DispatcherWorkspaceCommand {
const head = tokens[0];
if (head === "space-create") {
const name = tokens[1];
return name === undefined
? missingDispatcherWorkspaceName("space", "space-create")
: { kind: "create", noun: "space", name };
}
if (!isDispatcherWorkspaceNoun(head)) return { kind: "not-workspace" };
const noun = head;
const verbOrName = tokens[1];
if (verbOrName === undefined) return { kind: "list", noun, json: false };
if (verbOrName === "--json") return { kind: "list", noun, json: true };
if (verbOrName === "help" || verbOrName === "-h") {
return { kind: "help", noun };
}
if (DISPATCHER_RESERVED_FUTURE.has(verbOrName)) {
return {
kind: "error",
noun,
message:
`${noun} ${verbOrName} is reserved for a future workspace verb and is not implemented yet. ` +
`Use ${noun} switch ${verbOrName} to select an existing record with that name.`,
};
}
if (noun === "intent") {
if (verbOrName === "list") return { kind: "list", noun, json: tokens[2] === "--json" };
if (verbOrName === "switch") {
const name = tokens[2];
return name === undefined
? missingDispatcherWorkspaceName(noun, "switch")
: { kind: "switch", noun, name, explicit: true };
}
if (verbOrName === "create") {
return { kind: "create-intent", noun, rest: tokens.slice(2) };
}
}
if (noun === "space") {
if (verbOrName === "list") return { kind: "list", noun, json: tokens[2] === "--json" };
if (verbOrName === "switch") {
const name = tokens[2];
return name === undefined
? missingDispatcherWorkspaceName(noun, "switch")
: { kind: "switch", noun, name, explicit: true };
}
if (verbOrName === "create") {
const name = tokens[2];
return name === undefined
? missingDispatcherWorkspaceName(noun, "create")
: { kind: "create", noun, name };
}
}
if (
(noun === "intent" && DISPATCHER_INTENT_VERBS.has(verbOrName)) ||
(noun === "space" && DISPATCHER_SPACE_VERBS.has(verbOrName))
) {
return { kind: "error", noun, message: `invalid ${noun} command` };
}
return { kind: "switch", noun, name: verbOrName, explicit: false };
}
export function dispatcherWorkspaceUtilityArgv(
command: DispatcherWorkspaceCommand,
): string[] | null {
switch (command.kind) {
case "list":
return command.json ? [command.noun, "--json"] : [command.noun];
case "switch":
return command.explicit
? [command.noun, "switch", command.name]
: [command.noun, command.name];
case "create":
return ["space-create", command.name];
case "create-intent":
return ["intent-create", ...command.rest];
case "help":
return ["help"];
case "error":
case "not-workspace":
return null;
}
}
export type DispatcherPluginCommand =
| { kind: "not-plugin" }
| { kind: "help" }
| { kind: "error"; message: string }
| { kind: "run"; argv: string[] };
export function parseDispatcherPluginCommand(
args: string[],
): DispatcherPluginCommand {
if (args[0] !== "plugin") return { kind: "not-plugin" };
const verb = args[1];
if (verb === "help" || verb === "-h" || verb === "--help") {
return { kind: "help" };
}
const target = verb === "select"
? "select-plugins"
: verb === "list"
? "plugin-list"
: verb === "sync"
? "plugin-sync"
: verb === "validate"
? "plugin-validate"
: verb === "build"
? "plugin-build"
: undefined;
if (target !== undefined) {
return { kind: "run", argv: [target, ...args.slice(2)] };
}
const detail = verb ? `unknown verb '${verb}'` : "missing verb";
return {
kind: "error",
message: `aidlc: ${detail} for noun 'plugin'; try 'aidlc help --all'`,
};
}
export type NamespaceRouteShape = {
namespace: string;
group: string;
kind: string;
verbs: readonly string[];
};
export type NamespaceInvocation = {
file: string;
line: number;
source: "aidlc" | "aidlc";
namespace: "engine" | "system";
noun?: string;
verb?: string;
command: string;
resolves: boolean;
};
function cleanInvocationToken(token: string | undefined): string | undefined {
return token
?.replace(/^[([`"']+/, "")
.replace(/[\]),.:;\\`"']+$/, "");
}
export function namespaceInvocationResolves(
routes: readonly NamespaceRouteShape[],
namespace: "engine" | "system",
noun: string | undefined,
verb: string | undefined,
): boolean {
if (
!noun ||
noun === "*" ||
noun === ".*" ||
noun === "--help" ||
noun === "-h" ||
noun.includes("<") ||
noun.startsWith("$")
) {
return true;
}
const namespaceRoutes = routes.filter((route) => route.namespace === namespace);
if (
namespaceRoutes.some((route) =>
route.group === "top" && route.verbs.includes(noun)
)
) {
return true;
}
const grouped = namespaceRoutes.filter((route) => route.group === noun);
if (grouped.length === 0) return false;
if (!verb) return true;
if (verb.startsWith("<") || verb.startsWith("$")) return true;
if (verb.startsWith("--")) {
return grouped.some((route) => route.kind === "routing-only");
}
return grouped.some((route) =>
route.kind === "routing-only" ||
route.verbs.some((candidate) =>
candidate === verb ||
candidate.startsWith(`${verb} `) ||
candidate.startsWith("<")
)
);
}
export function scanNamespaceInvocations(
file: string,
value: string,
routes: readonly NamespaceRouteShape[],
): NamespaceInvocation[] {
const invocations: NamespaceInvocation[] = [];
for (const [index, line] of value.split(/\r?\n/).entries()) {
const invocation =
/(\baidlc|\{\{INVOKE\}\})\s+(engine|system)(?:\s+([^\s`"'|;&(){}]+))?(?:\s+([^\s`"'|;&(){}]+))?/g;
for (const match of line.matchAll(invocation)) {
const source = match[1] === "aidlc" ? "aidlc" : "aidlc";
const namespace = match[2] as "engine" | "system";
const noun = cleanInvocationToken(match[3]);
const verb = cleanInvocationToken(match[4]);
invocations.push({
file,
line: index + 1,
source,
namespace,
noun,
verb,
command: [source, namespace, noun, verb].filter(Boolean).join(" "),
resolves: namespaceInvocationResolves(routes, namespace, noun, verb),
});
}
}
return invocations;
}
export const EXIT = {
ok: 0,
failure: 1,
usage: 2,
unavailable: 3,
integrity: 4,
actionNeeded: 5,
} as const;
export type OutputMode = "human" | "quiet" | "json";
export type CommandResult = {
ok: boolean;
code: number;
status: string;
message: string;
data?: unknown;
remediation?: string;
};
export type GlobalOptions = {
mode: OutputMode;
color: boolean;
yes: boolean;
offline: boolean;
verbose: boolean;
};
export function globalOptions(argv: readonly string[]): GlobalOptions {
configureColor(argv);
return {
mode: argv.includes("--json") ? "json" : argv.includes("--quiet") ? "quiet" : "human",
color: colorEnabled(process.stdout),
yes: argv.includes("--yes"),
offline: argv.includes("--offline") || process.env.AIDLC_OFFLINE === "1",
verbose: argv.includes("--verbose"),
};
}
export function valueAfter(argv: readonly string[], flag: string): string | undefined {
const index = argv.indexOf(flag);
const value = index >= 0 ? argv[index + 1] : undefined;
return value && !value.startsWith("--") ? value : undefined;
}
export function valuesAfter(argv: readonly string[], flag: string): string[] {
const values: string[] = [];
for (let i = 0; i < argv.length; i++) {
if (argv[i] === flag && argv[i + 1] && !argv[i + 1].startsWith("--")) {
values.push(argv[++i]);
}
}
return values;
}
export function emitResult(result: CommandResult, options: GlobalOptions): void {
if (options.mode === "json") {
process.stdout.write(`${JSON.stringify({ schemaVersion: 1, ...result })}\n`);
} else if (options.mode === "quiet") {
const output = result.ok ? result.message : (result.remediation ?? result.message);
if (output) process.stdout.write(`${output}\n`);
} else {
if (result.ok) {
process.stdout.write(`${result.message}\n`);
} else if (result.code === EXIT.actionNeeded) {
process.stdout.write(`action: ${result.message}\n`);
} else {
process.stdout.write(`${errorLabel("error:", process.stdout)} ${result.message}\n`);
}
if (result.remediation) {
process.stdout.write(
`${
result.status === "usage"
? heading("usage:", process.stdout)
: fixLabel("fix:", process.stdout)
} ${result.remediation}\n`,
);
} else if (result.status === "usage") {
process.stdout.write(
`${heading("usage:", process.stdout)} rerun with --help for valid usage\n`,
);
}
}
process.exitCode = result.code;
}
export function usage(message: string, remediation?: string): CommandResult {
return { ok: false, code: EXIT.usage, status: "usage", message, remediation };
}
export function failure(
message: string,
code: number = EXIT.failure,
remediation?: string,
): CommandResult {
return { ok: false, code, status: "failed", message, remediation };
}
export function success(message: string, data?: unknown): CommandResult {
return { ok: true, code: EXIT.ok, status: "ok", message, data };
}
// Reads one answer from the terminal. Bun's global `prompt()` returns `null`
// for an empty line, which makes "press Enter to accept the default"
// indistinguishable from a cancelled prompt. This reader returns `""` for an
// empty line and `null` only when the input is closed (EOF), so callers can
// treat Enter as "accept the default" and EOF as "cancel". It reads one byte at
// a time so nothing past the newline is consumed from the input.
export function readTerminalLine(label: string, fd = 0): string | null {
process.stdout.write(`${label} `);
const bytes: number[] = [];
const byte = Buffer.alloc(1);
let sawNewline = false;
let sawEof = false;
while (!sawNewline && !sawEof) {
let read: number;
try {
read = readSync(fd, byte, 0, 1, null);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === "EAGAIN") {
Bun.sleepSync(10);
continue;
}
if (code === "EOF") {
sawEof = true;
continue;
}
throw error;
}
if (read === 0) {
sawEof = true;
} else if (byte[0] === 0x0a) {
sawNewline = true;
} else {
bytes.push(byte[0]);
}
}
if (sawEof && bytes.length === 0) return null;
return Buffer.from(bytes).toString("utf-8").replace(/\r$/, "");
}