Files
pikasTech-HWLAB/tools/src/tasktree-cli.ts
T

267 lines
15 KiB
TypeScript

import { readFile } from "node:fs/promises";
import type { TaskStatus, TaskTreeCommand } from "../../internal/tasktree/contracts.ts";
import { createTaskTreeDispatcher } from "../../internal/tasktree/dispatcher.ts";
import { loadMdtodoImportPayload } from "../../internal/tasktree/mdtodo-import.ts";
import { taskTreeRuntime } from "../../internal/tasktree/runtime.ts";
export async function runTaskTreeCli(argv: string[], env: Record<string, string | undefined> = process.env, stdin?: string) {
const parsed = parse(argv);
if (parsed.help || parsed.positionals.length === 0) return help();
validateArguments(parsed);
const command = await commandFrom(parsed, stdin);
if (parsed.overapi) return overApi(command, env, parsed);
const runtime = taskTreeRuntime(env);
try {
return await createTaskTreeDispatcher(runtime)(command);
} finally {
await runtime.store.close();
}
}
function help() {
return {
ok: true,
operation: "help",
mode: "local-dispatcher-default",
usage: [
"hwlab-cli tasktree health [--overapi]",
"hwlab-cli tasktree group list|overview",
"hwlab-cli tasktree group get|delete|stats --group ID",
"hwlab-cli tasktree group create --name TEXT [--description TEXT]",
"hwlab-cli tasktree group import-markdown --file PATH [--name TEXT] [--dry-run]",
"hwlab-cli tasktree task list --group ID",
"hwlab-cli tasktree task get --task ID",
"hwlab-cli tasktree task create --group ID [--parent ID] (--title TEXT|--stdin) [--description TEXT] [--start ISO] [--due ISO]",
"hwlab-cli tasktree task create-batch --group ID [--parent ID] [--description TEXT] [--start ISO] [--due ISO] TITLE...",
"hwlab-cli tasktree task update --task ID [--title TEXT|--stdin] [--description TEXT] [--status STATUS] [--start ISO|null] [--due ISO|null]",
"hwlab-cli tasktree task start|complete|done|delete|remove --task ID",
"hwlab-cli tasktree milestone create --group ID [--task ID] --title TEXT --at ISO",
"hwlab-cli tasktree report list|get --task ID|--report ID",
"hwlab-cli tasktree report write --task ID --title TEXT (--body TEXT|--body-file PATH|--stdin) [--status STATUS]",
"hwlab-cli tasktree timeline --group ID [--overapi]",
"hwlab-cli tasktree backup create [--dry-run] [--reason TEXT]",
"hwlab-cli tasktree backup status|verify",
"hwlab-cli tasktree backup restore [--confirm --expected-sha SHA]",
"hwlab-cli tasktree workflow start --task ID"
],
transportContract: "--overapi only changes transport; commands and options are identical",
transportOptions: "--overapi [--api-url URL]",
hierarchy: "taskgroup -> task -> subtask -> subsubtask",
lifecycleContract: "start pending work before execution; writing an execution report completes accepted work",
completionContract: "a task requires at least one execution report before completion"
};
}
async function commandFrom(parsed: Parsed, stdin?: string): Promise<TaskTreeCommand> {
const [group, action = ""] = parsed.positionals;
if (group === "health") return { operation: "health" };
if (group === "timeline") return { operation: "timeline.get", groupId: required(parsed, "group") };
if (group === "group" && action === "list") return { operation: "group.list" };
if (group === "group" && action === "overview") return { operation: "group.overview" };
if (group === "group" && action === "get") return { operation: "group.get", groupId: required(parsed, "group") };
if (group === "group" && action === "create") return { operation: "group.create", name: required(parsed, "name"), description: parsed.values.description };
if (group === "group" && action === "delete") return { operation: "group.delete", groupId: required(parsed, "group") };
if (group === "group" && action === "stats") return { operation: "group.stats", groupId: required(parsed, "group") };
if (group === "group" && action === "import-markdown") return {
operation: "group.import-markdown",
...await loadMdtodoImportPayload(required(parsed, "file"), { groupName: parsed.values.name, dryRun: parsed.flags.has("dry-run") })
};
if (group === "task" && action === "list") return { operation: "task.list", groupId: required(parsed, "group") };
if (group === "task" && action === "get") return { operation: "task.get", taskId: required(parsed, "task") };
if (group === "task" && action === "create") return {
operation: "task.create",
groupId: required(parsed, "group"),
parentId: parsed.values.parent,
title: titleInput(parsed, stdin),
description: parsed.values.description,
startAt: parsed.values.start,
dueAt: parsed.values.due
};
if (group === "task" && action === "create-batch") return {
operation: "task.create-batch",
groupId: required(parsed, "group"),
parentId: parsed.values.parent,
titles: parsed.positionals.slice(2),
description: parsed.values.description,
startAt: parsed.values.start,
dueAt: parsed.values.due
};
if (group === "task" && action === "update") {
const command: TaskTreeCommand = {
operation: "task.update",
taskId: required(parsed, "task"),
title: parsed.flags.has("stdin") ? titleInput(parsed, stdin) : parsed.values.title,
description: parsed.values.description,
status: parsed.values.status as TaskStatus | undefined,
startAt: nullableDateOption(parsed, "start"),
dueAt: nullableDateOption(parsed, "due")
};
if (command.title === undefined && command.description === undefined && command.status === undefined && command.startAt === undefined && command.dueAt === undefined) {
throw codedError("update_patch_required", "task update requires at least one field");
}
return command;
}
if (group === "task" && (action === "delete" || action === "remove")) return { operation: "task.delete", taskId: required(parsed, "task") };
if (group === "task" && action === "start") return { operation: "task.start", taskId: required(parsed, "task") };
if (group === "task" && (action === "complete" || action === "done")) return { operation: "task.complete", taskId: required(parsed, "task") };
if (group === "milestone" && action === "create") return { operation: "milestone.create", groupId: required(parsed, "group"), taskId: parsed.values.task, title: required(parsed, "title"), occursAt: required(parsed, "at") };
if (group === "report" && action === "list") return { operation: "report.list", taskId: required(parsed, "task") };
if (group === "report" && action === "get") return { operation: "report.get", reportId: required(parsed, "report") };
if (group === "report" && action === "write") return { operation: "report.write", taskId: required(parsed, "task"), title: required(parsed, "title"), body: await bodyInput(parsed, stdin), status: parsed.values.status };
if (group === "backup" && action === "create") return {
operation: "backup.create",
dryRun: parsed.flags.has("dry-run"),
reason: parsed.values.reason
};
if (group === "backup" && action === "status") return { operation: "backup.status" };
if (group === "backup" && action === "verify") return { operation: "backup.verify" };
if (group === "backup" && action === "restore") return {
operation: "backup.restore",
confirm: parsed.flags.has("confirm"),
expectedSha256: parsed.values["expected-sha"]
};
if (group === "workflow" && action === "start") return { operation: "workflow.start", taskId: required(parsed, "task") };
throw codedError("unsupported_command", `unsupported TaskTree command: ${group} ${action}`.trim());
}
async function overApi(command: TaskTreeCommand, env: Record<string, string | undefined>, parsed: Parsed) {
const baseUrl = parsed.values["api-url"] || env.HWLAB_TASKTREE_API_URL;
if (!baseUrl) throw codedError("missing_api_url", "HWLAB_TASKTREE_API_URL or --api-url is required with --overapi");
let response: Response;
try {
response = await fetch(`${baseUrl.replace(/\/$/u, "")}/v1/tasktree/commands`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(command)
});
} catch {
throw codedError("api_unreachable", "TaskTree API request failed");
}
const body = await response.json().catch(() => null);
if (
!body
|| typeof body !== "object"
|| typeof (body as any).ok !== "boolean"
|| (body as any).operation !== command.operation
|| response.ok !== (body as any).ok
) {
throw codedError("invalid_api_response", `TaskTree API returned an invalid response (${response.status})`);
}
return { ...(body as object), transport: "api", httpStatus: response.status };
}
type Parsed = { positionals: string[]; values: Record<string, string>; flags: Set<string>; overapi: boolean; help: boolean };
function parse(argv: string[]): Parsed {
const parsed: Parsed = { positionals: [], values: {}, flags: new Set(), overapi: false, help: false };
for (let index = 0; index < argv.length; index += 1) {
const token = argv[index];
if (token === "--overapi") {
if (parsed.overapi) throw codedError("duplicate_option", "--overapi may only be provided once");
parsed.overapi = true;
}
else if (token === "--dry-run" || token === "--stdin" || token === "--confirm") {
const flag = token.slice(2);
if (parsed.flags.has(flag)) throw codedError("duplicate_option", `${token} may only be provided once`);
parsed.flags.add(flag);
}
else if (token === "--help" || token === "-h") parsed.help = true;
else if (token.startsWith("--")) {
const key = token.slice(2);
const value = argv[index + 1];
if (parsed.values[key] !== undefined) throw codedError("duplicate_option", `${token} may only be provided once`);
if (value === undefined || value.startsWith("--")) throw codedError("missing_option_value", `${token} requires a value`);
parsed.values[key] = value;
index += 1;
} else parsed.positionals.push(token);
}
return parsed;
}
function required(parsed: Parsed, key: string) { const value = parsed.values[key]; if (!value) throw codedError("missing_option", `--${key} is required`); return value; }
function titleInput(parsed: Parsed, stdin?: string) {
if (parsed.flags.has("stdin") && parsed.values.title !== undefined) throw codedError("title_source_conflict", "--title and --stdin are mutually exclusive");
const value = parsed.flags.has("stdin") ? stripOneLineEnding(stdin ?? "") : parsed.values.title;
if (value === undefined || value.length === 0) throw codedError("title_required", "--title or --stdin is required");
return value;
}
async function bodyInput(parsed: Parsed, stdin?: string) {
const sources = Number(parsed.values.body !== undefined) + Number(parsed.values["body-file"] !== undefined) + Number(parsed.flags.has("stdin"));
if (sources !== 1) throw codedError("body_source_invalid", "exactly one of --body, --body-file, or --stdin is required");
if (parsed.flags.has("stdin")) return stdin ?? "";
return parsed.values.body ?? await readFile(required(parsed, "body-file"), "utf8");
}
function nullableDateOption(parsed: Parsed, key: string): string | null | undefined {
const value = parsed.values[key];
return value === "null" ? null : value;
}
function stripOneLineEnding(value: string) { return value.endsWith("\r\n") ? value.slice(0, -2) : value.endsWith("\n") ? value.slice(0, -1) : value; }
function codedError(code: string, message: string) { return Object.assign(new Error(message), { code }); }
type CommandSpec = {
positionals: number | { min: number; max?: number };
options?: string[];
flags?: string[];
};
const COMMAND_SPECS: Record<string, CommandSpec> = {
health: { positionals: 1 },
timeline: { positionals: 1, options: ["group"] },
"group list": { positionals: 2 },
"group overview": { positionals: 2 },
"group get": { positionals: 2, options: ["group"] },
"group create": { positionals: 2, options: ["name", "description"] },
"group delete": { positionals: 2, options: ["group"] },
"group stats": { positionals: 2, options: ["group"] },
"group import-markdown": { positionals: 2, options: ["file", "name"], flags: ["dry-run"] },
"task list": { positionals: 2, options: ["group"] },
"task get": { positionals: 2, options: ["task"] },
"task create": { positionals: 2, options: ["group", "parent", "title", "description", "start", "due"], flags: ["stdin"] },
"task create-batch": { positionals: { min: 3 }, options: ["group", "parent", "description", "start", "due"] },
"task update": { positionals: 2, options: ["task", "title", "description", "status", "start", "due"], flags: ["stdin"] },
"task delete": { positionals: 2, options: ["task"] },
"task remove": { positionals: 2, options: ["task"] },
"task start": { positionals: 2, options: ["task"] },
"task complete": { positionals: 2, options: ["task"] },
"task done": { positionals: 2, options: ["task"] },
"milestone create": { positionals: 2, options: ["group", "task", "title", "at"] },
"report list": { positionals: 2, options: ["task"] },
"report get": { positionals: 2, options: ["report"] },
"report write": { positionals: 2, options: ["task", "title", "body", "body-file", "status"], flags: ["stdin"] },
"backup create": { positionals: 2, options: ["reason"], flags: ["dry-run"] },
"backup status": { positionals: 2 },
"backup verify": { positionals: 2 },
"backup restore": { positionals: 2, options: ["expected-sha"], flags: ["confirm"] },
"workflow start": { positionals: 2, options: ["task"] }
};
function validateArguments(parsed: Parsed) {
const key = parsed.positionals.slice(0, 2).join(" ");
const spec = COMMAND_SPECS[key] ?? COMMAND_SPECS[parsed.positionals[0]];
if (!spec) throw codedError("unsupported_command", `unsupported TaskTree command: ${key}`);
const positionals = typeof spec.positionals === "number"
? { min: spec.positionals, max: spec.positionals }
: spec.positionals;
if (parsed.positionals.length < positionals.min || (positionals.max !== undefined && parsed.positionals.length > positionals.max)) {
throw codedError("invalid_arguments", `unexpected positional arguments for ${key}`);
}
const allowedOptions = new Set(spec.options ?? []);
if (parsed.overapi) allowedOptions.add("api-url");
for (const option of Object.keys(parsed.values)) {
if (!allowedOptions.has(option)) throw codedError("invalid_option", `--${option} is not valid for ${key}`);
}
if (parsed.values["api-url"] !== undefined && !parsed.overapi) {
throw codedError("invalid_option", "--api-url requires --overapi");
}
const allowedFlags = new Set(spec.flags ?? []);
for (const flag of parsed.flags) {
if (!allowedFlags.has(flag)) throw codedError("invalid_option", `--${flag} is not valid for ${key}`);
}
if (key === "backup restore") {
const confirm = parsed.flags.has("confirm");
const expectedSha = parsed.values["expected-sha"];
if (confirm !== (expectedSha !== undefined)) {
throw codedError("restore_confirmation_invalid", "--confirm and --expected-sha must be provided together");
}
}
}