Files
united-workforce/packages/cli-workflow/src/cli-dispatch.ts
T
xiaoju 7b0e256c13 feat(cli): add WORKFLOW_STORAGE_ROOT env var support
Add user-facing WORKFLOW_STORAGE_ROOT environment variable to override
the default storage directory (~/.uncaged/workflow). The existing
UNCAGED_WORKFLOW_STORAGE_ROOT (internal/test) takes priority.

- Update storage-env.ts with priority chain: internal > user > default
- Add env var documentation to CLI help text
- Add 5 tests covering all priority/fallback scenarios

Fixes #63
2026-05-07 22:29:26 +08:00

578 lines
18 KiB
TypeScript

import { printCliError, printCliLine, printCliWarn } from "./cli-output.js";
import { cmdAdd, formatAddSuccess, parseAddArgv } from "./cmd-add.js";
import { cmdCasGet, cmdCasList, cmdCasPut, cmdCasRm } from "./cmd-cas.js";
import { cmdFork, parseForkArgv } from "./cmd-fork.js";
import { cmdGc } from "./cmd-gc.js";
import { formatSkillDoc } from "./cmd-help.js";
import { cmdHistory } from "./cmd-history.js";
import { cmdInitTemplate, cmdInitWorkspace } from "./cmd-init.js";
import { cmdKill } from "./cmd-kill.js";
import { cmdList, formatListLines } from "./cmd-list.js";
import { cmdLive } from "./cmd-live.js";
import { cmdPause } from "./cmd-pause.js";
import { cmdPs } from "./cmd-ps.js";
import { cmdRemove } from "./cmd-remove.js";
import { cmdResume } from "./cmd-resume.js";
import { cmdRollback } from "./cmd-rollback.js";
import { cmdRun } from "./cmd-run.js";
import { cmdShow, formatShowYaml } from "./cmd-show.js";
import { cmdThreadRemove, cmdThreadShow } from "./cmd-thread.js";
import { cmdThreads } from "./cmd-threads.js";
import { parseLiveArgv } from "./live-argv.js";
import { parseRunArgv } from "./run-argv.js";
export function formatCliUsage(): string {
return [
"Usage:",
" uncaged-workflow workflow add <name> <file.esm.js> [--types <path>]",
" uncaged-workflow workflow list",
" uncaged-workflow workflow show <name>",
" uncaged-workflow workflow rm <name>",
" uncaged-workflow workflow history <name>",
" uncaged-workflow workflow rollback <name> [hash]",
"",
" uncaged-workflow thread run <name> [--prompt <text>] [--max-rounds N]",
" uncaged-workflow thread list [name]",
" uncaged-workflow thread show <id>",
" uncaged-workflow thread rm <id>",
" uncaged-workflow thread ps",
" uncaged-workflow thread kill <thread-id>",
" uncaged-workflow thread live <thread-id> [--debug] [--role <name>]",
" uncaged-workflow thread live --latest [--debug] [--role <name>]",
" uncaged-workflow thread pause <thread-id>",
" uncaged-workflow thread resume <thread-id>",
" uncaged-workflow thread fork <thread-id> [--from-role <role>]",
"",
" uncaged-workflow cas get <thread-id> <hash>",
" uncaged-workflow cas put <thread-id> <content>",
" uncaged-workflow cas list <thread-id>",
" uncaged-workflow cas rm <thread-id> <hash>",
" uncaged-workflow cas gc",
"",
" uncaged-workflow init workspace <name>",
" uncaged-workflow init template <name>",
"",
" uncaged-workflow run <name> [...] (shortcut for thread run)",
" uncaged-workflow live <thread-id> [...] (shortcut for thread live)",
"",
"Environment variables:",
" WORKFLOW_STORAGE_ROOT Override storage directory (default: ~/.uncaged/workflow)",
" UNCAGED_WORKFLOW_STORAGE_ROOT Internal override (takes priority over WORKFLOW_STORAGE_ROOT)",
].join("\n");
}
function printDeprecation(oldCmd: string, newCmd: string): void {
printCliWarn(`⚠ "${oldCmd}" is deprecated, use "${newCmd}" instead`);
}
type DispatchFn = (storageRoot: string, argv: string[]) => Promise<number>;
// ── Individual dispatch functions ──────────────────────────────────────
async function dispatchInit(_storageRoot: string, argv: string[]): Promise<number> {
const sub = argv[0];
const name = argv[1];
if (sub === undefined || name === undefined || argv.length > 2) {
printCliError(`${formatCliUsage()}\n\nerror: init requires workspace|template <name>`);
return 1;
}
if (sub === "workspace") {
const result = await cmdInitWorkspace(process.cwd(), name);
if (!result.ok) {
printCliError(result.error);
return 1;
}
printCliLine(`initialized workflow workspace at ${result.value.rootPath}`);
return 0;
}
if (sub === "template") {
const result = await cmdInitTemplate(process.cwd(), name);
if (!result.ok) {
printCliError(result.error);
return 1;
}
printCliLine(`initialized template at ${result.value.templatePath}`);
return 0;
}
printCliError(`${formatCliUsage()}\n\nerror: unknown init subcommand: ${sub}`);
return 1;
}
async function dispatchAdd(storageRoot: string, argv: string[]): Promise<number> {
const parsed = parseAddArgv(argv);
if (!parsed.ok) {
printCliError(`${formatCliUsage()}\n\nerror: ${parsed.error}`);
return 1;
}
const result = await cmdAdd(storageRoot, parsed.value);
if (!result.ok) {
printCliError(result.error);
return 1;
}
for (const w of result.value.warnings) {
printCliWarn(w);
}
printCliLine(formatAddSuccess(parsed.value.name, parsed.value.filePath, result.value.hash));
return 0;
}
async function dispatchList(storageRoot: string, argv: string[]): Promise<number> {
if (argv.length > 0) {
printCliError(`${formatCliUsage()}\n\nerror: list takes no arguments`);
return 1;
}
const result = await cmdList(storageRoot);
if (!result.ok) {
printCliError(result.error);
return 1;
}
for (const line of formatListLines(result.value)) {
printCliLine(line);
}
return 0;
}
async function dispatchShow(storageRoot: string, argv: string[]): Promise<number> {
const name = argv[0];
if (name === undefined || argv.length > 1) {
printCliError(`${formatCliUsage()}\n\nerror: show requires <name>`);
return 1;
}
const result = await cmdShow(storageRoot, name);
if (!result.ok) {
printCliError(result.error);
return 1;
}
printCliLine(formatShowYaml(name, result.value));
return 0;
}
async function dispatchRemove(storageRoot: string, argv: string[]): Promise<number> {
const name = argv[0];
if (name === undefined || argv.length > 1) {
printCliError(`${formatCliUsage()}\n\nerror: remove requires <name>`);
return 1;
}
const result = await cmdRemove(storageRoot, name);
if (!result.ok) {
printCliError(result.error);
return 1;
}
printCliLine(`removed workflow "${name}" from registry`);
return 0;
}
async function dispatchRun(storageRoot: string, argv: string[]): Promise<number> {
const parsed = parseRunArgv(argv);
if (!parsed.ok) {
printCliError(`${formatCliUsage()}\n\nerror: ${parsed.error}`);
return 1;
}
const result = await cmdRun(
storageRoot,
parsed.value.name,
parsed.value.prompt,
parsed.value.maxRounds,
);
if (!result.ok) {
printCliError(result.error);
return 1;
}
printCliLine(result.value.threadId);
return 0;
}
async function dispatchPs(storageRoot: string, argv: string[]): Promise<number> {
if (argv.length > 0) {
printCliError(`${formatCliUsage()}\n\nerror: ps takes no arguments`);
return 1;
}
for (const line of await cmdPs(storageRoot)) {
printCliLine(line);
}
return 0;
}
async function dispatchKill(storageRoot: string, argv: string[]): Promise<number> {
const threadId = argv[0];
if (threadId === undefined || argv.length > 1) {
printCliError(`${formatCliUsage()}\n\nerror: kill requires <thread-id>`);
return 1;
}
const result = await cmdKill(storageRoot, threadId);
if (!result.ok) {
printCliError(result.error);
return 1;
}
printCliLine(`kill sent for thread ${threadId}`);
return 0;
}
async function dispatchLive(storageRoot: string, argv: string[]): Promise<number> {
const parsed = parseLiveArgv(argv);
if (!parsed.ok) {
printCliError(`${formatCliUsage()}\n\nerror: ${parsed.error}`);
return 1;
}
return cmdLive(storageRoot, parsed.value);
}
async function dispatchHistory(storageRoot: string, argv: string[]): Promise<number> {
const name = argv[0];
if (name === undefined || argv.length > 1) {
printCliError(`${formatCliUsage()}\n\nerror: history requires <name>`);
return 1;
}
const result = await cmdHistory(storageRoot, name);
if (!result.ok) {
printCliError(result.error);
return 1;
}
for (const line of result.value) {
printCliLine(line);
}
return 0;
}
async function dispatchRollback(storageRoot: string, argv: string[]): Promise<number> {
const name = argv[0];
if (name === undefined || argv.length > 2) {
printCliError(`${formatCliUsage()}\n\nerror: rollback requires <name> [hash]`);
return 1;
}
const hashArg = argv[1];
const result = await cmdRollback(storageRoot, name, hashArg === undefined ? null : hashArg);
if (!result.ok) {
printCliError(result.error);
return 1;
}
printCliLine(`rolled back workflow "${name}"`);
return 0;
}
async function dispatchPause(storageRoot: string, argv: string[]): Promise<number> {
const threadId = argv[0];
if (threadId === undefined || argv.length > 1) {
printCliError(`${formatCliUsage()}\n\nerror: pause requires <thread-id>`);
return 1;
}
const result = await cmdPause(storageRoot, threadId);
if (!result.ok) {
printCliError(result.error);
return 1;
}
printCliLine(`pause sent for thread ${threadId}`);
return 0;
}
async function dispatchResume(storageRoot: string, argv: string[]): Promise<number> {
const threadId = argv[0];
if (threadId === undefined || argv.length > 1) {
printCliError(`${formatCliUsage()}\n\nerror: resume requires <thread-id>`);
return 1;
}
const result = await cmdResume(storageRoot, threadId);
if (!result.ok) {
printCliError(result.error);
return 1;
}
printCliLine(`resume sent for thread ${threadId}`);
return 0;
}
async function dispatchThreadList(storageRoot: string, argv: string[]): Promise<number> {
const result = await cmdThreads(storageRoot, argv);
if (!result.ok) {
printCliError(result.error);
return 1;
}
for (const line of result.value) {
printCliLine(line);
}
return 0;
}
async function dispatchThreadShow(storageRoot: string, argv: string[]): Promise<number> {
const id = argv[0];
if (id === undefined || argv.length > 1) {
printCliError(`${formatCliUsage()}\n\nerror: thread show requires <id>`);
return 1;
}
const result = await cmdThreadShow(storageRoot, id);
if (!result.ok) {
printCliError(result.error);
return 1;
}
printCliLine(result.value);
return 0;
}
async function dispatchThreadRm(storageRoot: string, argv: string[]): Promise<number> {
const id = argv[0];
if (id === undefined || argv.length > 1) {
printCliError(`${formatCliUsage()}\n\nerror: thread rm requires <id>`);
return 1;
}
const result = await cmdThreadRemove(storageRoot, id);
if (!result.ok) {
printCliError(result.error);
return 1;
}
printCliLine(`removed thread ${id}`);
return 0;
}
async function dispatchGc(storageRoot: string, argv: string[]): Promise<number> {
if (argv.length > 0) {
printCliError(`${formatCliUsage()}\n\nerror: gc takes no arguments`);
return 1;
}
const result = await cmdGc(storageRoot);
if (!result.ok) {
printCliError(result.error);
return 1;
}
const stats = result.value;
printCliLine(
`scanned ${stats.scannedThreads} threads, ${stats.activeRefs} active refs, deleted ${stats.deletedEntries} entries`,
);
return 0;
}
async function dispatchFork(storageRoot: string, argv: string[]): Promise<number> {
const parsed = parseForkArgv(argv);
if (!parsed.ok) {
printCliError(`${formatCliUsage()}\n\nerror: ${parsed.error}`);
return 1;
}
const result = await cmdFork(storageRoot, parsed.value.threadId, parsed.value.fromRole);
if (!result.ok) {
printCliError(result.error);
return 1;
}
printCliLine(result.value.threadId);
return 0;
}
// ── CAS subcommand table ───────────────────────────────────────────────
async function dispatchCasGet(storageRoot: string, rest: string[]): Promise<number> {
const threadId = rest[0];
const hash = rest[1];
if (threadId === undefined || hash === undefined || rest.length > 2) {
printCliError(`${formatCliUsage()}\n\nerror: cas get requires <thread-id> <hash>`);
return 1;
}
const result = await cmdCasGet(storageRoot, threadId, hash);
if (!result.ok) {
printCliError(result.error);
return 1;
}
printCliLine(result.value);
return 0;
}
async function dispatchCasPut(storageRoot: string, rest: string[]): Promise<number> {
const threadId = rest[0];
const content = rest[1];
if (threadId === undefined || content === undefined || rest.length > 2) {
printCliError(`${formatCliUsage()}\n\nerror: cas put requires <thread-id> <content>`);
return 1;
}
const result = await cmdCasPut(storageRoot, threadId, content);
if (!result.ok) {
printCliError(result.error);
return 1;
}
printCliLine(result.value);
return 0;
}
async function dispatchCasList(storageRoot: string, rest: string[]): Promise<number> {
const threadId = rest[0];
if (threadId === undefined || rest.length > 1) {
printCliError(`${formatCliUsage()}\n\nerror: cas list requires <thread-id>`);
return 1;
}
const result = await cmdCasList(storageRoot, threadId);
if (!result.ok) {
printCliError(result.error);
return 1;
}
for (const hash of result.value) {
printCliLine(hash);
}
return 0;
}
async function dispatchCasRm(storageRoot: string, rest: string[]): Promise<number> {
const threadId = rest[0];
const hash = rest[1];
if (threadId === undefined || hash === undefined || rest.length > 2) {
printCliError(`${formatCliUsage()}\n\nerror: cas rm requires <thread-id> <hash>`);
return 1;
}
const result = await cmdCasRm(storageRoot, threadId, hash);
if (!result.ok) {
printCliError(result.error);
return 1;
}
printCliLine(`removed cas entry ${hash}`);
return 0;
}
const CAS_SUBCOMMAND_TABLE: Record<string, DispatchFn> = {
get: dispatchCasGet,
put: dispatchCasPut,
list: dispatchCasList,
rm: dispatchCasRm,
gc: dispatchGc,
};
async function dispatchCas(storageRoot: string, argv: string[]): Promise<number> {
const sub = argv[0];
if (sub === undefined) {
printCliError(`${formatCliUsage()}\n\nerror: unknown cas subcommand: (none)`);
return 1;
}
const handler = CAS_SUBCOMMAND_TABLE[sub];
if (handler === undefined) {
printCliError(`${formatCliUsage()}\n\nerror: unknown cas subcommand: ${sub}`);
return 1;
}
return handler(storageRoot, argv.slice(1));
}
// ── Workflow subcommand table (Phase 1) ────────────────────────────────
const WORKFLOW_SUBCOMMAND_TABLE: Record<string, DispatchFn> = {
add: dispatchAdd,
list: dispatchList,
show: dispatchShow,
rm: dispatchRemove,
history: dispatchHistory,
rollback: dispatchRollback,
};
async function dispatchWorkflow(storageRoot: string, argv: string[]): Promise<number> {
const sub = argv[0];
if (sub === undefined) {
printCliError(`${formatCliUsage()}\n\nerror: unknown workflow subcommand: (none)`);
return 1;
}
const handler = WORKFLOW_SUBCOMMAND_TABLE[sub];
if (handler !== undefined) {
return handler(storageRoot, argv.slice(1));
}
if (sub === "remove") {
printDeprecation("workflow remove", "workflow rm");
return dispatchRemove(storageRoot, argv.slice(1));
}
printCliError(`${formatCliUsage()}\n\nerror: unknown workflow subcommand: ${sub}`);
return 1;
}
// ── Thread subcommand table (Phase 2) ──────────────────────────────────
const THREAD_SUBCOMMAND_TABLE: Record<string, DispatchFn> = {
run: dispatchRun,
list: dispatchThreadList,
show: dispatchThreadShow,
rm: dispatchThreadRm,
fork: dispatchFork,
ps: dispatchPs,
kill: dispatchKill,
live: dispatchLive,
pause: dispatchPause,
resume: dispatchResume,
};
async function dispatchThread(storageRoot: string, argv: string[]): Promise<number> {
const sub = argv[0];
if (sub === undefined) {
printCliError(`${formatCliUsage()}\n\nerror: unknown thread subcommand: (none)`);
return 1;
}
const handler = THREAD_SUBCOMMAND_TABLE[sub];
if (handler === undefined) {
printCliError(`${formatCliUsage()}\n\nerror: unknown thread subcommand: ${sub}`);
return 1;
}
return handler(storageRoot, argv.slice(1));
}
// ── Help ────────────────────────────────────────────────────────────────
async function dispatchHelp(_storageRoot: string, argv: string[]): Promise<number> {
if (argv.includes("--skill")) {
printCliLine(formatSkillDoc());
} else {
printCliLine(formatCliUsage());
}
return 0;
}
// ── Top-level command table (Phase 3) ──────────────────────────────────
const COMMAND_TABLE: Record<string, DispatchFn> = {
// Grouped commands (primary)
workflow: dispatchWorkflow,
thread: dispatchThread,
cas: dispatchCas,
init: dispatchInit,
help: dispatchHelp,
// Top-level shortcuts (no deprecation)
run: dispatchRun,
live: dispatchLive,
};
// Deprecated flat commands that delegate to grouped commands
const DEPRECATED_ALIASES: Record<string, { newCmd: string; handler: DispatchFn }> = {
add: { newCmd: "workflow add", handler: dispatchAdd },
list: { newCmd: "workflow list", handler: dispatchList },
show: { newCmd: "workflow show", handler: dispatchShow },
remove: { newCmd: "workflow rm", handler: dispatchRemove },
ps: { newCmd: "thread ps", handler: dispatchPs },
kill: { newCmd: "thread kill", handler: dispatchKill },
pause: { newCmd: "thread pause", handler: dispatchPause },
resume: { newCmd: "thread resume", handler: dispatchResume },
threads: { newCmd: "thread list", handler: dispatchThreadList },
fork: { newCmd: "thread fork", handler: dispatchFork },
gc: { newCmd: "cas gc", handler: dispatchGc },
history: { newCmd: "workflow history", handler: dispatchHistory },
rollback: { newCmd: "workflow rollback", handler: dispatchRollback },
};
export async function runCli(storageRoot: string, argv: string[]): Promise<number> {
if (argv.length === 0) {
printCliError(formatCliUsage());
return 1;
}
const command = argv[0];
if (command === undefined) {
printCliError(formatCliUsage());
return 1;
}
const rest = argv.slice(1);
const dispatch = COMMAND_TABLE[command];
if (dispatch !== undefined) {
return dispatch(storageRoot, rest);
}
const deprecated = DEPRECATED_ALIASES[command];
if (deprecated !== undefined) {
printDeprecation(command, deprecated.newCmd);
return deprecated.handler(storageRoot, rest);
}
printCliError(`${formatCliUsage()}\n\nerror: unknown command ${command}`);
return 1;
}