taskplane 0.1.11 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/taskplane.mjs CHANGED
@@ -1,751 +1,1047 @@
1
- #!/usr/bin/env node
2
-
3
- /**
4
- * Taskplane CLI — Project scaffolding, diagnostics, and dashboard launcher.
5
- *
6
- * This CLI handles what the pi package system cannot: project-local config
7
- * scaffolding, installation health checks, and dashboard management.
8
- *
9
- * Extensions, skills, and themes are delivered via `pi install npm:taskplane`
10
- * and auto-discovered by pi. This CLI is for everything else.
11
- */
12
-
13
- import fs from "node:fs";
14
- import path from "node:path";
15
- import readline from "node:readline";
16
- import { fileURLToPath } from "node:url";
17
- import { execSync, spawn } from "node:child_process";
18
-
19
- // ─── Paths ──────────────────────────────────────────────────────────────────
20
-
21
- const __filename = fileURLToPath(import.meta.url);
22
- const __dirname = path.dirname(__filename);
23
- const PACKAGE_ROOT = path.resolve(__dirname, "..");
24
- const TEMPLATES_DIR = path.join(PACKAGE_ROOT, "templates");
25
- const DASHBOARD_SERVER = path.join(PACKAGE_ROOT, "dashboard", "server.cjs");
26
-
27
- // ─── ANSI Colors ────────────────────────────────────────────────────────────
28
-
29
- const c = {
30
- reset: "\x1b[0m",
31
- bold: "\x1b[1m",
32
- dim: "\x1b[2m",
33
- red: "\x1b[31m",
34
- green: "\x1b[32m",
35
- yellow: "\x1b[33m",
36
- blue: "\x1b[34m",
37
- cyan: "\x1b[36m",
38
- };
39
-
40
- const OK = `${c.green}✅${c.reset}`;
41
- const WARN = `${c.yellow}⚠️${c.reset}`;
42
- const FAIL = `${c.red}❌${c.reset}`;
43
- const INFO = `${c.cyan}ℹ${c.reset}`;
44
-
45
- // ─── Utilities ──────────────────────────────────────────────────────────────
46
-
47
- function die(msg) {
48
- console.error(`${FAIL} ${msg}`);
49
- process.exit(1);
50
- }
51
-
52
- function today() {
53
- return new Date().toISOString().slice(0, 10);
54
- }
55
-
56
- function slugify(str) {
57
- return str
58
- .toLowerCase()
59
- .replace(/[^a-z0-9]+/g, "-")
60
- .replace(/^-|-$/g, "");
61
- }
62
-
63
- /** Prompt the user for input. Returns the answer or defaultValue. */
64
- function ask(question, defaultValue) {
65
- const rl = readline.createInterface({
66
- input: process.stdin,
67
- output: process.stdout,
68
- });
69
- const suffix = defaultValue != null ? ` ${c.dim}(${defaultValue})${c.reset}` : "";
70
- return new Promise((resolve) => {
71
- rl.question(` ${question}${suffix}: `, (answer) => {
72
- rl.close();
73
- resolve(answer.trim() || defaultValue || "");
74
- });
75
- });
76
- }
77
-
78
- /** Prompt yes/no. Returns boolean. */
79
- async function confirm(question, defaultYes = true) {
80
- const hint = defaultYes ? "Y/n" : "y/N";
81
- const answer = await ask(`${question} [${hint}]`);
82
- if (!answer) return defaultYes;
83
- return answer.toLowerCase().startsWith("y");
84
- }
85
-
86
- /** Read a YAML file if it exists, return null otherwise. */
87
- function readYaml(filePath) {
88
- try {
89
- // Dynamic import of yaml — it's a dependency of the package
90
- const raw = fs.readFileSync(filePath, "utf-8");
91
- // Simple YAML value extraction (avoids requiring yaml at top level for fast startup)
92
- return raw;
93
- } catch {
94
- return null;
95
- }
96
- }
97
-
98
- /** Check if a command exists on PATH. */
99
- function commandExists(cmd) {
100
- try {
101
- const which = process.platform === "win32" ? "where" : "which";
102
- execSync(`${which} ${cmd}`, { stdio: "pipe" });
103
- return true;
104
- } catch {
105
- return false;
106
- }
107
- }
108
-
109
- /** Get command version string. */
110
- function getVersion(cmd, flag = "--version") {
111
- try {
112
- return execSync(`${cmd} ${flag}`, { stdio: "pipe" }).toString().trim();
113
- } catch {
114
- return null;
115
- }
116
- }
117
-
118
- /** Write a file, creating parent directories as needed. Optionally skip if exists. */
119
- function writeFile(dest, content, { skipIfExists = false, label = "" } = {}) {
120
- if (skipIfExists && fs.existsSync(dest)) {
121
- if (label) console.log(` ${c.dim}skip${c.reset} ${label} (already exists)`);
122
- return false;
123
- }
124
- fs.mkdirSync(path.dirname(dest), { recursive: true });
125
- fs.writeFileSync(dest, content, "utf-8");
126
- if (label) console.log(` ${c.green}create${c.reset} ${label}`);
127
- return true;
128
- }
129
-
130
- /** Copy a file from templates, creating parent dirs. */
131
- function copyTemplate(src, dest, { skipIfExists = false, label = "" } = {}) {
132
- if (skipIfExists && fs.existsSync(dest)) {
133
- if (label) console.log(` ${c.dim}skip${c.reset} ${label} (already exists)`);
134
- return false;
135
- }
136
- const content = fs.readFileSync(src, "utf-8");
137
- return writeFile(dest, content, { label });
138
- }
139
-
140
- /** Replace {{variables}} in template content. */
141
- function interpolate(content, vars) {
142
- return content.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? `{{${key}}}`);
143
- }
144
-
145
- // ─── Stack Detection ────────────────────────────────────────────────────────
146
-
147
- function detectStack(projectRoot) {
148
- const checks = [
149
- { file: "package.json", stack: "node", test: "npm test", build: "npm run build" },
150
- { file: "go.mod", stack: "go", test: "go test ./...", build: "go build ./..." },
151
- { file: "Cargo.toml", stack: "rust", test: "cargo test", build: "cargo build" },
152
- { file: "pyproject.toml", stack: "python", test: "pytest", build: "" },
153
- { file: "pom.xml", stack: "java-maven", test: "mvn test", build: "mvn package" },
154
- { file: "build.gradle", stack: "java-gradle", test: "gradle test", build: "gradle build" },
155
- ];
156
- for (const { file, stack, test, build } of checks) {
157
- if (fs.existsSync(path.join(projectRoot, file))) {
158
- return { stack, test, build };
159
- }
160
- }
161
- return { stack: "unknown", test: "", build: "" };
162
- }
163
-
164
- // ─── YAML Generation ────────────────────────────────────────────────────────
165
-
166
- function generateTaskRunnerYaml(vars) {
167
- return `# ═══════════════════════════════════════════════════════════════════════
168
- # Task Runner Configuration — ${vars.project_name}
169
- # ═══════════════════════════════════════════════════════════════════════
170
- #
171
- # This file configures the /task command (task-runner extension).
172
- # Edit freely — this file is owned by you, not the package.
173
-
174
- # ── Task Areas ────────────────────────────────────────────────────────
175
- # Define where tasks live. Each area has a folder path, ID prefix, and
176
- # a CONTEXT.md file that provides domain context to agents.
177
-
178
- task_areas:
179
- ${vars.default_area}:
180
- path: "${vars.tasks_root}"
181
- prefix: "${vars.default_prefix}"
182
- context: "${vars.tasks_root}/CONTEXT.md"
183
-
184
- # ── Reference Docs ────────────────────────────────────────────────────
185
- # Docs that tasks can reference in their "Context to Read First" section.
186
- # Add your project's architecture docs, API specs, etc.
187
-
188
- reference_docs: {}
189
-
190
- # ── Standards ─────────────────────────────────────────────────────────
191
- # Coding standards and rules. Agents follow these during implementation.
192
-
193
- standards: {}
194
-
195
- # ── Testing ───────────────────────────────────────────────────────────
196
- # Commands that agents run to verify their work.
197
-
198
- testing:
199
- commands:${vars.test_cmd ? `\n unit: "${vars.test_cmd}"` : ""}${vars.build_cmd ? `\n build: "${vars.build_cmd}"` : ""}
200
- `;
201
- }
202
-
203
- function generateOrchestratorYaml(vars) {
204
- return `# ═══════════════════════════════════════════════════════════════════════
205
- # Parallel Task Orchestrator Configuration — ${vars.project_name}
206
- # ═══════════════════════════════════════════════════════════════════════
207
- #
208
- # This file configures the /orch commands (task-orchestrator extension).
209
- # Edit freely — this file is owned by you, not the package.
210
-
211
- orchestrator:
212
- max_lanes: ${vars.max_lanes}
213
- worktree_location: "subdirectory"
214
- worktree_prefix: "${vars.worktree_prefix}"
215
- integration_branch: "${vars.integration_branch}"
216
- batch_id_format: "timestamp"
217
- spawn_mode: "subprocess"
218
- tmux_prefix: "${vars.tmux_prefix}"
219
-
220
- dependencies:
221
- source: "prompt"
222
- cache: true
223
-
224
- assignment:
225
- strategy: "affinity-first"
226
- size_weights:
227
- S: 1
228
- M: 2
229
- L: 4
230
-
231
- pre_warm:
232
- auto_detect: false
233
- commands: {}
234
- always: []
235
-
236
- merge:
237
- model: ""
238
- tools: "read,write,edit,bash,grep,find,ls"
239
- verify: []
240
- order: "fewest-files-first"
241
-
242
- failure:
243
- on_task_failure: "skip-dependents"
244
- on_merge_failure: "pause"
245
- stall_timeout: 30
246
- max_worker_minutes: 30
247
- abort_grace_period: 60
248
-
249
- monitoring:
250
- poll_interval: 5
251
- `;
252
- }
253
-
254
- // ═════════════════════════════════════════════════════════════════════════════
255
- // COMMANDS
256
- // ═════════════════════════════════════════════════════════════════════════════
257
-
258
- /** Auto-commit task files to git so they're available in orchestrator worktrees. */
259
- async function autoCommitTaskFiles(projectRoot, tasksRoot) {
260
- // Check if we're in a git repo
261
- try {
262
- execSync("git rev-parse --is-inside-work-tree", { cwd: projectRoot, stdio: "pipe" });
263
- } catch {
264
- // Not a git repo — skip silently
265
- return;
266
- }
267
-
268
- // Stage the tasks directory (not .pi/ — that's gitignored)
269
- const tasksDir = path.join(projectRoot, tasksRoot);
270
- if (!fs.existsSync(tasksDir)) return;
271
-
272
- try {
273
- // Check if there's anything new to commit
274
- execSync(`git add "${tasksRoot}"`, { cwd: projectRoot, stdio: "pipe" });
275
- const status = execSync("git diff --cached --name-only", { cwd: projectRoot, stdio: "pipe" })
276
- .toString()
277
- .trim();
278
-
279
- if (!status) return; // nothing staged
280
-
281
- execSync('git commit -m "chore: initialize taskplane tasks"', {
282
- cwd: projectRoot,
283
- stdio: "pipe",
284
- });
285
- console.log(`\n ${c.green}git${c.reset} committed ${tasksRoot}/ to git`);
286
- console.log(` ${c.dim}(orchestrator worktrees require committed files)${c.reset}`);
287
- } catch (err) {
288
- // Git commit failed — warn but don't block init
289
- console.log(`\n ${WARN} Could not auto-commit task files to git.`);
290
- console.log(` ${c.dim}Run manually before using /orch: git add ${tasksRoot} && git commit -m "add taskplane tasks"${c.reset}`);
291
- }
292
- }
293
-
294
- // ─── init ───────────────────────────────────────────────────────────────────
295
-
296
- async function cmdInit(args) {
297
- const projectRoot = process.cwd();
298
- const force = args.includes("--force");
299
- const dryRun = args.includes("--dry-run");
300
- const noExamples = args.includes("--no-examples");
301
- const presetIdx = args.indexOf("--preset");
302
- const preset = presetIdx !== -1 ? args[presetIdx + 1] : null;
303
-
304
- console.log(`\n${c.bold}Taskplane Init${c.reset}\n`);
305
-
306
- // Check for existing config
307
- const hasConfig =
308
- fs.existsSync(path.join(projectRoot, ".pi", "task-runner.yaml")) ||
309
- fs.existsSync(path.join(projectRoot, ".pi", "task-orchestrator.yaml"));
310
-
311
- if (hasConfig && !force) {
312
- console.log(`${WARN} Taskplane config already exists in this project.`);
313
- const proceed = await confirm(" Overwrite existing files?", false);
314
- if (!proceed) {
315
- console.log(" Aborted.");
316
- return;
317
- }
318
- }
319
-
320
- // Gather config values
321
- let vars;
322
- if (preset === "minimal" || preset === "full" || preset === "runner-only") {
323
- vars = getPresetVars(preset, projectRoot);
324
- console.log(` Using preset: ${c.cyan}${preset}${c.reset}\n`);
325
- } else {
326
- vars = await getInteractiveVars(projectRoot);
327
- }
328
-
329
- if (dryRun) {
330
- console.log(`\n${c.bold}Dry run — files that would be created:${c.reset}\n`);
331
- printFileList(vars, noExamples, preset);
332
- return;
333
- }
334
-
335
- // Scaffold files
336
- console.log(`\n${c.bold}Creating files...${c.reset}\n`);
337
- const skipIfExists = !force;
338
-
339
- // Agent prompts
340
- for (const agent of ["task-worker.md", "task-reviewer.md", "task-merger.md"]) {
341
- copyTemplate(
342
- path.join(TEMPLATES_DIR, "agents", agent),
343
- path.join(projectRoot, ".pi", "agents", agent),
344
- { skipIfExists, label: `.pi/agents/${agent}` }
345
- );
346
- }
347
-
348
- // Task runner config
349
- writeFile(
350
- path.join(projectRoot, ".pi", "task-runner.yaml"),
351
- generateTaskRunnerYaml(vars),
352
- { skipIfExists, label: ".pi/task-runner.yaml" }
353
- );
354
-
355
- // Orchestrator config (skip for runner-only preset)
356
- if (preset !== "runner-only") {
357
- writeFile(
358
- path.join(projectRoot, ".pi", "task-orchestrator.yaml"),
359
- generateOrchestratorYaml(vars),
360
- { skipIfExists, label: ".pi/task-orchestrator.yaml" }
361
- );
362
- }
363
-
364
- // Version tracker (always overwrite)
365
- const versionInfo = {
366
- version: getPackageVersion(),
367
- installedAt: new Date().toISOString(),
368
- lastUpgraded: new Date().toISOString(),
369
- components: { agents: getPackageVersion(), config: getPackageVersion() },
370
- };
371
- writeFile(
372
- path.join(projectRoot, ".pi", "taskplane.json"),
373
- JSON.stringify(versionInfo, null, 2) + "\n",
374
- { label: ".pi/taskplane.json" }
375
- );
376
-
377
- // CONTEXT.md
378
- const contextSrc = fs.readFileSync(path.join(TEMPLATES_DIR, "tasks", "CONTEXT.md"), "utf-8");
379
- writeFile(
380
- path.join(projectRoot, vars.tasks_root, "CONTEXT.md"),
381
- interpolate(contextSrc, vars),
382
- { skipIfExists, label: `${vars.tasks_root}/CONTEXT.md` }
383
- );
384
-
385
- // Example task
386
- if (!noExamples) {
387
- const exampleDir = path.join(TEMPLATES_DIR, "tasks", "EXAMPLE-001-hello-world");
388
- const destDir = path.join(projectRoot, vars.tasks_root, "EXAMPLE-001-hello-world");
389
- for (const file of ["PROMPT.md", "STATUS.md"]) {
390
- const src = fs.readFileSync(path.join(exampleDir, file), "utf-8");
391
- writeFile(path.join(destDir, file), interpolate(src, vars), {
392
- skipIfExists,
393
- label: `${vars.tasks_root}/EXAMPLE-001-hello-world/${file}`,
394
- });
395
- }
396
- }
397
-
398
- // Auto-commit task files to git so they're available in worktrees
399
- if (!dryRun) {
400
- await autoCommitTaskFiles(projectRoot, vars.tasks_root);
401
- }
402
-
403
- // Report
404
- console.log(`\n${OK} ${c.bold}Taskplane initialized!${c.reset}\n`);
405
- console.log(`${c.bold}Quick start:${c.reset}`);
406
- console.log(` ${c.cyan}pi${c.reset} # start pi (taskplane auto-loads)`);
407
- if (!noExamples) {
408
- console.log(
409
- ` ${c.cyan}/task ${vars.tasks_root}/EXAMPLE-001-hello-world/PROMPT.md${c.reset} # run the example task`
410
- );
411
- }
412
- if (preset !== "runner-only") {
413
- console.log(
414
- ` ${c.cyan}/orch all${c.reset} # orchestrate all pending tasks`
415
- );
416
- }
417
- console.log();
418
- }
419
-
420
- function getPresetVars(preset, projectRoot) {
421
- const dirName = path.basename(projectRoot);
422
- const slug = slugify(dirName);
423
- const { test: test_cmd, build: build_cmd } = detectStack(projectRoot);
424
- return {
425
- project_name: dirName,
426
- integration_branch: "main",
427
- max_lanes: 3,
428
- worktree_prefix: `${slug}-wt`,
429
- tmux_prefix: `${slug}-orch`,
430
- tasks_root: "taskplane-tasks",
431
- default_area: "general",
432
- default_prefix: "TP",
433
- test_cmd,
434
- build_cmd,
435
- date: today(),
436
- };
437
- }
438
-
439
- async function getInteractiveVars(projectRoot) {
440
- const dirName = path.basename(projectRoot);
441
- const detected = detectStack(projectRoot);
442
-
443
- const project_name = await ask("Project name", dirName);
444
- const integration_branch = await ask("Default branch (fallback — orchestrator uses your current branch at runtime)", "main");
445
- const max_lanes = parseInt(await ask("Max parallel lanes", "3")) || 3;
446
- const tasks_root = await ask("Tasks directory", "taskplane-tasks");
447
- const default_area = await ask("Default area name", "general");
448
- const default_prefix = await ask("Task ID prefix", "TP");
449
- const test_cmd = await ask("Test command (agents run this to verify work — blank to skip)", detected.test || "");
450
- const build_cmd = await ask("Build command (agents run this after tests — blank to skip)", detected.build || "");
451
-
452
- const slug = slugify(project_name);
453
- return {
454
- project_name,
455
- integration_branch,
456
- max_lanes,
457
- worktree_prefix: `${slug}-wt`,
458
- tmux_prefix: `${slug}-orch`,
459
- tasks_root,
460
- default_area,
461
- default_prefix,
462
- test_cmd,
463
- build_cmd,
464
- date: today(),
465
- };
466
- }
467
-
468
- function printFileList(vars, noExamples, preset) {
469
- const files = [
470
- ".pi/agents/task-worker.md",
471
- ".pi/agents/task-reviewer.md",
472
- ".pi/agents/task-merger.md",
473
- ".pi/task-runner.yaml",
474
- ];
475
- if (preset !== "runner-only") files.push(".pi/task-orchestrator.yaml");
476
- files.push(".pi/taskplane.json");
477
- files.push(`${vars.tasks_root}/CONTEXT.md`);
478
- if (!noExamples) {
479
- files.push(`${vars.tasks_root}/EXAMPLE-001-hello-world/PROMPT.md`);
480
- files.push(`${vars.tasks_root}/EXAMPLE-001-hello-world/STATUS.md`);
481
- }
482
- for (const f of files) console.log(` ${c.green}create${c.reset} ${f}`);
483
- console.log();
484
- }
485
-
486
- // ─── doctor ─────────────────────────────────────────────────────────────────
487
-
488
- function cmdDoctor() {
489
- const projectRoot = process.cwd();
490
- let issues = 0;
491
-
492
- console.log(`\n${c.bold}Taskplane Doctor${c.reset}\n`);
493
-
494
- // Check prerequisites
495
- const checks = [
496
- { label: "pi installed", check: () => commandExists("pi"), detail: () => getVersion("pi") },
497
- {
498
- label: "Node.js >= 20.0.0",
499
- check: () => {
500
- const v = process.versions.node;
501
- return parseInt(v.split(".")[0]) >= 20;
502
- },
503
- detail: () => `v${process.versions.node}`,
504
- },
505
- { label: "git installed", check: () => commandExists("git"), detail: () => getVersion("git") },
506
- ];
507
-
508
- for (const { label, check, detail } of checks) {
509
- const ok = check();
510
- const info = ok && detail ? ` ${c.dim}(${detail()})${c.reset}` : "";
511
- console.log(` ${ok ? OK : FAIL} ${label}${info}`);
512
- if (!ok) issues++;
513
- }
514
-
515
- // Check tmux (optional)
516
- const hasTmux = commandExists("tmux");
517
- console.log(
518
- ` ${hasTmux ? OK : `${WARN}`} tmux installed${hasTmux ? ` ${c.dim}(${getVersion("tmux", "-V")})${c.reset}` : ` ${c.dim}(optional — needed for spawn_mode: tmux)${c.reset}`}`
519
- );
520
-
521
- // Check package installation
522
- const pkgJson = path.join(PACKAGE_ROOT, "package.json");
523
- const pkgVersion = getPackageVersion();
524
- const isProjectLocal = PACKAGE_ROOT.includes(".pi");
525
- const installType = isProjectLocal ? "project-local" : "global";
526
- console.log(` ${OK} taskplane package installed ${c.dim}(v${pkgVersion}, ${installType})${c.reset}`);
527
-
528
- // Check project config
529
- console.log();
530
- const configFiles = [
531
- { path: ".pi/task-runner.yaml", required: true },
532
- { path: ".pi/task-orchestrator.yaml", required: true },
533
- { path: ".pi/agents/task-worker.md", required: true },
534
- { path: ".pi/agents/task-reviewer.md", required: true },
535
- { path: ".pi/agents/task-merger.md", required: true },
536
- { path: ".pi/taskplane.json", required: false },
537
- ];
538
-
539
- for (const { path: relPath, required } of configFiles) {
540
- const exists = fs.existsSync(path.join(projectRoot, relPath));
541
- if (exists) {
542
- console.log(` ${OK} ${relPath} exists`);
543
- } else if (required) {
544
- console.log(` ${FAIL} ${relPath} missing`);
545
- issues++;
546
- } else {
547
- console.log(` ${WARN} ${relPath} missing ${c.dim}(optional)${c.reset}`);
548
- }
549
- }
550
-
551
- // Check task areas from config
552
- const runnerYaml = readYaml(path.join(projectRoot, ".pi", "task-runner.yaml"));
553
- if (runnerYaml) {
554
- // Simple regex extraction of task area paths
555
- const pathMatches = [...runnerYaml.matchAll(/^\s+path:\s*"?([^"\n]+)"?/gm)];
556
- const contextMatches = [...runnerYaml.matchAll(/^\s+context:\s*"?([^"\n]+)"?/gm)];
557
-
558
- if (pathMatches.length > 0) {
559
- console.log();
560
- for (const match of pathMatches) {
561
- const areaPath = match[1].trim();
562
- const exists = fs.existsSync(path.join(projectRoot, areaPath));
563
- if (exists) {
564
- console.log(` ${OK} task area path: ${areaPath}`);
565
- } else {
566
- console.log(` ${FAIL} task area path: ${areaPath} ${c.dim}(directory not found)${c.reset}`);
567
- console.log(` ${c.dim}→ Run: mkdir -p ${areaPath}${c.reset}`);
568
- issues++;
569
- }
570
- }
571
- for (const match of contextMatches) {
572
- const ctxPath = match[1].trim();
573
- const exists = fs.existsSync(path.join(projectRoot, ctxPath));
574
- if (exists) {
575
- console.log(` ${OK} CONTEXT.md: ${ctxPath}`);
576
- } else {
577
- console.log(` ${WARN} CONTEXT.md: ${ctxPath} ${c.dim}(not found)${c.reset}`);
578
- }
579
- }
580
- }
581
- }
582
-
583
- console.log();
584
- if (issues === 0) {
585
- console.log(`${OK} ${c.green}All checks passed!${c.reset}\n`);
586
- } else {
587
- console.log(`${FAIL} ${issues} issue(s) found. Run ${c.cyan}taskplane init${c.reset} to fix config issues.\n`);
588
- process.exit(1);
589
- }
590
- }
591
-
592
- // ─── version ────────────────────────────────────────────────────────────────
593
-
594
- function cmdVersion() {
595
- const pkgVersion = getPackageVersion();
596
- const isProjectLocal = PACKAGE_ROOT.includes(".pi");
597
- const installType = isProjectLocal ? `project-local: ${PACKAGE_ROOT}` : `global: ${PACKAGE_ROOT}`;
598
-
599
- console.log(`\ntaskplane ${c.bold}v${pkgVersion}${c.reset}`);
600
- console.log(` Package: ${installType}`);
601
-
602
- // Check for project config
603
- const projectRoot = process.cwd();
604
- const tpJson = path.join(projectRoot, ".pi", "taskplane.json");
605
- if (fs.existsSync(tpJson)) {
606
- try {
607
- const info = JSON.parse(fs.readFileSync(tpJson, "utf-8"));
608
- console.log(` Config: .pi/taskplane.json (v${info.version}, initialized ${info.installedAt?.slice(0, 10) || "unknown"})`);
609
- } catch {
610
- console.log(` Config: .pi/taskplane.json (unreadable)`);
611
- }
612
- } else {
613
- console.log(` Config: ${c.dim}not initialized (run taskplane init)${c.reset}`);
614
- }
615
-
616
- // Pi version
617
- const piVersion = getVersion("pi");
618
- if (piVersion) console.log(` Pi: ${piVersion}`);
619
-
620
- // Node version
621
- console.log(` Node: v${process.versions.node}`);
622
- console.log();
623
- }
624
-
625
- function getPackageVersion() {
626
- try {
627
- const pkg = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, "package.json"), "utf-8"));
628
- return pkg.version || "unknown";
629
- } catch {
630
- return "unknown";
631
- }
632
- }
633
-
634
- // ─── dashboard ──────────────────────────────────────────────────────────────
635
-
636
- function cmdDashboard(args) {
637
- const projectRoot = process.cwd();
638
-
639
- if (!fs.existsSync(DASHBOARD_SERVER)) {
640
- die(`Dashboard server not found at ${DASHBOARD_SERVER}`);
641
- }
642
-
643
- // Pass through args to server.cjs, adding --root
644
- const serverArgs = ["--root", projectRoot];
645
-
646
- // Forward --port and --no-open if provided
647
- for (let i = 0; i < args.length; i++) {
648
- if (args[i] === "--port" && args[i + 1]) {
649
- serverArgs.push("--port", args[i + 1]);
650
- i++;
651
- } else if (args[i] === "--no-open") {
652
- serverArgs.push("--no-open");
653
- }
654
- }
655
-
656
- console.log(`\n${c.bold}Taskplane Dashboard${c.reset}`);
657
- console.log(` Project: ${projectRoot}`);
658
- console.log(` Server: ${DASHBOARD_SERVER}\n`);
659
-
660
- const child = spawn("node", [DASHBOARD_SERVER, ...serverArgs], {
661
- stdio: "inherit",
662
- cwd: projectRoot,
663
- });
664
-
665
- child.on("error", (err) => {
666
- die(`Failed to start dashboard: ${err.message}`);
667
- });
668
-
669
- // Forward signals
670
- process.on("SIGINT", () => child.kill("SIGINT"));
671
- process.on("SIGTERM", () => child.kill("SIGTERM"));
672
-
673
- child.on("exit", (code) => {
674
- process.exit(code ?? 0);
675
- });
676
- }
677
-
678
- // ─── help ───────────────────────────────────────────────────────────────────
679
-
680
- function showHelp() {
681
- const version = getPackageVersion();
682
- console.log(`
683
- ${c.bold}taskplane${c.reset} v${version} — AI agent orchestration for pi
684
-
685
- ${c.bold}Usage:${c.reset}
686
- taskplane <command> [options]
687
-
688
- ${c.bold}Commands:${c.reset}
689
- ${c.cyan}init${c.reset} Scaffold Taskplane config in the current project
690
- ${c.cyan}doctor${c.reset} Validate installation and project configuration
691
- ${c.cyan}version${c.reset} Show version information
692
- ${c.cyan}dashboard${c.reset} Launch the web-based orchestrator dashboard
693
- ${c.cyan}help${c.reset} Show this help message
694
-
695
- ${c.bold}Init options:${c.reset}
696
- --preset <name> Use a preset: minimal, full, runner-only
697
- --no-examples Skip example task scaffolding
698
- --force Overwrite existing files without prompting
699
- --dry-run Show what would be created without writing
700
-
701
- ${c.bold}Dashboard options:${c.reset}
702
- --port <number> Port to listen on (default: 8099)
703
- --no-open Don't auto-open browser
704
-
705
- ${c.bold}Examples:${c.reset}
706
- taskplane init # Interactive project setup
707
- taskplane init --preset full # Quick setup with defaults
708
- taskplane init --dry-run # Preview what would be created
709
- taskplane doctor # Check installation health
710
- taskplane dashboard # Launch web dashboard
711
- taskplane dashboard --port 3000 # Dashboard on custom port
712
-
713
- ${c.bold}Getting started:${c.reset}
714
- 1. pi install npm:taskplane # Install the pi package
715
- 2. cd my-project && taskplane init # Scaffold project config
716
- 3. pi # Start pi — /task and /orch are ready
717
- `);
718
- }
719
-
720
- // ═════════════════════════════════════════════════════════════════════════════
721
- // MAIN
722
- // ═════════════════════════════════════════════════════════════════════════════
723
-
724
- const [command, ...args] = process.argv.slice(2);
725
-
726
- switch (command) {
727
- case "init":
728
- await cmdInit(args);
729
- break;
730
- case "doctor":
731
- cmdDoctor();
732
- break;
733
- case "version":
734
- case "--version":
735
- case "-v":
736
- cmdVersion();
737
- break;
738
- case "dashboard":
739
- cmdDashboard(args);
740
- break;
741
- case "help":
742
- case "--help":
743
- case "-h":
744
- case undefined:
745
- showHelp();
746
- break;
747
- default:
748
- console.error(`${FAIL} Unknown command: ${command}`);
749
- showHelp();
750
- process.exit(1);
751
- }
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Taskplane CLI — Project scaffolding, diagnostics, uninstall, and dashboard launcher.
5
+ *
6
+ * This CLI handles what the pi package system cannot: project-local config
7
+ * scaffolding, installation health checks, and dashboard management.
8
+ *
9
+ * Extensions, skills, and themes are delivered via `pi install npm:taskplane`
10
+ * and auto-discovered by pi. This CLI is for everything else.
11
+ */
12
+
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+ import readline from "node:readline";
16
+ import { fileURLToPath } from "node:url";
17
+ import { execSync, spawn } from "node:child_process";
18
+
19
+ // ─── Paths ──────────────────────────────────────────────────────────────────
20
+
21
+ const __filename = fileURLToPath(import.meta.url);
22
+ const __dirname = path.dirname(__filename);
23
+ const PACKAGE_ROOT = path.resolve(__dirname, "..");
24
+ const TEMPLATES_DIR = path.join(PACKAGE_ROOT, "templates");
25
+ const DASHBOARD_SERVER = path.join(PACKAGE_ROOT, "dashboard", "server.cjs");
26
+
27
+ // ─── ANSI Colors ────────────────────────────────────────────────────────────
28
+
29
+ const c = {
30
+ reset: "\x1b[0m",
31
+ bold: "\x1b[1m",
32
+ dim: "\x1b[2m",
33
+ red: "\x1b[31m",
34
+ green: "\x1b[32m",
35
+ yellow: "\x1b[33m",
36
+ blue: "\x1b[34m",
37
+ cyan: "\x1b[36m",
38
+ };
39
+
40
+ const OK = `${c.green}✅${c.reset}`;
41
+ const WARN = `${c.yellow}⚠️${c.reset}`;
42
+ const FAIL = `${c.red}❌${c.reset}`;
43
+ const INFO = `${c.cyan}ℹ${c.reset}`;
44
+
45
+ // ─── Utilities ──────────────────────────────────────────────────────────────
46
+
47
+ function die(msg) {
48
+ console.error(`${FAIL} ${msg}`);
49
+ process.exit(1);
50
+ }
51
+
52
+ function today() {
53
+ return new Date().toISOString().slice(0, 10);
54
+ }
55
+
56
+ function slugify(str) {
57
+ return str
58
+ .toLowerCase()
59
+ .replace(/[^a-z0-9]+/g, "-")
60
+ .replace(/^-|-$/g, "");
61
+ }
62
+
63
+ /** Prompt the user for input. Returns the answer or defaultValue. */
64
+ function ask(question, defaultValue) {
65
+ const rl = readline.createInterface({
66
+ input: process.stdin,
67
+ output: process.stdout,
68
+ });
69
+ const suffix = defaultValue != null ? ` ${c.dim}(${defaultValue})${c.reset}` : "";
70
+ return new Promise((resolve) => {
71
+ rl.question(` ${question}${suffix}: `, (answer) => {
72
+ rl.close();
73
+ resolve(answer.trim() || defaultValue || "");
74
+ });
75
+ });
76
+ }
77
+
78
+ /** Prompt yes/no. Returns boolean. */
79
+ async function confirm(question, defaultYes = true) {
80
+ const hint = defaultYes ? "Y/n" : "y/N";
81
+ const answer = await ask(`${question} [${hint}]`);
82
+ if (!answer) return defaultYes;
83
+ return answer.toLowerCase().startsWith("y");
84
+ }
85
+
86
+ /** Read a YAML file if it exists, return null otherwise. */
87
+ function readYaml(filePath) {
88
+ try {
89
+ // Dynamic import of yaml — it's a dependency of the package
90
+ const raw = fs.readFileSync(filePath, "utf-8");
91
+ // Simple YAML value extraction (avoids requiring yaml at top level for fast startup)
92
+ return raw;
93
+ } catch {
94
+ return null;
95
+ }
96
+ }
97
+
98
+ /** Check if a command exists on PATH. */
99
+ function commandExists(cmd) {
100
+ try {
101
+ const which = process.platform === "win32" ? "where" : "which";
102
+ execSync(`${which} ${cmd}`, { stdio: "pipe" });
103
+ return true;
104
+ } catch {
105
+ return false;
106
+ }
107
+ }
108
+
109
+ /** Get command version string. */
110
+ function getVersion(cmd, flag = "--version") {
111
+ try {
112
+ return execSync(`${cmd} ${flag}`, { stdio: "pipe" }).toString().trim();
113
+ } catch {
114
+ return null;
115
+ }
116
+ }
117
+
118
+ /** Write a file, creating parent directories as needed. Optionally skip if exists. */
119
+ function writeFile(dest, content, { skipIfExists = false, label = "" } = {}) {
120
+ if (skipIfExists && fs.existsSync(dest)) {
121
+ if (label) console.log(` ${c.dim}skip${c.reset} ${label} (already exists)`);
122
+ return false;
123
+ }
124
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
125
+ fs.writeFileSync(dest, content, "utf-8");
126
+ if (label) console.log(` ${c.green}create${c.reset} ${label}`);
127
+ return true;
128
+ }
129
+
130
+ /** Copy a file from templates, creating parent dirs. */
131
+ function copyTemplate(src, dest, { skipIfExists = false, label = "" } = {}) {
132
+ if (skipIfExists && fs.existsSync(dest)) {
133
+ if (label) console.log(` ${c.dim}skip${c.reset} ${label} (already exists)`);
134
+ return false;
135
+ }
136
+ const content = fs.readFileSync(src, "utf-8");
137
+ return writeFile(dest, content, { label });
138
+ }
139
+
140
+ /** Replace {{variables}} in template content. */
141
+ function interpolate(content, vars) {
142
+ return content.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? `{{${key}}}`);
143
+ }
144
+
145
+ // ─── Stack Detection ────────────────────────────────────────────────────────
146
+
147
+ function detectStack(projectRoot) {
148
+ const checks = [
149
+ { file: "package.json", stack: "node", test: "npm test", build: "npm run build" },
150
+ { file: "go.mod", stack: "go", test: "go test ./...", build: "go build ./..." },
151
+ { file: "Cargo.toml", stack: "rust", test: "cargo test", build: "cargo build" },
152
+ { file: "pyproject.toml", stack: "python", test: "pytest", build: "" },
153
+ { file: "pom.xml", stack: "java-maven", test: "mvn test", build: "mvn package" },
154
+ { file: "build.gradle", stack: "java-gradle", test: "gradle test", build: "gradle build" },
155
+ ];
156
+ for (const { file, stack, test, build } of checks) {
157
+ if (fs.existsSync(path.join(projectRoot, file))) {
158
+ return { stack, test, build };
159
+ }
160
+ }
161
+ return { stack: "unknown", test: "", build: "" };
162
+ }
163
+
164
+ // ─── YAML Generation ────────────────────────────────────────────────────────
165
+
166
+ function generateTaskRunnerYaml(vars) {
167
+ return `# ═══════════════════════════════════════════════════════════════════════
168
+ # Task Runner Configuration — ${vars.project_name}
169
+ # ═══════════════════════════════════════════════════════════════════════
170
+ #
171
+ # This file configures the /task command (task-runner extension).
172
+ # Edit freely — this file is owned by you, not the package.
173
+
174
+ # ── Task Areas ────────────────────────────────────────────────────────
175
+ # Define where tasks live. Each area has a folder path, ID prefix, and
176
+ # a CONTEXT.md file that provides domain context to agents.
177
+
178
+ task_areas:
179
+ ${vars.default_area}:
180
+ path: "${vars.tasks_root}"
181
+ prefix: "${vars.default_prefix}"
182
+ context: "${vars.tasks_root}/CONTEXT.md"
183
+
184
+ # ── Reference Docs ────────────────────────────────────────────────────
185
+ # Docs that tasks can reference in their "Context to Read First" section.
186
+ # Add your project's architecture docs, API specs, etc.
187
+
188
+ reference_docs: {}
189
+
190
+ # ── Standards ─────────────────────────────────────────────────────────
191
+ # Coding standards and rules. Agents follow these during implementation.
192
+
193
+ standards: {}
194
+
195
+ # ── Testing ───────────────────────────────────────────────────────────
196
+ # Commands that agents run to verify their work.
197
+
198
+ testing:
199
+ commands:${vars.test_cmd ? `\n unit: "${vars.test_cmd}"` : ""}${vars.build_cmd ? `\n build: "${vars.build_cmd}"` : ""}
200
+ `;
201
+ }
202
+
203
+ function generateOrchestratorYaml(vars) {
204
+ return `# ═══════════════════════════════════════════════════════════════════════
205
+ # Parallel Task Orchestrator Configuration — ${vars.project_name}
206
+ # ═══════════════════════════════════════════════════════════════════════
207
+ #
208
+ # This file configures the /orch commands (task-orchestrator extension).
209
+ # Edit freely — this file is owned by you, not the package.
210
+
211
+ orchestrator:
212
+ max_lanes: ${vars.max_lanes}
213
+ worktree_location: "subdirectory"
214
+ worktree_prefix: "${vars.worktree_prefix}"
215
+ integration_branch: "${vars.integration_branch}"
216
+ batch_id_format: "timestamp"
217
+ spawn_mode: "subprocess"
218
+ tmux_prefix: "${vars.tmux_prefix}"
219
+
220
+ dependencies:
221
+ source: "prompt"
222
+ cache: true
223
+
224
+ assignment:
225
+ strategy: "affinity-first"
226
+ size_weights:
227
+ S: 1
228
+ M: 2
229
+ L: 4
230
+
231
+ pre_warm:
232
+ auto_detect: false
233
+ commands: {}
234
+ always: []
235
+
236
+ merge:
237
+ model: ""
238
+ tools: "read,write,edit,bash,grep,find,ls"
239
+ verify: []
240
+ order: "fewest-files-first"
241
+
242
+ failure:
243
+ on_task_failure: "skip-dependents"
244
+ on_merge_failure: "pause"
245
+ stall_timeout: 30
246
+ max_worker_minutes: 30
247
+ abort_grace_period: 60
248
+
249
+ monitoring:
250
+ poll_interval: 5
251
+ `;
252
+ }
253
+
254
+ // ═════════════════════════════════════════════════════════════════════════════
255
+ // COMMANDS
256
+ // ═════════════════════════════════════════════════════════════════════════════
257
+
258
+ /** Auto-commit task files to git so they're available in orchestrator worktrees. */
259
+ async function autoCommitTaskFiles(projectRoot, tasksRoot) {
260
+ // Check if we're in a git repo
261
+ try {
262
+ execSync("git rev-parse --is-inside-work-tree", { cwd: projectRoot, stdio: "pipe" });
263
+ } catch {
264
+ // Not a git repo — skip silently
265
+ return;
266
+ }
267
+
268
+ // Stage the tasks directory (not .pi/ — that's gitignored)
269
+ const tasksDir = path.join(projectRoot, tasksRoot);
270
+ if (!fs.existsSync(tasksDir)) return;
271
+
272
+ try {
273
+ // Check if there's anything new to commit
274
+ execSync(`git add "${tasksRoot}"`, { cwd: projectRoot, stdio: "pipe" });
275
+ const status = execSync("git diff --cached --name-only", { cwd: projectRoot, stdio: "pipe" })
276
+ .toString()
277
+ .trim();
278
+
279
+ if (!status) return; // nothing staged
280
+
281
+ execSync('git commit -m "chore: initialize taskplane tasks"', {
282
+ cwd: projectRoot,
283
+ stdio: "pipe",
284
+ });
285
+ console.log(`\n ${c.green}git${c.reset} committed ${tasksRoot}/ to git`);
286
+ console.log(` ${c.dim}(orchestrator worktrees require committed files)${c.reset}`);
287
+ } catch (err) {
288
+ // Git commit failed — warn but don't block init
289
+ console.log(`\n ${WARN} Could not auto-commit task files to git.`);
290
+ console.log(` ${c.dim}Run manually before using /orch: git add ${tasksRoot} && git commit -m "add taskplane tasks"${c.reset}`);
291
+ }
292
+ }
293
+
294
+ function discoverTaskAreaPaths(projectRoot) {
295
+ const runnerPath = path.join(projectRoot, ".pi", "task-runner.yaml");
296
+ if (!fs.existsSync(runnerPath)) return [];
297
+
298
+ const raw = readYaml(runnerPath);
299
+ if (!raw) return [];
300
+
301
+ const lines = raw.split(/\r?\n/);
302
+ let inTaskAreas = false;
303
+ const paths = new Set();
304
+
305
+ for (const line of lines) {
306
+ const trimmed = line.trim();
307
+
308
+ if (!inTaskAreas) {
309
+ if (/^task_areas:\s*$/.test(trimmed)) {
310
+ inTaskAreas = true;
311
+ }
312
+ continue;
313
+ }
314
+
315
+ // End of task_areas block when we hit next top-level key
316
+ if (/^[A-Za-z0-9_]+\s*:\s*$/.test(line)) {
317
+ break;
318
+ }
319
+
320
+ const m = line.match(/^\s{4}path:\s*["']?([^"'\n#]+)["']?\s*(?:#.*)?$/);
321
+ if (m?.[1]) {
322
+ paths.add(m[1].trim());
323
+ }
324
+ }
325
+
326
+ return [...paths];
327
+ }
328
+
329
+ function pruneEmptyDir(dirPath) {
330
+ try {
331
+ if (!fs.existsSync(dirPath)) return false;
332
+ if (fs.readdirSync(dirPath).length !== 0) return false;
333
+ fs.rmdirSync(dirPath);
334
+ return true;
335
+ } catch {
336
+ return false;
337
+ }
338
+ }
339
+
340
+ function listExampleTaskTemplates() {
341
+ const tasksTemplatesDir = path.join(TEMPLATES_DIR, "tasks");
342
+ try {
343
+ return fs.readdirSync(tasksTemplatesDir, { withFileTypes: true })
344
+ .filter((entry) => entry.isDirectory() && /^EXAMPLE-\d+/i.test(entry.name))
345
+ .map((entry) => entry.name)
346
+ .sort();
347
+ } catch {
348
+ return [];
349
+ }
350
+ }
351
+
352
+ async function cmdUninstall(args) {
353
+ const projectRoot = process.cwd();
354
+ const dryRun = args.includes("--dry-run");
355
+ const yes = args.includes("--yes") || args.includes("-y");
356
+ const removePackage = args.includes("--package") || args.includes("--all") || args.includes("--package-only");
357
+ const packageOnly = args.includes("--package-only");
358
+ const removeProject = !packageOnly;
359
+ const removeTasks = removeProject && (args.includes("--remove-tasks") || args.includes("--all"));
360
+ const local = args.includes("--local");
361
+ const global = args.includes("--global");
362
+
363
+ if (local && global) {
364
+ die("Choose either --local or --global, not both.");
365
+ }
366
+
367
+ console.log(`\n${c.bold}Taskplane Uninstall${c.reset}\n`);
368
+
369
+ const managedFiles = [
370
+ ".pi/task-runner.yaml",
371
+ ".pi/task-orchestrator.yaml",
372
+ ".pi/taskplane.json",
373
+ ".pi/agents/task-worker.md",
374
+ ".pi/agents/task-reviewer.md",
375
+ ".pi/agents/task-merger.md",
376
+ ".pi/batch-state.json",
377
+ ".pi/batch-history.json",
378
+ ".pi/orch-abort-signal",
379
+ ];
380
+
381
+ const sidecarPrefixes = [
382
+ "lane-state-",
383
+ "worker-conversation-",
384
+ "merge-result-",
385
+ "merge-request-",
386
+ ];
387
+
388
+ const filesToDelete = managedFiles
389
+ .map(rel => ({ rel, abs: path.join(projectRoot, rel) }))
390
+ .filter(({ abs }) => fs.existsSync(abs));
391
+
392
+ const piDir = path.join(projectRoot, ".pi");
393
+ const sidecarsToDelete = fs.existsSync(piDir)
394
+ ? fs.readdirSync(piDir)
395
+ .filter(name => sidecarPrefixes.some(prefix => name.startsWith(prefix)))
396
+ .map(name => ({ rel: path.join(".pi", name), abs: path.join(piDir, name) }))
397
+ : [];
398
+
399
+ let taskDirsToDelete = [];
400
+ if (removeTasks) {
401
+ const areaPaths = discoverTaskAreaPaths(projectRoot);
402
+ const rootPrefix = path.resolve(projectRoot) + path.sep;
403
+ taskDirsToDelete = areaPaths
404
+ .map(rel => ({ rel, abs: path.resolve(projectRoot, rel) }))
405
+ .filter(({ abs }) => abs.startsWith(rootPrefix) && fs.existsSync(abs));
406
+ }
407
+
408
+ const inferredInstallType = /[\\/]\.pi[\\/]/.test(PACKAGE_ROOT) ? "local" : "global";
409
+ const packageScope = local ? "local" : global ? "global" : inferredInstallType;
410
+ const piRemoveCmd = packageScope === "local"
411
+ ? "pi remove -l npm:taskplane"
412
+ : "pi remove npm:taskplane";
413
+
414
+ if (!removeProject && !removePackage) {
415
+ console.log(` ${WARN} Nothing to do. Use one of:`);
416
+ console.log(` ${c.cyan}taskplane uninstall${c.reset} # remove project-scaffolded files`);
417
+ console.log(` ${c.cyan}taskplane uninstall --package${c.reset} # remove installed package via pi`);
418
+ console.log();
419
+ return;
420
+ }
421
+
422
+ if (removeProject) {
423
+ console.log(`${c.bold}Project cleanup:${c.reset}`);
424
+ if (filesToDelete.length === 0 && sidecarsToDelete.length === 0 && taskDirsToDelete.length === 0) {
425
+ console.log(` ${c.dim}No Taskplane-managed project files found.${c.reset}`);
426
+ }
427
+ for (const f of filesToDelete) console.log(` - remove ${f.rel}`);
428
+ for (const f of sidecarsToDelete) console.log(` - remove ${f.rel}`);
429
+ for (const d of taskDirsToDelete) console.log(` - remove dir ${d.rel}`);
430
+ if (removeTasks && taskDirsToDelete.length === 0) {
431
+ console.log(` ${c.dim}No task area directories found from .pi/task-runner.yaml.${c.reset}`);
432
+ }
433
+ if (!removeTasks) {
434
+ console.log(` ${c.dim}Task directories are preserved by default (use --remove-tasks to delete them).${c.reset}`);
435
+ }
436
+ console.log();
437
+ }
438
+
439
+ if (removePackage) {
440
+ console.log(`${c.bold}Package cleanup:${c.reset}`);
441
+ console.log(` - run ${piRemoveCmd}`);
442
+ console.log(` ${c.dim}(removes extensions, skills, and dashboard files from this install scope)${c.reset}`);
443
+ console.log();
444
+ }
445
+
446
+ if (dryRun) {
447
+ console.log(`${INFO} Dry run complete. No files were changed.\n`);
448
+ return;
449
+ }
450
+
451
+ if (!yes) {
452
+ const proceed = await confirm("Proceed with uninstall?", false);
453
+ if (!proceed) {
454
+ console.log(" Aborted.");
455
+ return;
456
+ }
457
+ if (removeTasks) {
458
+ const taskConfirm = await confirm("This will delete task area directories recursively. Continue?", false);
459
+ if (!taskConfirm) {
460
+ console.log(" Aborted.");
461
+ return;
462
+ }
463
+ }
464
+ }
465
+
466
+ let removedCount = 0;
467
+ let failedCount = 0;
468
+
469
+ if (removeProject) {
470
+ for (const item of [...filesToDelete, ...sidecarsToDelete]) {
471
+ try {
472
+ fs.unlinkSync(item.abs);
473
+ removedCount++;
474
+ } catch (err) {
475
+ failedCount++;
476
+ console.log(` ${WARN} Failed to remove ${item.rel}: ${err.message}`);
477
+ }
478
+ }
479
+
480
+ for (const dir of taskDirsToDelete) {
481
+ try {
482
+ fs.rmSync(dir.abs, { recursive: true, force: true });
483
+ removedCount++;
484
+ } catch (err) {
485
+ failedCount++;
486
+ console.log(` ${WARN} Failed to remove directory ${dir.rel}: ${err.message}`);
487
+ }
488
+ }
489
+
490
+ // Best-effort cleanup of empty folders
491
+ pruneEmptyDir(path.join(projectRoot, ".pi", "agents"));
492
+ pruneEmptyDir(path.join(projectRoot, ".pi"));
493
+ }
494
+
495
+ if (removePackage) {
496
+ if (!commandExists("pi")) {
497
+ failedCount++;
498
+ console.log(` ${FAIL} pi is not on PATH; could not run: ${piRemoveCmd}`);
499
+ } else {
500
+ try {
501
+ execSync(piRemoveCmd, { cwd: projectRoot, stdio: "inherit" });
502
+ } catch {
503
+ failedCount++;
504
+ console.log(` ${FAIL} Package uninstall failed: ${piRemoveCmd}`);
505
+ }
506
+ }
507
+ }
508
+
509
+ console.log();
510
+ if (failedCount === 0) {
511
+ console.log(`${OK} ${c.bold}Uninstall complete.${c.reset}`);
512
+ if (removeProject) {
513
+ console.log(` Removed ${removedCount} project artifact(s).`);
514
+ }
515
+ console.log();
516
+ } else {
517
+ console.log(`${FAIL} Uninstall completed with ${failedCount} error(s).`);
518
+ if (removeProject) {
519
+ console.log(` Removed ${removedCount} project artifact(s).`);
520
+ }
521
+ console.log();
522
+ process.exit(1);
523
+ }
524
+ }
525
+
526
+ // ─── init ───────────────────────────────────────────────────────────────────
527
+
528
+ async function cmdInit(args) {
529
+ const projectRoot = process.cwd();
530
+ const force = args.includes("--force");
531
+ const dryRun = args.includes("--dry-run");
532
+ const noExamplesFlag = args.includes("--no-examples");
533
+ const includeExamples = args.includes("--include-examples");
534
+ const presetIdx = args.indexOf("--preset");
535
+ const preset = presetIdx !== -1 ? args[presetIdx + 1] : null;
536
+ const tasksRootIdx = args.indexOf("--tasks-root");
537
+ const tasksRootRaw = tasksRootIdx !== -1 ? args[tasksRootIdx + 1] : null;
538
+
539
+ if (noExamplesFlag && includeExamples) {
540
+ die("Choose either --no-examples or --include-examples, not both.");
541
+ }
542
+
543
+ if (tasksRootIdx !== -1 && (!tasksRootRaw || tasksRootRaw.startsWith("--"))) {
544
+ die("Missing value for --tasks-root <relative-path>.");
545
+ }
546
+
547
+ let tasksRootOverride = null;
548
+ if (tasksRootRaw) {
549
+ if (path.isAbsolute(tasksRootRaw)) {
550
+ die("--tasks-root must be relative to the project root (absolute paths are not allowed).");
551
+ }
552
+ tasksRootOverride = tasksRootRaw.trim().replace(/\\/g, "/").replace(/^\.\/+/, "").replace(/\/+$/, "");
553
+ if (!tasksRootOverride || tasksRootOverride === ".") {
554
+ die("--tasks-root must not be empty.");
555
+ }
556
+ if (tasksRootOverride === ".." || tasksRootOverride.startsWith("../")) {
557
+ die("--tasks-root must stay within the project root (paths starting with .. are not allowed).");
558
+ }
559
+ }
560
+
561
+ const noExamples = noExamplesFlag || (!!tasksRootOverride && !includeExamples);
562
+
563
+ console.log(`\n${c.bold}Taskplane Init${c.reset}\n`);
564
+
565
+ if (tasksRootOverride && !noExamplesFlag && !includeExamples) {
566
+ console.log(` ${INFO} Using custom --tasks-root (${tasksRootOverride}); skipping example tasks by default.`);
567
+ console.log(` Use --include-examples to scaffold examples into that directory.\n`);
568
+ }
569
+
570
+ // Check for existing config
571
+ const hasConfig =
572
+ fs.existsSync(path.join(projectRoot, ".pi", "task-runner.yaml")) ||
573
+ fs.existsSync(path.join(projectRoot, ".pi", "task-orchestrator.yaml"));
574
+
575
+ if (hasConfig && !force) {
576
+ console.log(`${WARN} Taskplane config already exists in this project.`);
577
+ const proceed = await confirm(" Overwrite existing files?", false);
578
+ if (!proceed) {
579
+ console.log(" Aborted.");
580
+ return;
581
+ }
582
+ }
583
+
584
+ // Gather config values
585
+ let vars;
586
+ if (preset === "minimal" || preset === "full" || preset === "runner-only") {
587
+ vars = getPresetVars(preset, projectRoot, tasksRootOverride);
588
+ console.log(` Using preset: ${c.cyan}${preset}${c.reset}`);
589
+ if (tasksRootOverride) {
590
+ console.log(` Task directory: ${c.cyan}${tasksRootOverride}${c.reset}`);
591
+ }
592
+ console.log();
593
+ } else {
594
+ vars = await getInteractiveVars(projectRoot, tasksRootOverride);
595
+ }
596
+
597
+ const exampleTemplateDirs = noExamples ? [] : listExampleTaskTemplates();
598
+
599
+ if (dryRun) {
600
+ console.log(`\n${c.bold}Dry run — files that would be created:${c.reset}\n`);
601
+ printFileList(vars, noExamples, preset, exampleTemplateDirs);
602
+ return;
603
+ }
604
+
605
+ // Scaffold files
606
+ console.log(`\n${c.bold}Creating files...${c.reset}\n`);
607
+ const skipIfExists = !force;
608
+
609
+ // Agent prompts
610
+ for (const agent of ["task-worker.md", "task-reviewer.md", "task-merger.md"]) {
611
+ copyTemplate(
612
+ path.join(TEMPLATES_DIR, "agents", agent),
613
+ path.join(projectRoot, ".pi", "agents", agent),
614
+ { skipIfExists, label: `.pi/agents/${agent}` }
615
+ );
616
+ }
617
+
618
+ // Task runner config
619
+ writeFile(
620
+ path.join(projectRoot, ".pi", "task-runner.yaml"),
621
+ generateTaskRunnerYaml(vars),
622
+ { skipIfExists, label: ".pi/task-runner.yaml" }
623
+ );
624
+
625
+ // Orchestrator config (skip for runner-only preset)
626
+ if (preset !== "runner-only") {
627
+ writeFile(
628
+ path.join(projectRoot, ".pi", "task-orchestrator.yaml"),
629
+ generateOrchestratorYaml(vars),
630
+ { skipIfExists, label: ".pi/task-orchestrator.yaml" }
631
+ );
632
+ }
633
+
634
+ // Version tracker (always overwrite)
635
+ const versionInfo = {
636
+ version: getPackageVersion(),
637
+ installedAt: new Date().toISOString(),
638
+ lastUpgraded: new Date().toISOString(),
639
+ components: { agents: getPackageVersion(), config: getPackageVersion() },
640
+ };
641
+ writeFile(
642
+ path.join(projectRoot, ".pi", "taskplane.json"),
643
+ JSON.stringify(versionInfo, null, 2) + "\n",
644
+ { label: ".pi/taskplane.json" }
645
+ );
646
+
647
+ // CONTEXT.md
648
+ const contextSrc = fs.readFileSync(path.join(TEMPLATES_DIR, "tasks", "CONTEXT.md"), "utf-8");
649
+ writeFile(
650
+ path.join(projectRoot, vars.tasks_root, "CONTEXT.md"),
651
+ interpolate(contextSrc, vars),
652
+ { skipIfExists, label: `${vars.tasks_root}/CONTEXT.md` }
653
+ );
654
+
655
+ // Example tasks
656
+ if (!noExamples) {
657
+ for (const exampleName of exampleTemplateDirs) {
658
+ const exampleDir = path.join(TEMPLATES_DIR, "tasks", exampleName);
659
+ const destDir = path.join(projectRoot, vars.tasks_root, exampleName);
660
+ for (const file of ["PROMPT.md", "STATUS.md"]) {
661
+ const srcPath = path.join(exampleDir, file);
662
+ if (!fs.existsSync(srcPath)) continue;
663
+ const src = fs.readFileSync(srcPath, "utf-8");
664
+ writeFile(path.join(destDir, file), interpolate(src, vars), {
665
+ skipIfExists,
666
+ label: `${vars.tasks_root}/${exampleName}/${file}`,
667
+ });
668
+ }
669
+ }
670
+ if (exampleTemplateDirs.length === 0) {
671
+ console.log(` ${WARN} No example task templates found under templates/tasks/EXAMPLE-*`);
672
+ }
673
+ }
674
+
675
+ // Auto-commit task files to git so they're available in worktrees
676
+ await autoCommitTaskFiles(projectRoot, vars.tasks_root);
677
+
678
+ // Report
679
+ console.log(`\n${OK} ${c.bold}Taskplane initialized!${c.reset}\n`);
680
+ console.log(`${c.bold}Quick start:${c.reset}`);
681
+ console.log(` ${c.cyan}pi${c.reset} # start pi (taskplane auto-loads)`);
682
+ if (preset !== "runner-only") {
683
+ console.log(` ${c.cyan}/orch-plan all${c.reset} # preview waves/lanes/dependencies`);
684
+ console.log(` ${c.cyan}/orch all${c.reset} # run examples via orchestrator`);
685
+ }
686
+ if (!noExamples && exampleTemplateDirs.length > 0) {
687
+ const firstExample = exampleTemplateDirs[0];
688
+ console.log(` ${c.dim}optional single-task mode:${c.reset}`);
689
+ console.log(` ${c.cyan}/task ${vars.tasks_root}/${firstExample}/PROMPT.md${c.reset}`);
690
+ }
691
+ console.log();
692
+ }
693
+
694
+ function getPresetVars(preset, projectRoot, tasksRootOverride = null) {
695
+ const dirName = path.basename(projectRoot);
696
+ const slug = slugify(dirName);
697
+ const { test: test_cmd, build: build_cmd } = detectStack(projectRoot);
698
+ return {
699
+ project_name: dirName,
700
+ integration_branch: "main",
701
+ max_lanes: 3,
702
+ worktree_prefix: `${slug}-wt`,
703
+ tmux_prefix: `${slug}-orch`,
704
+ tasks_root: tasksRootOverride || "taskplane-tasks",
705
+ default_area: "general",
706
+ default_prefix: "TP",
707
+ test_cmd,
708
+ build_cmd,
709
+ date: today(),
710
+ };
711
+ }
712
+
713
+ async function getInteractiveVars(projectRoot, tasksRootOverride = null) {
714
+ const dirName = path.basename(projectRoot);
715
+ const detected = detectStack(projectRoot);
716
+
717
+ const project_name = await ask("Project name", dirName);
718
+ const integration_branch = await ask("Default branch (fallback — orchestrator uses your current branch at runtime)", "main");
719
+ const max_lanes = parseInt(await ask("Max parallel lanes", "3")) || 3;
720
+ const tasks_root = tasksRootOverride || await ask("Tasks directory", "taskplane-tasks");
721
+ const default_area = await ask("Default area name", "general");
722
+ const default_prefix = await ask("Task ID prefix", "TP");
723
+ const test_cmd = await ask("Test command (agents run this to verify work — blank to skip)", detected.test || "");
724
+ const build_cmd = await ask("Build command (agents run this after tests — blank to skip)", detected.build || "");
725
+
726
+ const slug = slugify(project_name);
727
+ return {
728
+ project_name,
729
+ integration_branch,
730
+ max_lanes,
731
+ worktree_prefix: `${slug}-wt`,
732
+ tmux_prefix: `${slug}-orch`,
733
+ tasks_root,
734
+ default_area,
735
+ default_prefix,
736
+ test_cmd,
737
+ build_cmd,
738
+ date: today(),
739
+ };
740
+ }
741
+
742
+ function printFileList(vars, noExamples, preset, exampleTemplateDirs = []) {
743
+ const files = [
744
+ ".pi/agents/task-worker.md",
745
+ ".pi/agents/task-reviewer.md",
746
+ ".pi/agents/task-merger.md",
747
+ ".pi/task-runner.yaml",
748
+ ];
749
+ if (preset !== "runner-only") files.push(".pi/task-orchestrator.yaml");
750
+ files.push(".pi/taskplane.json");
751
+ files.push(`${vars.tasks_root}/CONTEXT.md`);
752
+ if (!noExamples) {
753
+ for (const exampleName of exampleTemplateDirs) {
754
+ files.push(`${vars.tasks_root}/${exampleName}/PROMPT.md`);
755
+ files.push(`${vars.tasks_root}/${exampleName}/STATUS.md`);
756
+ }
757
+ }
758
+ for (const f of files) console.log(` ${c.green}create${c.reset} ${f}`);
759
+ console.log();
760
+ }
761
+
762
+ // ─── doctor ─────────────────────────────────────────────────────────────────
763
+
764
+ function cmdDoctor() {
765
+ const projectRoot = process.cwd();
766
+ let issues = 0;
767
+
768
+ console.log(`\n${c.bold}Taskplane Doctor${c.reset}\n`);
769
+
770
+ // Check prerequisites
771
+ const checks = [
772
+ { label: "pi installed", check: () => commandExists("pi"), detail: () => getVersion("pi") },
773
+ {
774
+ label: "Node.js >= 20.0.0",
775
+ check: () => {
776
+ const v = process.versions.node;
777
+ return parseInt(v.split(".")[0]) >= 20;
778
+ },
779
+ detail: () => `v${process.versions.node}`,
780
+ },
781
+ { label: "git installed", check: () => commandExists("git"), detail: () => getVersion("git") },
782
+ ];
783
+
784
+ for (const { label, check, detail } of checks) {
785
+ const ok = check();
786
+ const info = ok && detail ? ` ${c.dim}(${detail()})${c.reset}` : "";
787
+ console.log(` ${ok ? OK : FAIL} ${label}${info}`);
788
+ if (!ok) issues++;
789
+ }
790
+
791
+ // Check tmux (optional)
792
+ const hasTmux = commandExists("tmux");
793
+ console.log(
794
+ ` ${hasTmux ? OK : `${WARN}`} tmux installed${hasTmux ? ` ${c.dim}(${getVersion("tmux", "-V")})${c.reset}` : ` ${c.dim}(optional — needed for spawn_mode: tmux)${c.reset}`}`
795
+ );
796
+
797
+ // Check package installation
798
+ const pkgJson = path.join(PACKAGE_ROOT, "package.json");
799
+ const pkgVersion = getPackageVersion();
800
+ const isProjectLocal = PACKAGE_ROOT.includes(".pi");
801
+ const installType = isProjectLocal ? "project-local" : "global";
802
+ console.log(` ${OK} taskplane package installed ${c.dim}(v${pkgVersion}, ${installType})${c.reset}`);
803
+
804
+ // Check project config
805
+ console.log();
806
+ const configFiles = [
807
+ { path: ".pi/task-runner.yaml", required: true },
808
+ { path: ".pi/task-orchestrator.yaml", required: true },
809
+ { path: ".pi/agents/task-worker.md", required: true },
810
+ { path: ".pi/agents/task-reviewer.md", required: true },
811
+ { path: ".pi/agents/task-merger.md", required: true },
812
+ { path: ".pi/taskplane.json", required: false },
813
+ ];
814
+
815
+ for (const { path: relPath, required } of configFiles) {
816
+ const exists = fs.existsSync(path.join(projectRoot, relPath));
817
+ if (exists) {
818
+ console.log(` ${OK} ${relPath} exists`);
819
+ } else if (required) {
820
+ console.log(` ${FAIL} ${relPath} missing`);
821
+ issues++;
822
+ } else {
823
+ console.log(` ${WARN} ${relPath} missing ${c.dim}(optional)${c.reset}`);
824
+ }
825
+ }
826
+
827
+ // Check task areas from config
828
+ const runnerYaml = readYaml(path.join(projectRoot, ".pi", "task-runner.yaml"));
829
+ if (runnerYaml) {
830
+ // Simple regex extraction of task area paths
831
+ const pathMatches = [...runnerYaml.matchAll(/^\s+path:\s*"?([^"\n]+)"?/gm)];
832
+ const contextMatches = [...runnerYaml.matchAll(/^\s+context:\s*"?([^"\n]+)"?/gm)];
833
+
834
+ if (pathMatches.length > 0) {
835
+ console.log();
836
+ for (const match of pathMatches) {
837
+ const areaPath = match[1].trim();
838
+ const exists = fs.existsSync(path.join(projectRoot, areaPath));
839
+ if (exists) {
840
+ console.log(` ${OK} task area path: ${areaPath}`);
841
+ } else {
842
+ console.log(` ${FAIL} task area path: ${areaPath} ${c.dim}(directory not found)${c.reset}`);
843
+ console.log(` ${c.dim}→ Run: mkdir -p ${areaPath}${c.reset}`);
844
+ issues++;
845
+ }
846
+ }
847
+ for (const match of contextMatches) {
848
+ const ctxPath = match[1].trim();
849
+ const exists = fs.existsSync(path.join(projectRoot, ctxPath));
850
+ if (exists) {
851
+ console.log(` ${OK} CONTEXT.md: ${ctxPath}`);
852
+ } else {
853
+ console.log(` ${WARN} CONTEXT.md: ${ctxPath} ${c.dim}(not found)${c.reset}`);
854
+ }
855
+ }
856
+ }
857
+ }
858
+
859
+ console.log();
860
+ if (issues === 0) {
861
+ console.log(`${OK} ${c.green}All checks passed!${c.reset}\n`);
862
+ } else {
863
+ console.log(`${FAIL} ${issues} issue(s) found. Run ${c.cyan}taskplane init${c.reset} to fix config issues.\n`);
864
+ process.exit(1);
865
+ }
866
+ }
867
+
868
+ // ─── version ────────────────────────────────────────────────────────────────
869
+
870
+ function cmdVersion() {
871
+ const pkgVersion = getPackageVersion();
872
+ const isProjectLocal = PACKAGE_ROOT.includes(".pi");
873
+ const installType = isProjectLocal ? `project-local: ${PACKAGE_ROOT}` : `global: ${PACKAGE_ROOT}`;
874
+
875
+ console.log(`\ntaskplane ${c.bold}v${pkgVersion}${c.reset}`);
876
+ console.log(` Package: ${installType}`);
877
+
878
+ // Check for project config
879
+ const projectRoot = process.cwd();
880
+ const tpJson = path.join(projectRoot, ".pi", "taskplane.json");
881
+ if (fs.existsSync(tpJson)) {
882
+ try {
883
+ const info = JSON.parse(fs.readFileSync(tpJson, "utf-8"));
884
+ console.log(` Config: .pi/taskplane.json (v${info.version}, initialized ${info.installedAt?.slice(0, 10) || "unknown"})`);
885
+ } catch {
886
+ console.log(` Config: .pi/taskplane.json (unreadable)`);
887
+ }
888
+ } else {
889
+ console.log(` Config: ${c.dim}not initialized (run taskplane init)${c.reset}`);
890
+ }
891
+
892
+ // Pi version
893
+ const piVersion = getVersion("pi");
894
+ if (piVersion) console.log(` Pi: ${piVersion}`);
895
+
896
+ // Node version
897
+ console.log(` Node: v${process.versions.node}`);
898
+ console.log();
899
+ }
900
+
901
+ function getPackageVersion() {
902
+ try {
903
+ const pkg = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, "package.json"), "utf-8"));
904
+ return pkg.version || "unknown";
905
+ } catch {
906
+ return "unknown";
907
+ }
908
+ }
909
+
910
+ // ─── dashboard ──────────────────────────────────────────────────────────────
911
+
912
+ function cmdDashboard(args) {
913
+ const projectRoot = process.cwd();
914
+
915
+ if (!fs.existsSync(DASHBOARD_SERVER)) {
916
+ die(`Dashboard server not found at ${DASHBOARD_SERVER}`);
917
+ }
918
+
919
+ // Pass through args to server.cjs, adding --root
920
+ const serverArgs = ["--root", projectRoot];
921
+
922
+ // Forward --port and --no-open if provided
923
+ for (let i = 0; i < args.length; i++) {
924
+ if (args[i] === "--port" && args[i + 1]) {
925
+ serverArgs.push("--port", args[i + 1]);
926
+ i++;
927
+ } else if (args[i] === "--no-open") {
928
+ serverArgs.push("--no-open");
929
+ }
930
+ }
931
+
932
+ console.log(`\n${c.bold}Taskplane Dashboard${c.reset}`);
933
+ console.log(` Project: ${projectRoot}`);
934
+ console.log(` Server: ${DASHBOARD_SERVER}\n`);
935
+
936
+ const child = spawn("node", [DASHBOARD_SERVER, ...serverArgs], {
937
+ stdio: "inherit",
938
+ cwd: projectRoot,
939
+ });
940
+
941
+ child.on("error", (err) => {
942
+ die(`Failed to start dashboard: ${err.message}`);
943
+ });
944
+
945
+ // Forward signals
946
+ process.on("SIGINT", () => child.kill("SIGINT"));
947
+ process.on("SIGTERM", () => child.kill("SIGTERM"));
948
+
949
+ child.on("exit", (code) => {
950
+ process.exit(code ?? 0);
951
+ });
952
+ }
953
+
954
+ // ─── help ───────────────────────────────────────────────────────────────────
955
+
956
+ function showHelp() {
957
+ const version = getPackageVersion();
958
+ console.log(`
959
+ ${c.bold}taskplane${c.reset} v${version} — AI agent orchestration for pi
960
+
961
+ ${c.bold}Usage:${c.reset}
962
+ taskplane <command> [options]
963
+
964
+ ${c.bold}Commands:${c.reset}
965
+ ${c.cyan}init${c.reset} Scaffold Taskplane config in the current project
966
+ ${c.cyan}doctor${c.reset} Validate installation and project configuration
967
+ ${c.cyan}version${c.reset} Show version information
968
+ ${c.cyan}dashboard${c.reset} Launch the web-based orchestrator dashboard
969
+ ${c.cyan}uninstall${c.reset} Remove Taskplane project files and/or package install
970
+ ${c.cyan}help${c.reset} Show this help message
971
+
972
+ ${c.bold}Init options:${c.reset}
973
+ --preset <name> Use a preset: minimal, full, runner-only
974
+ --tasks-root <path> Relative tasks directory to use (e.g. docs/task-management)
975
+ --no-examples Skip example tasks scaffolding
976
+ --include-examples With --tasks-root, include example tasks (default is skip)
977
+ --force Overwrite existing files without prompting
978
+ --dry-run Show what would be created without writing
979
+
980
+ ${c.bold}Dashboard options:${c.reset}
981
+ --port <number> Port to listen on (default: 8099)
982
+ --no-open Don't auto-open browser
983
+
984
+ ${c.bold}Uninstall options:${c.reset}
985
+ --dry-run Show what would be removed
986
+ --yes, -y Skip confirmation prompts
987
+ --package Also remove installed package via pi remove
988
+ --package-only Only remove installed package (skip project cleanup)
989
+ --local Force package uninstall from project-local scope
990
+ --global Force package uninstall from global scope
991
+ --remove-tasks Also remove task area directories from task-runner.yaml
992
+ --all Equivalent to --package + --remove-tasks
993
+
994
+ ${c.bold}Examples:${c.reset}
995
+ taskplane init # Interactive project setup
996
+ taskplane init --preset full # Quick setup with defaults
997
+ taskplane init --preset full --tasks-root docs/task-management
998
+ # Use existing task area path
999
+ taskplane init --dry-run # Preview what would be created
1000
+ taskplane doctor # Check installation health
1001
+ taskplane dashboard # Launch web dashboard
1002
+ taskplane dashboard --port 3000 # Dashboard on custom port
1003
+ taskplane uninstall --dry-run # Preview uninstall actions
1004
+ taskplane uninstall --package --yes # Remove project files + package install
1005
+
1006
+ ${c.bold}Getting started:${c.reset}
1007
+ 1. pi install npm:taskplane # Install the pi package
1008
+ 2. cd my-project && taskplane init # Scaffold project config
1009
+ 3. pi # Start pi — /task and /orch are ready
1010
+ `);
1011
+ }
1012
+
1013
+ // ═════════════════════════════════════════════════════════════════════════════
1014
+ // MAIN
1015
+ // ═════════════════════════════════════════════════════════════════════════════
1016
+
1017
+ const [command, ...args] = process.argv.slice(2);
1018
+
1019
+ switch (command) {
1020
+ case "init":
1021
+ await cmdInit(args);
1022
+ break;
1023
+ case "doctor":
1024
+ cmdDoctor();
1025
+ break;
1026
+ case "version":
1027
+ case "--version":
1028
+ case "-v":
1029
+ cmdVersion();
1030
+ break;
1031
+ case "dashboard":
1032
+ cmdDashboard(args);
1033
+ break;
1034
+ case "uninstall":
1035
+ await cmdUninstall(args);
1036
+ break;
1037
+ case "help":
1038
+ case "--help":
1039
+ case "-h":
1040
+ case undefined:
1041
+ showHelp();
1042
+ break;
1043
+ default:
1044
+ console.error(`${FAIL} Unknown command: ${command}`);
1045
+ showHelp();
1046
+ process.exit(1);
1047
+ }