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 = 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 workflow start --task ID" ], transportContract: "--overapi only changes transport; commands and options are identical", transportOptions: "--overapi [--api-url URL]", hierarchy: "taskgroup -> task -> subtask -> subsubtask", completionContract: "a task requires at least one execution report before completion" }; } async function commandFrom(parsed: Parsed, stdin?: string): Promise { 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 === "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, 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; flags: Set; 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") { 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 = { 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"] }, "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}`); } }