hologit
Version:
Hologit automates the projection of layered composite file trees based on flat, declarative plans
1,451 lines (1,424 loc) • 54.1 kB
JavaScript
#!/usr/bin/env node
// GENERATED by scripts/build-cli.ts from src/cli/ — do not edit. Run `bun run build`.
// node_modules/axi-sdk-js/dist/cli.js
import { basename } from "node:path";
// node_modules/axi-sdk-js/dist/errors.js
var AxiError = class extends Error {
code;
suggestions;
constructor(message, code, suggestions = []) {
super(message);
this.code = code;
this.suggestions = suggestions;
this.name = "AxiError";
}
};
function exitCodeForError(error) {
if (error instanceof AxiError && error.code === "VALIDATION_ERROR") {
return 2;
}
return 1;
}
// node_modules/axi-sdk-js/dist/output.js
import { homedir } from "node:os";
// node_modules/@toon-format/toon/dist/index.mjs
var NULL_LITERAL = "null";
var DELIMITERS = {
comma: ",",
tab: " ",
pipe: "|"
};
var DEFAULT_DELIMITER = DELIMITERS.comma;
function escapeString(value) {
return value.replace(/\\/g, `\\\\`).replace(/"/g, `\\"`).replace(/\n/g, `\\n`).replace(/\r/g, `\\r`).replace(/\t/g, `\\t`).replace(/[\u0000-\u001F]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
}
function isBooleanOrNullLiteral(token) {
return token === "true" || token === "false" || token === "null";
}
function normalizeValue(value) {
if (value === null) return null;
if (typeof value === "object" && value !== null && "toJSON" in value && typeof value.toJSON === "function") {
const next = value.toJSON();
if (next !== value) return normalizeValue(next);
}
if (typeof value === "string" || typeof value === "boolean") return value;
if (typeof value === "number") {
if (Object.is(value, -0)) return 0;
if (!Number.isFinite(value)) return null;
return value;
}
if (typeof value === "bigint") {
if (value >= Number.MIN_SAFE_INTEGER && value <= Number.MAX_SAFE_INTEGER) return Number(value);
return value.toString();
}
if (value instanceof Date) return value.toISOString();
if (Array.isArray(value)) return value.map(normalizeValue);
if (value instanceof Set) return Array.from(value).map(normalizeValue);
if (value instanceof Map) return Object.fromEntries(Array.from(value, ([k, v]) => [String(k), normalizeValue(v)]));
if (isPlainObject(value)) {
const encodedValues = {};
for (const key in value) if (Object.hasOwn(value, key)) encodedValues[key] = normalizeValue(value[key]);
return encodedValues;
}
return null;
}
function isJsonPrimitive(value) {
return value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean";
}
function isJsonArray(value) {
return Array.isArray(value);
}
function isJsonObject(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function isEmptyObject(value) {
return Object.keys(value).length === 0;
}
function isPlainObject(value) {
if (value === null || typeof value !== "object") return false;
const prototype = Object.getPrototypeOf(value);
return prototype === null || prototype === Object.prototype;
}
function isArrayOfPrimitives(value) {
return value.length === 0 || value.every((item) => isJsonPrimitive(item));
}
function isArrayOfArrays(value) {
return value.length === 0 || value.every((item) => isJsonArray(item));
}
function isArrayOfObjects(value) {
return value.length === 0 || value.every((item) => isJsonObject(item));
}
var NUMERIC_LIKE_PATTERN = /^-?\d+(?:\.\d+)?(?:e[+-]?\d+)?$/i;
var LEADING_ZERO_PATTERN = /^0\d+$/;
function isValidUnquotedKey(key) {
return /^[A-Z_][\w.]*$/i.test(key);
}
function isIdentifierSegment(key) {
return /^[A-Z_]\w*$/i.test(key);
}
function isSafeUnquoted(value, delimiter = DEFAULT_DELIMITER) {
if (!value) return false;
if (value !== value.trim()) return false;
if (isBooleanOrNullLiteral(value) || isNumericLike(value)) return false;
if (value.includes(":")) return false;
if (value.includes('"') || value.includes("\\")) return false;
if (/[[\]{}]/.test(value)) return false;
if (/[\u0000-\u001F]/.test(value)) return false;
if (value.includes(delimiter)) return false;
if (value.startsWith("-")) return false;
return true;
}
function isNumericLike(value) {
return NUMERIC_LIKE_PATTERN.test(value) || LEADING_ZERO_PATTERN.test(value);
}
function tryFoldKeyChain(key, value, siblings, options, rootLiteralKeys, pathPrefix, flattenDepth) {
if (options.keyFolding !== "safe") return;
if (!isJsonObject(value)) return;
const { segments, tail, leafValue } = collectSingleKeyChain(key, value, flattenDepth ?? options.flattenDepth);
if (segments.length < 2) return;
if (!segments.every((seg) => isIdentifierSegment(seg))) return;
const foldedKey = buildFoldedKey(segments);
const absolutePath = pathPrefix ? `${pathPrefix}.${foldedKey}` : foldedKey;
if (siblings.includes(foldedKey)) return;
if (rootLiteralKeys && rootLiteralKeys.has(absolutePath)) return;
return {
foldedKey,
remainder: tail,
leafValue,
segmentCount: segments.length
};
}
function collectSingleKeyChain(startKey, startValue, maxDepth) {
const segments = [startKey];
let currentValue = startValue;
while (segments.length < maxDepth) {
if (!isJsonObject(currentValue)) break;
const keys = Object.keys(currentValue);
if (keys.length !== 1) break;
const nextKey = keys[0];
const nextValue = currentValue[nextKey];
segments.push(nextKey);
currentValue = nextValue;
}
if (!isJsonObject(currentValue) || isEmptyObject(currentValue)) return {
segments,
tail: void 0,
leafValue: currentValue
};
return {
segments,
tail: currentValue,
leafValue: currentValue
};
}
function buildFoldedKey(segments) {
return segments.join(".");
}
function encodePrimitive(value, delimiter) {
if (value === null) return NULL_LITERAL;
if (typeof value === "boolean") return String(value);
if (typeof value === "number") return String(value);
return encodeStringLiteral(value, delimiter);
}
function encodeStringLiteral(value, delimiter = DEFAULT_DELIMITER) {
if (isSafeUnquoted(value, delimiter)) return value;
return `"${escapeString(value)}"`;
}
function encodeKey(key) {
if (isValidUnquotedKey(key)) return key;
return `"${escapeString(key)}"`;
}
function encodeAndJoinPrimitives(values, delimiter = DEFAULT_DELIMITER) {
return values.map((v) => encodePrimitive(v, delimiter)).join(delimiter);
}
function formatHeader(length, options) {
const key = options?.key;
const fields = options?.fields;
const delimiter = options?.delimiter ?? ",";
let header = "";
if (key != null) header += encodeKey(key);
header += `[${length}${delimiter !== DEFAULT_DELIMITER ? delimiter : ""}]`;
if (fields) {
const quotedFields = fields.map((f) => encodeKey(f));
header += `{${quotedFields.join(delimiter)}}`;
}
header += ":";
return header;
}
function* encodeJsonValue(value, options, depth) {
if (isJsonPrimitive(value)) {
const encodedPrimitive = encodePrimitive(value, options.delimiter);
if (encodedPrimitive !== "") yield encodedPrimitive;
return;
}
if (isJsonArray(value)) yield* encodeArrayLines(void 0, value, depth, options);
else if (isJsonObject(value)) yield* encodeObjectLines(value, depth, options);
}
function* encodeObjectLines(value, depth, options, rootLiteralKeys, pathPrefix, remainingDepth) {
const keys = Object.keys(value);
if (depth === 0 && !rootLiteralKeys) rootLiteralKeys = new Set(keys.filter((k) => k.includes(".")));
const effectiveFlattenDepth = remainingDepth ?? options.flattenDepth;
for (const [key, val] of Object.entries(value)) yield* encodeKeyValuePairLines(key, val, depth, options, keys, rootLiteralKeys, pathPrefix, effectiveFlattenDepth);
}
function* encodeKeyValuePairLines(key, value, depth, options, siblings, rootLiteralKeys, pathPrefix, flattenDepth) {
const currentPath = pathPrefix ? `${pathPrefix}.${key}` : key;
const effectiveFlattenDepth = flattenDepth ?? options.flattenDepth;
if (options.keyFolding === "safe" && siblings) {
const foldResult = tryFoldKeyChain(key, value, siblings, options, rootLiteralKeys, pathPrefix, effectiveFlattenDepth);
if (foldResult) {
const { foldedKey, remainder, leafValue, segmentCount } = foldResult;
const encodedFoldedKey = encodeKey(foldedKey);
if (remainder === void 0) {
if (isJsonPrimitive(leafValue)) {
yield indentedLine(depth, `${encodedFoldedKey}: ${encodePrimitive(leafValue, options.delimiter)}`, options.indent);
return;
} else if (isJsonArray(leafValue)) {
yield* encodeArrayLines(foldedKey, leafValue, depth, options);
return;
} else if (isJsonObject(leafValue) && isEmptyObject(leafValue)) {
yield indentedLine(depth, `${encodedFoldedKey}:`, options.indent);
return;
}
}
if (isJsonObject(remainder)) {
yield indentedLine(depth, `${encodedFoldedKey}:`, options.indent);
const remainingDepth = effectiveFlattenDepth - segmentCount;
const foldedPath = pathPrefix ? `${pathPrefix}.${foldedKey}` : foldedKey;
yield* encodeObjectLines(remainder, depth + 1, options, rootLiteralKeys, foldedPath, remainingDepth);
return;
}
}
}
const encodedKey = encodeKey(key);
if (isJsonPrimitive(value)) yield indentedLine(depth, `${encodedKey}: ${encodePrimitive(value, options.delimiter)}`, options.indent);
else if (isJsonArray(value)) yield* encodeArrayLines(key, value, depth, options);
else if (isJsonObject(value)) {
yield indentedLine(depth, `${encodedKey}:`, options.indent);
if (!isEmptyObject(value)) yield* encodeObjectLines(value, depth + 1, options, rootLiteralKeys, currentPath, effectiveFlattenDepth);
}
}
function* encodeArrayLines(key, value, depth, options) {
if (value.length === 0) {
yield indentedLine(depth, key != null ? `${encodeKey(key)}: []` : "[]", options.indent);
return;
}
if (isArrayOfPrimitives(value)) {
yield indentedLine(depth, encodeInlineArrayLine(value, options.delimiter, key), options.indent);
return;
}
if (isArrayOfArrays(value)) {
if (value.every((arr) => isArrayOfPrimitives(arr))) {
yield* encodeArrayOfArraysAsListItemsLines(key, value, depth, options);
return;
}
}
if (isArrayOfObjects(value)) {
const header = extractTabularHeader(value);
if (header) yield* encodeArrayOfObjectsAsTabularLines(key, value, header, depth, options);
else yield* encodeMixedArrayAsListItemsLines(key, value, depth, options);
return;
}
yield* encodeMixedArrayAsListItemsLines(key, value, depth, options);
}
function* encodeArrayOfArraysAsListItemsLines(prefix, values, depth, options) {
yield indentedLine(depth, formatHeader(values.length, {
key: prefix,
delimiter: options.delimiter
}), options.indent);
for (const arr of values) if (isArrayOfPrimitives(arr)) {
const arrayLine = encodeInlineArrayLine(arr, options.delimiter);
yield indentedListItem(depth + 1, arrayLine, options.indent);
}
}
function encodeInlineArrayLine(values, delimiter, prefix) {
const header = formatHeader(values.length, {
key: prefix,
delimiter
});
const joinedValue = encodeAndJoinPrimitives(values, delimiter);
if (values.length === 0) return header;
return `${header} ${joinedValue}`;
}
function* encodeArrayOfObjectsAsTabularLines(prefix, rows, header, depth, options) {
yield indentedLine(depth, formatHeader(rows.length, {
key: prefix,
fields: header,
delimiter: options.delimiter
}), options.indent);
yield* writeTabularRowsLines(rows, header, depth + 1, options);
}
function extractTabularHeader(rows) {
if (rows.length === 0) return;
const firstRow = rows[0];
const firstKeys = Object.keys(firstRow);
if (firstKeys.length === 0) return;
if (isTabularArray(rows, firstKeys)) return firstKeys;
}
function isTabularArray(rows, header) {
for (const row of rows) {
if (Object.keys(row).length !== header.length) return false;
for (const key of header) {
if (!(key in row)) return false;
if (!isJsonPrimitive(row[key])) return false;
}
}
return true;
}
function* writeTabularRowsLines(rows, header, depth, options) {
for (const row of rows) yield indentedLine(depth, encodeAndJoinPrimitives(header.map((key) => row[key]), options.delimiter), options.indent);
}
function* encodeMixedArrayAsListItemsLines(prefix, items, depth, options) {
yield indentedLine(depth, formatHeader(items.length, {
key: prefix,
delimiter: options.delimiter
}), options.indent);
for (const item of items) yield* encodeListItemValueLines(item, depth + 1, options);
}
function* encodeObjectAsListItemLines(obj, depth, options) {
if (isEmptyObject(obj)) {
yield indentedLine(depth, "-", options.indent);
return;
}
const entries = Object.entries(obj);
const [firstKey, firstValue] = entries[0];
const restEntries = entries.slice(1);
if (isJsonArray(firstValue) && isArrayOfObjects(firstValue)) {
const header = extractTabularHeader(firstValue);
if (header) {
yield indentedListItem(depth, formatHeader(firstValue.length, {
key: firstKey,
fields: header,
delimiter: options.delimiter
}), options.indent);
yield* writeTabularRowsLines(firstValue, header, depth + 2, options);
if (restEntries.length > 0) yield* encodeObjectLines(Object.fromEntries(restEntries), depth + 1, options);
return;
}
}
const encodedKey = encodeKey(firstKey);
if (isJsonPrimitive(firstValue)) yield indentedListItem(depth, `${encodedKey}: ${encodePrimitive(firstValue, options.delimiter)}`, options.indent);
else if (isJsonArray(firstValue)) if (firstValue.length === 0) yield indentedListItem(depth, `${encodedKey}: []`, options.indent);
else if (isArrayOfPrimitives(firstValue)) yield indentedListItem(depth, `${encodedKey}${encodeInlineArrayLine(firstValue, options.delimiter)}`, options.indent);
else {
yield indentedListItem(depth, `${encodedKey}${formatHeader(firstValue.length, { delimiter: options.delimiter })}`, options.indent);
for (const item of firstValue) yield* encodeListItemValueLines(item, depth + 2, options);
}
else if (isJsonObject(firstValue)) {
yield indentedListItem(depth, `${encodedKey}:`, options.indent);
if (!isEmptyObject(firstValue)) yield* encodeObjectLines(firstValue, depth + 2, options);
}
if (restEntries.length > 0) yield* encodeObjectLines(Object.fromEntries(restEntries), depth + 1, options);
}
function* encodeListItemValueLines(value, depth, options) {
if (isJsonPrimitive(value)) yield indentedListItem(depth, encodePrimitive(value, options.delimiter), options.indent);
else if (isJsonArray(value)) if (isArrayOfPrimitives(value)) yield indentedListItem(depth, encodeInlineArrayLine(value, options.delimiter), options.indent);
else {
yield indentedListItem(depth, formatHeader(value.length, { delimiter: options.delimiter }), options.indent);
for (const item of value) yield* encodeListItemValueLines(item, depth + 1, options);
}
else if (isJsonObject(value)) yield* encodeObjectAsListItemLines(value, depth, options);
}
function indentedLine(depth, content, indentSize) {
return " ".repeat(indentSize * depth) + content;
}
function indentedListItem(depth, content, indentSize) {
return indentedLine(depth, "- " + content, indentSize);
}
function applyReplacer(root, replacer) {
const replacedRoot = replacer("", root, []);
if (replacedRoot === void 0) return transformChildren(root, replacer, []);
return transformChildren(normalizeValue(replacedRoot), replacer, []);
}
function transformChildren(value, replacer, path) {
if (isJsonObject(value)) return transformObject(value, replacer, path);
if (isJsonArray(value)) return transformArray(value, replacer, path);
return value;
}
function transformObject(obj, replacer, path) {
const result = {};
for (const [key, value] of Object.entries(obj)) {
const childPath = [...path, key];
const replacedValue = replacer(key, value, childPath);
if (replacedValue === void 0) continue;
result[key] = transformChildren(normalizeValue(replacedValue), replacer, childPath);
}
return result;
}
function transformArray(arr, replacer, path) {
const result = [];
for (let i = 0; i < arr.length; i++) {
const value = arr[i];
const childPath = [...path, i];
const replacedValue = replacer(String(i), value, childPath);
if (replacedValue === void 0) continue;
const normalizedValue = normalizeValue(replacedValue);
result.push(transformChildren(normalizedValue, replacer, childPath));
}
return result;
}
function encode(input, options) {
return Array.from(encodeLines(input, options)).join("\n");
}
function encodeLines(input, options) {
const normalizedValue = normalizeValue(input);
const resolvedOptions = resolveOptions(options);
return encodeJsonValue(resolvedOptions.replacer ? applyReplacer(normalizedValue, resolvedOptions.replacer) : normalizedValue, resolvedOptions, 0);
}
function resolveOptions(options) {
return {
indent: options?.indent ?? 2,
delimiter: options?.delimiter ?? DEFAULT_DELIMITER,
keyFolding: options?.keyFolding ?? "off",
flattenDepth: options?.flattenDepth ?? Number.POSITIVE_INFINITY,
replacer: options?.replacer
};
}
// node_modules/axi-sdk-js/dist/output.js
function collapseHomeDirectory(path, homeDir = homedir()) {
if (!path.startsWith(homeDir)) {
return path;
}
return `~${path.slice(homeDir.length)}`;
}
function homeHeaderOutput(options) {
return {
bin: collapseHomeDirectory(options.execPath ?? process.argv[1] ?? "", options.homeDir),
description: options.description
};
}
function errorOutput(message, code, suggestions = []) {
const output = {
error: message,
code
};
if (suggestions.length > 0) {
output.help = suggestions;
}
return output;
}
function renderOutput(output) {
if (typeof output === "string") {
return output;
}
return encode(output);
}
function renderError(message, code, suggestions = []) {
return renderOutput(errorOutput(message, code, suggestions));
}
// node_modules/axi-sdk-js/dist/cli.js
function defaultFormatError(error) {
if (error instanceof AxiError) {
return {
output: `${renderError(error.message, error.code, error.suggestions)}
`,
exitCode: exitCodeForError(error)
};
}
const message = error instanceof Error ? error.message : String(error);
return {
output: `${renderError(message, "UNKNOWN")}
`,
exitCode: 1
};
}
function defaultUnknownCommand(command) {
return `${renderError(`Unknown command: ${command}`, "VALIDATION_ERROR", [
"Run `--help` to see available commands"
])}
`;
}
async function runAxiCli(options) {
options.initialize?.();
const stdout = options.stdout ?? process.stdout;
const argv = options.argv ?? process.argv.slice(2);
if (argv.length === 1 && argv[0] === "--help") {
stdout.write(options.topLevelHelp);
return;
}
if (argv.length === 1 && isVersionFlag(argv[0])) {
if (!options.version) {
stdout.write(`${renderError("Version is not configured for this tool", "VALIDATION_ERROR")}
`);
process.exitCode = 2;
return;
}
stdout.write(`${options.version}
`);
return;
}
const command = argv[0];
if (!command) {
const context2 = await options.resolveContext?.({
command: void 0,
args: []
});
await runHandler(options.home, [], context2, stdout, options, true);
return;
}
if (command.startsWith("-")) {
stdout.write(renderLeadingFlagError(command));
process.exitCode = 2;
return;
}
const args = argv.slice(1);
if (args.includes("--help")) {
const help = options.getCommandHelp?.(command);
if (help) {
stdout.write(help);
return;
}
}
const handler = options.commands[command];
if (!handler) {
stdout.write((options.renderUnknownCommand ?? defaultUnknownCommand)(command));
process.exitCode = 2;
return;
}
const context = await options.resolveContext?.({ command, args });
await runHandler(handler, args, context, stdout, options, false);
}
async function runHandler(handler, args, context, stdout, options, isHomeView) {
try {
const output = await handler(args, context);
stdout.write(`${renderCommandOutput(output, options, isHomeView)}
`);
} catch (error) {
const formatted = (options.formatError ?? defaultFormatError)(error);
stdout.write(formatted.output);
process.exitCode = formatted.exitCode;
}
}
function renderLeadingFlagError(flag) {
const bin = basename(process.argv[1] ?? "tool") || "tool";
return `${renderError("Flags must come after the command", "VALIDATION_ERROR", [
`Run \`${bin} <command> [args] [flags]\``,
`Move \`${flag}\` after the command instead of before it`
])}
`;
}
function isVersionFlag(flag) {
return flag === "-v" || flag === "-V" || flag === "--version";
}
function renderCommandOutput(output, options, isHomeView) {
if (!isHomeView) {
return renderOutput(output);
}
const header = homeHeaderOutput({ description: options.description });
if (typeof output === "string") {
return `${renderOutput(header)}
${output}`;
}
return renderOutput(mergeHomeHeader(header, output));
}
function mergeHomeHeader(header, output) {
const rest = { ...output };
delete rest.bin;
delete rest.description;
return {
...header,
...rest
};
}
// node_modules/axi-sdk-js/dist/hooks.js
function isManagedHook(hook, marker) {
return typeof hook?.command === "string" && hook.command.includes(marker);
}
function computeSessionStartHookUpdate(settings, spec) {
const updated = structuredClone(settings);
let changed = false;
if (!updated.hooks) {
updated.hooks = {};
changed = true;
}
if (Array.isArray(updated.hooks.session_start)) {
const legacyHooks = updated.hooks.session_start.filter((hook) => !isManagedHook(hook, spec.marker));
if (legacyHooks.length !== updated.hooks.session_start.length) {
changed = true;
if (legacyHooks.length === 0) {
delete updated.hooks.session_start;
} else {
updated.hooks.session_start = legacyHooks;
}
}
}
if (!Array.isArray(updated.hooks.SessionStart)) {
updated.hooks.SessionStart = [];
changed = true;
}
for (const group of updated.hooks.SessionStart) {
if (!Array.isArray(group.hooks)) {
continue;
}
for (const hook of group.hooks) {
if (!isManagedHook(hook, spec.marker)) {
continue;
}
const timeout = spec.timeoutSeconds ?? 10;
const isCorrect = hook.command === spec.command && hook.type === "command" && hook.timeout === timeout;
if (isCorrect && !changed) {
return [settings, false];
}
hook.command = spec.command;
hook.type = "command";
hook.timeout = timeout;
return [updated, true];
}
}
updated.hooks.SessionStart.push({
matcher: "",
hooks: [
{
type: "command",
command: spec.command,
timeout: spec.timeoutSeconds ?? 10
}
]
});
return [updated, true];
}
// src/cli/reference.ts
var DESCRIPTION = "Query the SpecOps plans DAG for the current repo \u2014 what's ready to work on, what's blocked, and the dependency graph. A thin determinism layer over a files-first plans/ workflow.";
// src/cli/args.ts
function parseArgs(args, booleanFlags = []) {
const positionals = [];
const flags = {};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg.startsWith("--")) {
const eq = arg.indexOf("=");
if (eq !== -1) {
flags[arg.slice(2, eq)] = arg.slice(eq + 1);
continue;
}
const name = arg.slice(2);
if (booleanFlags.includes(name)) {
flags[name] = true;
} else {
const value = args[++i];
if (value === void 0) {
throw new AxiError(`Flag --${name} requires a value`, "VALIDATION_ERROR");
}
flags[name] = value;
}
} else {
positionals.push(arg);
}
}
return { positionals, flags };
}
function plansDirArg(parsed) {
const dir = parsed.flags.dir;
if (typeof dir === "string" && dir) return dir;
return parsed.positionals[0] ?? "./plans";
}
// src/cli/plans.ts
import { readFileSync, readdirSync, statSync, existsSync } from "node:fs";
import { basename as basename2, join } from "node:path";
var VALID_STATUSES = [
"planned",
"in-progress",
"done",
"blocked",
"cancelled"
];
function readFrontmatter(filePath) {
const text = readFileSync(filePath, "utf8");
const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (!match) return null;
return match[1];
}
function parseScalar(block, key) {
const re = new RegExp(`^${key}:\\s*(.+?)\\s*$`, "m");
const m = block.match(re);
if (!m) return null;
return m[1].replace(/^["']|["']$/g, "");
}
function parseInlineList(block, key) {
const re = new RegExp(`^${key}:\\s*\\[(.*?)\\]\\s*$`, "m");
const m = block.match(re);
if (!m) return null;
return m[1].split(",").map((s) => s.trim().replace(/^["']|["']$/g, "")).filter(Boolean);
}
function parseBlockList(block, key) {
const lines = block.split(/\r?\n/);
const startRe = new RegExp(`^${key}:\\s*$`);
let i = lines.findIndex((l) => startRe.test(l));
if (i === -1) return null;
const items = [];
for (i += 1; i < lines.length; i += 1) {
const line = lines[i];
const m = line.match(/^\s+-\s+(.+?)\s*$/);
if (!m) break;
items.push(m[1].replace(/^["']|["']$/g, ""));
}
return items;
}
function parseList(block, key) {
const inline = parseInlineList(block, key);
if (inline !== null) return inline;
const blockList = parseBlockList(block, key);
if (blockList !== null) return blockList;
return [];
}
function parsePlan(filePath) {
const fm = readFrontmatter(filePath);
if (fm === null) return null;
const slug = basename2(filePath, ".md");
const status2 = parseScalar(fm, "status") || "unknown";
const depends = parseList(fm, "depends");
const awaits = parseList(fm, "awaits");
const pr = parseScalar(fm, "pr");
return {
slug,
file: filePath,
status: status2,
depends,
awaits,
pr: pr ? Number(pr) : null
};
}
function loadPlans(dir) {
const stat = statSync(dir);
if (!stat.isDirectory()) {
throw new Error(`not a directory: ${dir}`);
}
const entries = readdirSync(dir).filter((n) => n.endsWith(".md")).filter((n) => n !== "README.md").filter((n) => !n.startsWith("_"));
const plans = /* @__PURE__ */ new Map();
const warnings = [];
for (const name of entries) {
const full = join(dir, name);
const plan = parsePlan(full);
if (plan === null) {
warnings.push(`${name}: no YAML frontmatter, skipping`);
continue;
}
if (!VALID_STATUSES.includes(plan.status)) {
warnings.push(`${plan.slug}: unknown status "${plan.status}"`);
}
plans.set(plan.slug, plan);
}
for (const plan of plans.values()) {
for (const dep of plan.depends) {
if (!plans.has(dep)) {
warnings.push(`${plan.slug}: depends on "${dep}" which has no plan file`);
}
}
}
for (const plan of plans.values()) {
if (plan.status !== "blocked") continue;
if (plan.awaits.length > 0) continue;
const hasOpenDeps = plan.depends.some((dep) => {
const d = plans.get(dep);
return d && d.status !== "done" && d.status !== "cancelled";
});
if (!hasOpenDeps) {
warnings.push(
`${plan.slug}: status: blocked with no awaits: and no unfinished depends \u2014 what's blocking it?`
);
}
}
return { plans, warnings };
}
function topoSort(plans) {
const inDegree = /* @__PURE__ */ new Map();
const dependents = /* @__PURE__ */ new Map();
for (const slug of plans.keys()) {
inDegree.set(slug, 0);
dependents.set(slug, []);
}
for (const plan of plans.values()) {
for (const dep of plan.depends) {
if (!plans.has(dep)) continue;
inDegree.set(plan.slug, inDegree.get(plan.slug) + 1);
dependents.get(dep).push(plan.slug);
}
}
const ready = [];
for (const [slug, deg] of inDegree.entries()) {
if (deg === 0) ready.push(slug);
}
ready.sort();
const order = [];
while (ready.length > 0) {
const slug = ready.shift();
order.push(slug);
for (const child of dependents.get(slug)) {
inDegree.set(child, inDegree.get(child) - 1);
if (inDegree.get(child) === 0) {
ready.push(child);
ready.sort();
}
}
}
const cycles = [];
for (const [slug, deg] of inDegree.entries()) {
if (deg > 0) cycles.push(slug);
}
return { order, cycles };
}
function computeDownstreamCounts(plans) {
const dependents = /* @__PURE__ */ new Map();
for (const slug of plans.keys()) dependents.set(slug, []);
for (const plan of plans.values()) {
for (const dep of plan.depends) {
if (dependents.has(dep)) dependents.get(dep).push(plan.slug);
}
}
const isOpen = (slug) => {
const p = plans.get(slug);
return !!p && p.status !== "done" && p.status !== "cancelled";
};
const memo = /* @__PURE__ */ new Map();
function walk(slug, seen) {
if (memo.has(slug)) return memo.get(slug);
if (seen.has(slug)) return /* @__PURE__ */ new Set();
seen.add(slug);
const reachable = /* @__PURE__ */ new Set();
for (const child of dependents.get(slug)) {
if (isOpen(child)) reachable.add(child);
for (const r of walk(child, seen)) reachable.add(r);
}
seen.delete(slug);
memo.set(slug, reachable);
return reachable;
}
const counts = /* @__PURE__ */ new Map();
for (const slug of plans.keys()) {
counts.set(slug, walk(slug, /* @__PURE__ */ new Set()).size);
}
return counts;
}
function classify(plan, plans) {
const openDeps = [];
for (const dep of plan.depends) {
const depPlan = plans.get(dep);
if (!depPlan) {
openDeps.push(`${dep} (no plan file)`);
continue;
}
if (depPlan.status !== "done" && depPlan.status !== "cancelled") {
openDeps.push(`${dep} [${depPlan.status}]`);
}
}
if (plan.status === "in-progress") return { state: "in-progress", openDeps };
if (plan.status === "blocked") return { state: "blocked-status", openDeps };
if (plan.awaits.length > 0) return { state: "awaiting", openDeps };
if (openDeps.length > 0) return { state: "blocked-by-deps", openDeps };
return { state: "ready", openDeps };
}
function analyzePlans(dir) {
const { plans, warnings } = loadPlans(dir);
const downstream = computeDownstreamCounts(plans);
const { order, cycles } = topoSort(plans);
if (cycles.length > 0) {
warnings.push(`cycle detected among: ${cycles.join(", ")}`);
}
const ready = [];
const inProgress = [];
const awaiting = [];
const blockedByDeps = [];
const blockedByStatus = [];
const done = [];
const cancelled = [];
const slugs = order.length ? order : [...plans.keys()];
for (const slug of slugs) {
const plan = plans.get(slug);
if (plan.status === "done") {
done.push(plan);
continue;
}
if (plan.status === "cancelled") {
cancelled.push(plan);
continue;
}
const c = classify(plan, plans);
const entry = { plan, openDeps: c.openDeps };
if (c.state === "ready") ready.push(entry);
else if (c.state === "in-progress") inProgress.push(entry);
else if (c.state === "awaiting") awaiting.push(entry);
else if (c.state === "blocked-status") blockedByStatus.push(entry);
else blockedByDeps.push(entry);
}
ready.sort((a, b) => {
const da = downstream.get(a.plan.slug) || 0;
const db = downstream.get(b.plan.slug) || 0;
if (db !== da) return db - da;
return a.plan.slug.localeCompare(b.plan.slug);
});
return {
plans,
warnings,
cycles,
downstream,
ready,
inProgress,
awaiting,
blockedByDeps,
blockedByStatus,
done,
cancelled
};
}
function plansDirExists(dir) {
return existsSync(dir) && statSync(dir).isDirectory();
}
// src/cli/toon.ts
function renderObject(obj) {
return encode(obj);
}
function renderList(label, items) {
return encode({ [label]: items });
}
function renderLines(label, lines) {
const clean = lines.filter(Boolean);
if (clean.length === 0) return "";
return `${label}[${clean.length}]:
${clean.map((l) => ` ${l}`).join("\n")}`;
}
function renderHelp(lines) {
return renderLines("help", lines);
}
function renderOutput2(blocks) {
return blocks.filter(Boolean).join("\n");
}
// src/cli/invocation.ts
import { accessSync, constants } from "node:fs";
import { homedir as homedir2 } from "node:os";
import { fileURLToPath } from "node:url";
var cached;
function cliInvocation() {
if (cached) return cached;
let bundle;
try {
bundle = fileURLToPath(import.meta.url);
} catch {
bundle = process.argv[1] ?? "specops";
}
const shim = bundle.replace(/\.mjs$/, "");
try {
if (shim !== bundle) {
accessSync(shim, constants.X_OK);
cached = quote(collapseHome(shim));
return cached;
}
} catch {
}
cached = `node ${quote(collapseHome(bundle))}`;
return cached;
}
function collapseHome(p) {
const home = homedir2();
return home && p.startsWith(`${home}/`) ? `~${p.slice(home.length)}` : p;
}
function quote(p) {
return /\s/.test(p) ? `"${p}"` : p;
}
// src/cli/commands/common.ts
import { resolve } from "node:path";
import { homedir as homedir3 } from "node:os";
function resolvePlansDir(parsed) {
return resolve(plansDirArg(parsed));
}
function collapseHome2(p) {
const home = homedir3();
return home && p.startsWith(`${home}/`) ? `~${p.slice(home.length)}` : p;
}
function summaryLine(a) {
return `${a.ready.length} ready \xB7 ${a.inProgress.length} in-progress \xB7 ${a.awaiting.length} awaiting \xB7 ${a.blockedByDeps.length} blocked-by-deps \xB7 ${a.blockedByStatus.length} blocked \xB7 ${a.done.length} done \xB7 ${a.cancelled.length} cancelled`;
}
function joinAwaits(c) {
return c.plan.awaits.join("; ");
}
function joinDeps(c) {
return c.openDeps.length ? c.openDeps.join("; ") : "none";
}
// src/cli/git.ts
import { execFileSync } from "node:child_process";
var UNIT = "";
function recentlyDone(plansDir, doneSlugs, limit) {
if (doneSlugs.length === 0 || limit <= 0) return [];
let out;
try {
out = execFileSync(
"git",
[
"log",
"-p",
"-U0",
"--no-color",
"-G",
"status:",
`--format=${UNIT}%H %cs`,
"--",
...doneSlugs.map((s) => `${s}.md`)
],
{
cwd: plansDir,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
maxBuffer: 32 * 1024 * 1024
}
);
} catch {
return [];
}
const wanted = new Set(doneSlugs);
const seen = /* @__PURE__ */ new Set();
const results = [];
let commit = "";
let date = "";
let file;
for (const line of out.split("\n")) {
if (line.startsWith(UNIT)) {
const sp = line.indexOf(" ");
commit = line.slice(1, sp);
date = line.slice(sp + 1).trim();
file = void 0;
continue;
}
if (line.startsWith("+++ ")) {
const m = line.match(/([^/\s]+)\.md/);
file = m ? m[1] : void 0;
continue;
}
if (line.startsWith("--- ")) continue;
if (file && !seen.has(file) && wanted.has(file) && /^\+status:\s*done\b/.test(line)) {
seen.add(file);
results.push({ slug: file, date, commit });
if (results.length >= limit) break;
}
}
return results;
}
// src/cli/commands/home.ts
var RECENT_DONE_LIMIT = 3;
async function homeCommand(args) {
const parsed = parseArgs(args, []);
const dir = resolvePlansDir(parsed);
const cli = cliInvocation();
if (!plansDirExists(dir)) {
return renderOutput2([
renderObject({ plans: `no plans/ directory in ${collapseHome2(dir)}` }),
renderHelp([
"This repo has no plans/ yet \u2014 add one to track work-in-flight (see references/plans-protocol.md)",
`Run \`${cli} --help\` to see the full command list, or \`${cli} <command> --help\` for usage on any command`
])
]);
}
const a = analyzePlans(dir);
if (a.plans.size === 0) {
return renderOutput2([
renderObject({ plans: `0 plan files in ${collapseHome2(dir)}` }),
renderHelp([
"Add a plan file under plans/ to begin (see references/plans-protocol.md)",
`Run \`${cli} --help\` to see the full command list, or \`${cli} <command> --help\` for usage on any command`
])
]);
}
const blocks = [renderObject({ summary: summaryLine(a) })];
if (a.inProgress.length) {
blocks.push(
renderList(
"in_progress",
a.inProgress.map((r) => ({ slug: r.plan.slug, unblocks: a.downstream.get(r.plan.slug) || 0 }))
)
);
}
if (a.ready.length) {
blocks.push(
renderList(
"ready",
a.ready.map((r) => ({ slug: r.plan.slug, unblocks: a.downstream.get(r.plan.slug) || 0 }))
)
);
}
const blocked = [...a.awaiting, ...a.blockedByDeps, ...a.blockedByStatus];
if (blocked.length) {
blocks.push(
renderList(
"blocked",
blocked.map((c) => ({ slug: c.plan.slug, why: whyBlocked(c) }))
)
);
}
const recent = recentlyDone(
dir,
a.done.map((p) => p.slug),
RECENT_DONE_LIMIT
);
if (recent.length) {
const prBySlug = new Map(a.done.map((p) => [p.slug, p.pr]));
blocks.push(
renderList(
"recently_done",
recent.map((r) => ({ slug: r.slug, done: r.date, pr: prBySlug.get(r.slug) ?? null }))
)
);
}
if (a.warnings.length) {
blocks.push(renderLines("warnings", a.warnings));
}
blocks.push(
renderHelp([
`Run \`${cli} next\` for the full readiness breakdown`,
`Run \`${cli} dag --fence\` to visualize the dependency graph`,
`Run \`${cli} --help\` to see the full command list, or \`${cli} <command> --help\` for usage on any command`
])
);
return renderOutput2(blocks);
}
function whyBlocked(c) {
const more = (rest) => rest > 0 ? ` (+${rest} more)` : "";
if (c.plan.awaits.length) {
return `awaits: ${c.plan.awaits[0]}${more(c.plan.awaits.length - 1)}`;
}
if (c.openDeps.length) {
return `needs: ${c.openDeps[0]}${more(c.openDeps.length - 1)}`;
}
return `status: ${c.plan.status}`;
}
// src/cli/commands/next.ts
var NEXT_HELP = `usage: specops next [plans-dir] [flags]
Plans ordered by readiness. Sections (each emitted only when non-empty):
ready deps all done AND awaits empty \u2014 sorted so the plan that
unblocks the most downstream work appears first
in_progress (only with --include-in-progress) someone's already on it
awaiting non-empty awaits: \u2014 external blockers called out
blocked_by_deps awaits empty but one or more deps still open
blocked status: blocked (lifecycle explicitly blocked)
done/cancelled plans are omitted; the summary line carries their counts.
flags:
--dir <path> plans directory (default ./plans; also positional)
--include-in-progress also list in-progress plans
--slugs-only print ready slugs only, one per line (scripting)
--help
examples:
specops next
specops next --include-in-progress
specops next --slugs-only`;
async function nextCommand(args) {
const parsed = parseArgs(args, ["include-in-progress", "slugs-only"]);
const dir = resolvePlansDir(parsed);
const cli = cliInvocation();
if (!plansDirExists(dir)) {
return renderOutput2([
renderObject({ plans: `no plans/ directory at ${collapseHome2(dir)}` }),
renderHelp([
"Start one by adding a plan file under plans/ (see references/plans-protocol.md)",
`Run \`${cli} --help\` for usage`
])
]);
}
const a = analyzePlans(dir);
if (a.plans.size === 0) {
return renderOutput2([
renderObject({ plans: `0 plan files in ${collapseHome2(dir)} (looked for *.md, excluding README.md and _*.md)` }),
renderHelp(["Add a plan file under plans/ to begin (see references/plans-protocol.md)"])
]);
}
if (parsed.flags["slugs-only"]) {
return a.ready.map((r) => r.plan.slug).join("\n");
}
const unblocks = (slug) => a.downstream.get(slug) || 0;
const blocks = [renderObject({ summary: summaryLine(a) })];
if (a.ready.length) {
blocks.push(
renderList(
"ready",
a.ready.map((r) => ({ slug: r.plan.slug, unblocks: unblocks(r.plan.slug) }))
)
);
}
if (parsed.flags["include-in-progress"] && a.inProgress.length) {
blocks.push(
renderList(
"in_progress",
a.inProgress.map((r) => ({
slug: r.plan.slug,
unblocks: unblocks(r.plan.slug),
awaits: r.plan.awaits.length ? joinAwaits(r) : "none"
}))
)
);
}
if (a.awaiting.length) {
blocks.push(
renderList(
"awaiting",
a.awaiting.map((r) => ({ slug: r.plan.slug, awaits: joinAwaits(r), deps: joinDeps(r) }))
)
);
}
if (a.blockedByDeps.length) {
blocks.push(
renderList(
"blocked_by_deps",
a.blockedByDeps.map((r) => ({ slug: r.plan.slug, deps: joinDeps(r) }))
)
);
}
if (a.blockedByStatus.length) {
blocks.push(
renderList(
"blocked",
a.blockedByStatus.map((r) => ({
slug: r.plan.slug,
awaits: r.plan.awaits.length ? joinAwaits(r) : "none",
deps: joinDeps(r)
}))
)
);
}
if (a.warnings.length) {
blocks.push(renderLines("warnings", a.warnings));
}
const help = [];
if (a.ready.length) {
help.push("Read the top ready plan's file, then mark it in-progress to start");
}
if (!parsed.flags["include-in-progress"] && a.inProgress.length) {
help.push(`Run \`${cli} next --include-in-progress\` to also see in-flight plans`);
}
help.push(`Run \`${cli} dag --fence\` to visualize the dependency graph`);
blocks.push(renderHelp(help));
return renderOutput2(blocks);
}
// src/cli/commands/dag.ts
var DAG_HELP = `usage: specops dag [plans-dir] [flags]
Emit a Mermaid graph of the plans DAG, nodes styled by status:
planned light gray \xB7 in-progress amber \xB7 done green (PR # appended) \xB7
blocked red \xB7 cancelled dashed gray (hidden unless --include-cancelled).
Plans with non-empty awaits: get a dashed border. Edges follow depends:.
flags:
--dir <path> plans directory (default ./plans; also positional)
--direction TB|LR|BT|RL graph direction (default TB)
--fence wrap output in a \`\`\`mermaid fence
--include-cancelled render cancelled nodes
--help
examples:
specops dag --fence
specops dag --direction LR`;
var DIRECTIONS = ["TB", "LR", "BT", "RL"];
function nodeId(slug) {
return slug.replace(/[^A-Za-z0-9_]/g, "_");
}
function nodeShape(plan) {
const id = nodeId(plan.slug);
let label = plan.slug;
if (plan.status === "done" && plan.pr) label += `<br/>PR #${plan.pr}`;
label = label.replace(/"/g, """);
if (plan.status === "blocked") return `${id}(["${label}"])`;
if (plan.status === "cancelled") return `${id}["${label}"]`;
return `${id}("${label}")`;
}
function statusClass(status2) {
switch (status2) {
case "planned":
return "planned";
case "in-progress":
return "inProgress";
case "done":
return "done";
case "blocked":
return "blocked";
case "cancelled":
return "cancelled";
default:
return "unknown";
}
}
function renderMermaid(plans, direction, includeCancelled) {
const lines = [`graph ${direction}`];
const shown = /* @__PURE__ */ new Map();
for (const plan of plans.values()) {
if (plan.status === "cancelled" && !includeCancelled) continue;
shown.set(plan.slug, plan);
lines.push(` ${nodeShape(plan)}`);
}
for (const plan of shown.values()) {
for (const dep of plan.depends) {
if (!shown.has(dep)) continue;
lines.push(` ${nodeId(dep)} --> ${nodeId(plan.slug)}`);
}
}
lines.push("");
lines.push(" classDef planned fill:#f3f4f6,stroke:#9ca3af,color:#111827");
lines.push(" classDef inProgress fill:#fef3c7,stroke:#d97706,color:#78350f");
lines.push(" classDef done fill:#d1fae5,stroke:#059669,color:#064e3b");
lines.push(" classDef blocked fill:#fee2e2,stroke:#dc2626,color:#7f1d1d");
lines.push(" classDef cancelled fill:#e5e7eb,stroke:#9ca3af,color:#6b7280,stroke-dasharray:5 5");
lines.push(" classDef awaits stroke-dasharray:3 3,stroke-width:2px");
for (const plan of shown.values()) {
lines.push(` class ${nodeId(plan.slug)} ${statusClass(plan.status)}`);
if (plan.awaits.length > 0) {
lines.push(` class ${nodeId(plan.slug)} awaits`);
}
}
return lines.join("\n") + "\n";
}
async function dagCommand(args) {
const parsed = parseArgs(args, ["fence", "include-cancelled"]);
const direction = typeof parsed.flags.direction === "string" ? parsed.flags.direction : "TB";
if (!DIRECTIONS.includes(direction)) {
throw new AxiError(`--direction must be one of ${DIRECTIONS.join(", ")} (got ${direction})`, "VALIDATION_ERROR", [
"specops dag --direction LR"
]);
}
const dir = resolvePlansDir(parsed);
const cli = cliInvocation();
if (!plansDirExists(dir)) {
return renderOutput2([
renderObject({ plans: `no plans/ directory at ${collapseHome2(dir)}` }),
renderHelp([`Run \`${cli} --help\` for usage`])
]);
}
const { plans, warnings } = loadPlans(dir);
if (plans.size === 0) {
return renderObject({ plans: `0 plan files in ${collapseHome2(dir)} \u2014 nothing to graph` });
}
const body = renderMermaid(plans, direction, parsed.flags["include-cancelled"] === true);
const diagram = parsed.flags.fence ? "```mermaid\n" + body + "```" : body.replace(/\n$/, "");
return renderOutput2([diagram, renderLines("warnings", warnings)]);
}
// src/cli/commands/hook.ts
import { readFileSync as readFileSync2, writeFileSync, mkdirSync, existsSync as existsSync2 } from "node:fs";
import { homedir as homedir4 } from "node:os";
import { dirname, join as join2, resolve as resolve2 } from "node:path";
import { fileURLToPath as fileURLToPath2 } from "node:url";
import { execSync } from "node:child_process";
var HOOK_HELP = `usage: specops hook <install|uninstall> [--scope project|global] [--dir <path>]
specops hook status
Manage the SessionStart hook that loads this repo's plans dashboard at the start
of every agent session \u2014 so an agent sees what's ready / blocked from turn 1.
Default scope is project: the hook is written to <repo>/.claude/settings.json, so
it only fires for sessions in that repo and reads its plans/ (sessions start at
the repo root). --dir sets the repo root (default: the git repo root of the
current directory). Use --scope global to install it in ~/.claude/settings.json
for every session instead.
hook install project hook for the current repo (default)
hook install --dir ../other-repo project hook for another repo
hook install --scope global hook for every session, any repo
hook uninstall [--scope project|global] remove it (default scope project)
hook status report where the hook is installed`;
var MARKER = "specops";
var TIMEOUT_SECONDS = 10;
function gitRoot() {
try {
const root = execSync("git rev-parse --show-toplevel", {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"]
}).trim();
return root || void 0;
} catch {
return void 0;
}
}
function projectBase(flags) {
const dir = flags.dir;
if (typeof dir === "string" && dir) return resolve2(dir);
const root = gitRoot();
if (root) return root;
throw new AxiError(
"Couldn't determine the project directory (current directory is not a git repository)",
"VALIDATION_ERROR",
["Pass --dir <project-path>, or run from inside the target repo"]
);
}
function resolveScope(flags) {
const scope = flags.scope;
if (scope === void 0) return "project";
if (scope !== "project" && scope !== "global") {
throw new AxiError("--scope must be project or global", "VALIDATION_ERROR", [
"specops hook install (project \u2014 the default)",
"specops hook install --scope global"
]);
}
return scope;
}
function settingsPath(scope, flags) {
const base = scope === "global" ? homedir4() : projectBase(flags);
return join2(base, ".claude", "settings.json");
}
function shimPath() {
return join2(dirname(fileURLToPath2(import.meta.url)), "specops");
}
var PROJECT_HOOK_COMMAND = '"${CLAUDE_PROJECT_DIR}/.claude/skills/specops/scripts/specops"';
function hookCommand(scope) {
return scope === "global" ? JSON.stringify(shimPath()) : PROJECT_HOOK_COMMAND;
}
function readSettings(path) {
if (!existsSync2(path)) return {};
try {
const parsed = JSON.parse(readFileSync2(path, "utf8"));
return parsed && typeof parsed === "object" ? parsed : {};
} catch {
throw new AxiError(`Could not parse ${path}`, "CONFIG_INVALID", ["Fix or remove the malformed JSON file"]);
}
}
function writeSettings(path, settings) {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, `${JSON.stringify(settings, null, 2)}
`, "utf8");
}
function managedCommand(settings) {
const groups = settings.hooks?.SessionStart;
if (!Array.isArray(groups)) return void 0;
for (const group of groups) {
for (const hook of group.hooks ?? []) {
if (typeof hook.command === "string" && hook.command.includes(MARKER)) return hook.command;
}
}
return void 0;
}
async function hookCommand_(args) {
const verb = args[0];
const rest = args.slice(1);
switch (verb) {
case "install":
return install(rest);
case "uninstall":
return uninstall(rest);
case "status":
return status();
default:
throw new AxiError(`Unknown hook subcommand: ${verb ?? "(none)"}`, "VAL