feat: complete TaskTree CLI parity

This commit is contained in:
root
2026-07-18 06:41:31 +02:00
parent 16fed1bdba
commit cc1a946ef2
9 changed files with 445 additions and 43 deletions
+80 -12
View File
@@ -5,10 +5,10 @@ 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) {
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();
const command = await commandFrom(parsed);
const command = await commandFrom(parsed, stdin);
if (parsed.overapi) return overApi(command, env, parsed);
const runtime = taskTreeRuntime(env);
try {
@@ -25,19 +25,26 @@ function help() {
mode: "local-dispatcher-default",
usage: [
"hwlab-cli tasktree health [--overapi]",
"hwlab-cli tasktree group list|overview|get|create|delete [--group ID] [--name TEXT]",
"hwlab-cli tasktree task create|update|delete --group ID|--task ID [--parent ID] --title TEXT --start ISO --due ISO --status STATUS",
"hwlab-cli tasktree group list|overview|get|create|delete|stats [--group ID] [--name TEXT]",
"hwlab-cli tasktree group import-markdown --file PATH [--name TEXT] [--dry-run]",
"hwlab-cli tasktree task list|get --group ID|--task ID",
"hwlab-cli tasktree task create --group ID [--parent ID] (--title TEXT|--stdin)",
"hwlab-cli tasktree task create-batch --group ID [--parent ID] TITLE...",
"hwlab-cli tasktree task update --task ID [--title TEXT|--stdin] [--description TEXT] [--status STATUS]",
"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 create --task ID --title TEXT (--body TEXT|--body-file PATH)",
"hwlab-cli tasktree import mdtodo --file PATH [--name TEXT] [--dry-run] [--overapi]",
"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)",
"hwlab-cli tasktree timeline --group ID [--overapi]",
"hwlab-cli tasktree workflow start --task ID"
],
transportContract: "--overapi only changes transport; commands and options are identical"
transportContract: "--overapi only changes transport; commands and options are identical",
hierarchy: "taskgroup -> task -> subtask -> subsubtask",
completionContract: "a task requires at least one execution report before completion"
};
}
async function commandFrom(parsed: Parsed): Promise<TaskTreeCommand> {
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") };
@@ -46,12 +53,54 @@ async function commandFrom(parsed: Parsed): Promise<TaskTreeCommand> {
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 === "task" && action === "create") return { operation: "task.create", groupId: required(parsed, "group"), parentId: parsed.values.parent, title: required(parsed, "title"), description: parsed.values.description, startAt: parsed.values.start, dueAt: parsed.values.due };
if (group === "task" && action === "update") return { operation: "task.update", taskId: required(parsed, "task"), title: parsed.values.title, description: parsed.values.description, status: parsed.values.status as TaskStatus | undefined, startAt: parsed.values.start, dueAt: parsed.values.due };
if (group === "task" && action === "delete") return { operation: "task.delete", taskId: required(parsed, "task") };
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 === "report" && action === "create") return { operation: "report.create", taskId: required(parsed, "task"), title: required(parsed, "title"), body: parsed.values.body ?? await readFile(required(parsed, "body-file"), "utf8"), status: parsed.values.status };
if (group === "import" && action === "mdtodo") return { operation: "mdtodo.import", ...await loadMdtodoImportPayload(required(parsed, "file"), { groupName: parsed.values.name, dryRun: parsed.flags.has("dry-run") }) };
if (group === "workflow" && action === "start") return { operation: "workflow.start", taskId: required(parsed, "task") };
throw new Error(`unsupported TaskTree command: ${group} ${action}`.trim());
}
@@ -72,6 +121,7 @@ function parse(argv: string[]): Parsed {
const token = argv[index];
if (token === "--overapi") parsed.overapi = true;
else if (token === "--dry-run") parsed.flags.add("dry-run");
else if (token === "--stdin") parsed.flags.add("stdin");
else if (token === "--help" || token === "-h") parsed.help = true;
else if (token.startsWith("--")) {
const key = token.slice(2);
@@ -84,3 +134,21 @@ function parse(argv: string[]): Parsed {
return parsed;
}
function required(parsed: Parsed, key: string) { const value = parsed.values[key]; if (!value) throw new Error(`--${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 }); }