@nijaru/tk 0.0.1

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/src/cli.ts ADDED
@@ -0,0 +1,871 @@
1
+ #!/usr/bin/env bun
2
+ import { parseArgs } from "util";
3
+ import { version } from "../package.json";
4
+
5
+ import * as storage from "./db/storage";
6
+ import { parsePriority } from "./lib/priority";
7
+ import { formatTaskList, formatTaskDetail, formatJson, formatConfig } from "./lib/format";
8
+ import { findRoot, setWorkingDir } from "./lib/root";
9
+ import { parseId } from "./types";
10
+ import type { Status } from "./types";
11
+ import { BASH_COMPLETION, ZSH_COMPLETION, FISH_COMPLETION } from "./lib/completions";
12
+
13
+ const VALID_STATUSES: Status[] = ["open", "active", "done"];
14
+ const PROJECT_PATTERN = /^[a-z][a-z0-9]*$/;
15
+
16
+ function validateProject(name: string): void {
17
+ if (!PROJECT_PATTERN.test(name)) {
18
+ throw new Error(
19
+ `Invalid project name: ${name}. Use lowercase letters and numbers, starting with a letter.`,
20
+ );
21
+ }
22
+ }
23
+
24
+ function parseStatus(input: string | undefined): Status | undefined {
25
+ if (!input) return undefined;
26
+ if (!VALID_STATUSES.includes(input as Status)) {
27
+ throw new Error(`Invalid status: ${input}. Use: ${VALID_STATUSES.join(", ")}`);
28
+ }
29
+ return input as Status;
30
+ }
31
+
32
+ function parseLimit(input: string | undefined): number | undefined {
33
+ if (!input) return undefined;
34
+ const n = parseInt(input, 10);
35
+ if (isNaN(n) || n < 1) {
36
+ throw new Error(`Invalid limit: ${input}. Must be a positive number.`);
37
+ }
38
+ return n;
39
+ }
40
+
41
+ function parseEstimate(input: string | undefined): number | undefined {
42
+ if (!input) return undefined;
43
+ const n = parseInt(input, 10);
44
+ if (isNaN(n) || n < 0) {
45
+ throw new Error(`Invalid estimate: ${input}. Must be a non-negative number.`);
46
+ }
47
+ return n;
48
+ }
49
+
50
+ function parseDueDate(input: string | undefined): string | undefined {
51
+ if (!input) return undefined;
52
+ if (input === "-") return undefined; // clear
53
+
54
+ // Handle relative dates like +7d
55
+ if (input.startsWith("+")) {
56
+ const match = input.match(/^\+(\d+)([dwmh])$/);
57
+ if (match && match[1] && match[2]) {
58
+ const num = match[1];
59
+ const unit = match[2];
60
+ const n = parseInt(num, 10);
61
+ const now = new Date();
62
+ switch (unit) {
63
+ case "h":
64
+ now.setHours(now.getHours() + n);
65
+ break;
66
+ case "d":
67
+ now.setDate(now.getDate() + n);
68
+ break;
69
+ case "w":
70
+ now.setDate(now.getDate() + n * 7);
71
+ break;
72
+ case "m":
73
+ now.setMonth(now.getMonth() + n);
74
+ break;
75
+ }
76
+ return now.toISOString().split("T")[0];
77
+ }
78
+ throw new Error(`Invalid relative date: ${input}. Use format like +7d, +2w, +1m`);
79
+ }
80
+
81
+ // Validate and normalize to YYYY-MM-DD
82
+ const date = new Date(input);
83
+ if (isNaN(date.getTime())) {
84
+ throw new Error(`Invalid date: ${input}. Use YYYY-MM-DD or +Nd format.`);
85
+ }
86
+ return date.toISOString().split("T")[0];
87
+ }
88
+
89
+ function parseLabels(input: string | undefined): string[] | undefined {
90
+ if (!input) return undefined;
91
+ return input
92
+ .split(",")
93
+ .map((l) => l.trim())
94
+ .filter(Boolean);
95
+ }
96
+
97
+ function parseAssignees(input: string | undefined): string[] | undefined {
98
+ if (!input) return undefined;
99
+ // Expand @me to git user
100
+ const assignees = input
101
+ .split(",")
102
+ .map((a) => a.trim())
103
+ .filter(Boolean);
104
+ return assignees.map((a) => {
105
+ if (a === "@me") {
106
+ try {
107
+ const result = Bun.spawnSync(["git", "config", "user.name"]);
108
+ if (result.success) {
109
+ return result.stdout.toString().trim() || a;
110
+ }
111
+ } catch {
112
+ // Fall through to return @me
113
+ }
114
+ }
115
+ return a;
116
+ });
117
+ }
118
+
119
+ function resolveId(input: string | undefined, context: string): string {
120
+ if (!input) {
121
+ throw new Error(`ID required: tk ${context} <id>`);
122
+ }
123
+
124
+ // Try to resolve ambiguous ID (just a number)
125
+ const resolved = storage.resolveId(input);
126
+ if (resolved) return resolved;
127
+
128
+ // Check if it's a valid full ID
129
+ if (parseId(input)) return input;
130
+
131
+ throw new Error(
132
+ `Invalid task ID: ${input}. Use format like tk-a1b2, or just the ref (a1b2) if unambiguous.`,
133
+ );
134
+ }
135
+
136
+ const rawArgs = process.argv.slice(2);
137
+
138
+ // Global flags that can appear anywhere in the command
139
+ const GLOBAL_FLAGS = new Set(["--json", "--help", "-h", "--version", "-V"]);
140
+
141
+ function isFlag(arg: string): boolean {
142
+ return arg.startsWith("-");
143
+ }
144
+
145
+ // Extract -C <dir> flag (can appear anywhere)
146
+ let dirFlag: string | null = null;
147
+ const argsWithoutDir: string[] = [];
148
+ for (let i = 0; i < rawArgs.length; i++) {
149
+ const arg = rawArgs[i];
150
+ if (arg === "-C" && rawArgs[i + 1]) {
151
+ dirFlag = rawArgs[i + 1]!;
152
+ i++; // skip next arg
153
+ } else {
154
+ argsWithoutDir.push(arg!);
155
+ }
156
+ }
157
+ if (dirFlag) {
158
+ setWorkingDir(dirFlag);
159
+ }
160
+
161
+ // Extract global flags from anywhere in args
162
+ const jsonFlag = argsWithoutDir.includes("--json");
163
+ const helpFlag = argsWithoutDir.includes("--help") || argsWithoutDir.includes("-h");
164
+ const versionFlag = argsWithoutDir.includes("--version") || argsWithoutDir.includes("-V");
165
+
166
+ // Find command: first non-flag argument
167
+ const command = argsWithoutDir.find((arg) => !isFlag(arg));
168
+
169
+ // Get args for command: everything except the command itself and global flags
170
+ const args = argsWithoutDir.filter((arg) => arg !== command && !GLOBAL_FLAGS.has(arg));
171
+
172
+ function output(data: unknown, formatted: string) {
173
+ console.log(jsonFlag ? formatJson(data) : formatted);
174
+ }
175
+
176
+ function error(message: string): never {
177
+ console.error(`Error: ${message}`);
178
+ process.exit(1);
179
+ }
180
+
181
+ function showHelp() {
182
+ console.log(`tk v${version} - Task tracker for AI agents
183
+
184
+ USAGE:
185
+ tk <command> [options]
186
+
187
+ COMMANDS:
188
+ init Initialize .tasks/ directory
189
+ add Create task
190
+ ls, list List tasks
191
+ ready List ready tasks (open + unblocked)
192
+ show Show task details
193
+ start Start working (open -> active)
194
+ done Complete task
195
+ reopen Reopen task
196
+ edit Edit task
197
+ log Add log entry
198
+ block Add blocker
199
+ unblock Remove blocker
200
+ rm, remove Delete task
201
+ clean Remove old done tasks
202
+ config Show/set configuration
203
+ completions Output shell completions
204
+
205
+ GLOBAL OPTIONS:
206
+ -C <dir> Run in different directory
207
+ --json Output as JSON
208
+ -h, --help Show help
209
+ -V Show version
210
+
211
+ Run 'tk <command> --help' for command-specific options.
212
+ `);
213
+ }
214
+
215
+ function showCommandHelp(cmd: string) {
216
+ const helps: Record<string, string> = {
217
+ init: `tk init - Initialize .tasks/ directory
218
+
219
+ USAGE:
220
+ tk init [options]
221
+
222
+ OPTIONS:
223
+ -P, --project <name> Set default project name
224
+ `,
225
+ add: `tk add - Create a new task
226
+
227
+ USAGE:
228
+ tk add <title> [options]
229
+
230
+ OPTIONS:
231
+ -p, --priority <0-4> Priority (0=none, 1=urgent, 2=high, 3=medium, 4=low)
232
+ -P, --project <name> Project prefix for ID
233
+ -d, --description <text> Description
234
+ -l, --labels <csv> Labels (comma-separated)
235
+ -A, --assignees <csv> Assignees (comma-separated, @me for git user)
236
+ --parent <id> Parent task ID
237
+ --estimate <n> Estimate (user-defined units)
238
+ --due <date> Due date (YYYY-MM-DD or +Nh/+Nd/+Nw/+Nm)
239
+
240
+ EXAMPLES:
241
+ tk add "Fix login bug" -p 1
242
+ tk add "New feature" -P api -l bug,urgent
243
+ tk add "Sprint task" --due +7d
244
+ `,
245
+ ls: `tk ls - List tasks
246
+
247
+ USAGE:
248
+ tk ls [options]
249
+
250
+ OPTIONS:
251
+ -s, --status <status> Filter by status (open, active, done)
252
+ -p, --priority <0-4> Filter by priority
253
+ -P, --project <name> Filter by project
254
+ -l, --label <label> Filter by label
255
+ --assignee <name> Filter by assignee
256
+ --parent <id> Filter by parent
257
+ --roots Show only root tasks (no parent)
258
+ --overdue Show only overdue tasks
259
+ -n, --limit <n> Limit results (default: 20)
260
+ -a, --all Show all (no limit)
261
+
262
+ EXAMPLES:
263
+ tk ls -s open -p 1 # Urgent open tasks
264
+ tk ls --overdue # Overdue tasks
265
+ tk ls -P api --roots # Root tasks in api project
266
+ `,
267
+ list: `tk list - List tasks (alias for 'ls')
268
+
269
+ Run 'tk ls --help' for options.
270
+ `,
271
+ ready: `tk ready - List ready tasks (open + unblocked)
272
+
273
+ USAGE:
274
+ tk ready
275
+
276
+ Shows open tasks that are not blocked by any incomplete task.
277
+ `,
278
+ show: `tk show - Show task details
279
+
280
+ USAGE:
281
+ tk show <id>
282
+
283
+ EXAMPLES:
284
+ tk show tk-1
285
+ tk show 1 # If unambiguous
286
+ `,
287
+ start: `tk start - Start working on a task
288
+
289
+ USAGE:
290
+ tk start <id>
291
+
292
+ Changes status from open to active.
293
+ `,
294
+ done: `tk done - Complete a task
295
+
296
+ USAGE:
297
+ tk done <id>
298
+
299
+ Changes status to done.
300
+ `,
301
+ reopen: `tk reopen - Reopen a task
302
+
303
+ USAGE:
304
+ tk reopen <id>
305
+
306
+ Changes status back to open.
307
+ `,
308
+ edit: `tk edit - Edit a task
309
+
310
+ USAGE:
311
+ tk edit <id> [options]
312
+
313
+ OPTIONS:
314
+ -t, --title <text> New title
315
+ -d, --description <text> New description
316
+ -p, --priority <0-4> New priority
317
+ -l, --labels <csv> Replace labels (use +tag/-tag to add/remove)
318
+ -A, --assignees <csv> Replace assignees
319
+ --parent <id> Set parent (use - to clear)
320
+ --estimate <n> Set estimate (use - to clear)
321
+ --due <date> Set due date (use - to clear)
322
+
323
+ EXAMPLES:
324
+ tk edit tk-1 -t "New title"
325
+ tk edit tk-1 -l +urgent # Add label
326
+ tk edit tk-1 --due - # Clear due date
327
+ `,
328
+ log: `tk log - Add a log entry to a task
329
+
330
+ USAGE:
331
+ tk log <id> <message>
332
+
333
+ EXAMPLES:
334
+ tk log tk-1 "Started implementation"
335
+ tk log tk-1 "Blocked on API changes"
336
+ `,
337
+ block: `tk block - Add a blocker dependency
338
+
339
+ USAGE:
340
+ tk block <task> <blocker>
341
+
342
+ The first task becomes blocked by the second.
343
+
344
+ EXAMPLES:
345
+ tk block tk-2 tk-1 # tk-2 is blocked by tk-1
346
+ `,
347
+ unblock: `tk unblock - Remove a blocker dependency
348
+
349
+ USAGE:
350
+ tk unblock <task> <blocker>
351
+
352
+ EXAMPLES:
353
+ tk unblock tk-2 tk-1
354
+ `,
355
+ rm: `tk rm - Delete a task
356
+
357
+ USAGE:
358
+ tk rm <id>
359
+
360
+ EXAMPLES:
361
+ tk rm tk-1
362
+ `,
363
+ remove: `tk remove - Delete a task (alias for 'rm')
364
+
365
+ USAGE:
366
+ tk remove <id>
367
+ `,
368
+ clean: `tk clean - Remove old completed tasks
369
+
370
+ USAGE:
371
+ tk clean [options]
372
+
373
+ OPTIONS:
374
+ --older-than <duration> Age threshold (default: 7d)
375
+ -a, --all Remove all done tasks (ignore age)
376
+
377
+ DURATION FORMAT:
378
+ 7d = 7 days, 2w = 2 weeks, 24h = 24 hours
379
+
380
+ EXAMPLES:
381
+ tk clean # Remove done tasks older than 7 days
382
+ tk clean --older-than 30d
383
+ tk clean -a # Remove all done tasks
384
+ `,
385
+ config: `tk config - Show or set configuration
386
+
387
+ USAGE:
388
+ tk config Show all config
389
+ tk config project Show default project
390
+ tk config project <name> Set default project
391
+ tk config project <new> --rename <old> Rename project (old-* → new-*)
392
+ tk config alias List aliases
393
+ tk config alias <name> <path> Add alias
394
+ tk config alias --rm <name> Remove alias
395
+
396
+ EXAMPLES:
397
+ tk config project api
398
+ tk config project lsmvec --rename cloudlsmvec
399
+ tk config alias web src/web
400
+ `,
401
+ completions: `tk completions - Output shell completions
402
+
403
+ USAGE:
404
+ tk completions <shell>
405
+
406
+ SHELLS:
407
+ bash Bash completion script
408
+ zsh Zsh completion script
409
+ fish Fish completion script
410
+
411
+ EXAMPLES:
412
+ eval "$(tk completions bash)" # Add to ~/.bashrc
413
+ eval "$(tk completions zsh)" # Add to ~/.zshrc
414
+ `,
415
+ };
416
+
417
+ const help = helps[cmd];
418
+ if (help) {
419
+ console.log(help);
420
+ } else {
421
+ console.error(`Unknown command: ${cmd}. Run 'tk --help' for usage.`);
422
+ process.exit(1);
423
+ }
424
+ }
425
+
426
+ function main() {
427
+ if (versionFlag) {
428
+ console.log(`tk v${version}`);
429
+ return;
430
+ }
431
+
432
+ if (!command) {
433
+ showHelp();
434
+ return;
435
+ }
436
+
437
+ // Handle 'tk help' and 'tk help <command>'
438
+ if (command === "help") {
439
+ const subcommand = args[0];
440
+ if (subcommand) {
441
+ showCommandHelp(subcommand);
442
+ } else {
443
+ showHelp();
444
+ }
445
+ return;
446
+ }
447
+
448
+ if (helpFlag) {
449
+ showCommandHelp(command);
450
+ return;
451
+ }
452
+
453
+ switch (command) {
454
+ case "init": {
455
+ const { values } = parseArgs({
456
+ args,
457
+ options: {
458
+ project: { type: "string", short: "P" },
459
+ },
460
+ allowPositionals: true,
461
+ });
462
+ if (values.project) {
463
+ validateProject(values.project);
464
+ }
465
+ const info = findRoot();
466
+ if (info.exists) {
467
+ output({ path: info.tasksDir, created: false }, `Already initialized: ${info.tasksDir}`);
468
+ } else {
469
+ const path = storage.initTasks(values.project);
470
+ output({ path, created: true }, `Initialized: ${path}`);
471
+ }
472
+ break;
473
+ }
474
+
475
+ case "add": {
476
+ const { values, positionals } = parseArgs({
477
+ args,
478
+ options: {
479
+ description: { type: "string", short: "d" },
480
+ priority: { type: "string", short: "p" },
481
+ project: { type: "string", short: "P" },
482
+ labels: { type: "string", short: "l" },
483
+ assignees: { type: "string", short: "A" },
484
+ parent: { type: "string" },
485
+ estimate: { type: "string" },
486
+ due: { type: "string" },
487
+ },
488
+ allowPositionals: true,
489
+ });
490
+ const title = positionals[0]?.trim();
491
+ if (!title) error("Title required: tk add <title>");
492
+
493
+ // Validate project if provided
494
+ if (values.project) {
495
+ validateProject(values.project);
496
+ }
497
+
498
+ // Resolve and validate parent if provided
499
+ let parentId: string | undefined;
500
+ if (values.parent) {
501
+ const resolved = storage.resolveId(values.parent);
502
+ if (!resolved) error(`Parent task not found: ${values.parent}`);
503
+ parentId = resolved;
504
+ const parentResult = storage.validateParent(parentId);
505
+ if (!parentResult.ok) error(parentResult.error!);
506
+ }
507
+
508
+ const task = storage.createTask({
509
+ title,
510
+ description: values.description,
511
+ priority: parsePriority(values.priority),
512
+ project: values.project,
513
+ labels: parseLabels(values.labels),
514
+ assignees: parseAssignees(values.assignees),
515
+ parent: parentId,
516
+ estimate: parseEstimate(values.estimate),
517
+ due_date: parseDueDate(values.due),
518
+ });
519
+ output(task, task.id);
520
+ break;
521
+ }
522
+
523
+ case "ls":
524
+ case "list": {
525
+ const { values } = parseArgs({
526
+ args,
527
+ options: {
528
+ status: { type: "string", short: "s" },
529
+ priority: { type: "string", short: "p" },
530
+ project: { type: "string", short: "P" },
531
+ label: { type: "string", short: "l" },
532
+ assignee: { type: "string" },
533
+ parent: { type: "string" },
534
+ roots: { type: "boolean" },
535
+ overdue: { type: "boolean" },
536
+ limit: { type: "string", short: "n" },
537
+ all: { type: "boolean", short: "a" },
538
+ },
539
+ allowPositionals: true,
540
+ });
541
+ const status = parseStatus(values.status);
542
+ const priority = values.priority ? parsePriority(values.priority) : undefined;
543
+ const limit = values.all ? undefined : (parseLimit(values.limit) ?? 20);
544
+
545
+ // Resolve parent filter if provided
546
+ let parentFilter: string | undefined;
547
+ if (values.parent) {
548
+ const resolved = storage.resolveId(values.parent);
549
+ if (!resolved) error(`Parent task not found: ${values.parent}`);
550
+ parentFilter = resolved;
551
+ }
552
+
553
+ const list = storage.listTasks({
554
+ status,
555
+ priority,
556
+ project: values.project,
557
+ label: values.label,
558
+ assignee: values.assignee,
559
+ parent: parentFilter,
560
+ roots: values.roots,
561
+ overdue: values.overdue,
562
+ limit,
563
+ });
564
+ output(list, formatTaskList(list));
565
+ break;
566
+ }
567
+
568
+ case "ready": {
569
+ const list = storage.listReadyTasks();
570
+ output(list, formatTaskList(list));
571
+ break;
572
+ }
573
+
574
+ case "show": {
575
+ const id = resolveId(args[0], "show");
576
+ const task = storage.getTaskWithMeta(id);
577
+ if (!task) error(`Task not found: ${id}`);
578
+ output(task, formatTaskDetail(task, task.logs));
579
+ break;
580
+ }
581
+
582
+ case "start": {
583
+ const id = resolveId(args[0], "start");
584
+ const task = storage.getTask(id);
585
+ if (!task) error(`Task not found: ${id}`);
586
+ if (task.status !== "open") error(`Task is ${task.status}, not open`);
587
+ const updated = storage.updateTaskStatus(id, "active");
588
+ output(updated, `Started: ${id}`);
589
+ break;
590
+ }
591
+
592
+ case "done": {
593
+ const id = resolveId(args[0], "done");
594
+ const task = storage.getTask(id);
595
+ if (!task) error(`Task not found: ${id}`);
596
+ const updated = storage.updateTaskStatus(id, "done");
597
+ output(updated, `Completed: ${id}`);
598
+ break;
599
+ }
600
+
601
+ case "reopen": {
602
+ const id = resolveId(args[0], "reopen");
603
+ const task = storage.getTask(id);
604
+ if (!task) error(`Task not found: ${id}`);
605
+ const updated = storage.updateTaskStatus(id, "open");
606
+ output(updated, `Reopened: ${id}`);
607
+ break;
608
+ }
609
+
610
+ case "edit": {
611
+ const { values, positionals } = parseArgs({
612
+ args,
613
+ options: {
614
+ title: { type: "string", short: "t" },
615
+ description: { type: "string", short: "d" },
616
+ priority: { type: "string", short: "p" },
617
+ labels: { type: "string", short: "l" },
618
+ assignees: { type: "string", short: "A" },
619
+ parent: { type: "string" },
620
+ estimate: { type: "string" },
621
+ due: { type: "string" },
622
+ },
623
+ allowPositionals: true,
624
+ });
625
+ const id = resolveId(positionals[0], "edit");
626
+ const task = storage.getTask(id);
627
+ if (!task) error(`Task not found: ${id}`);
628
+
629
+ // Handle label modifications (+tag, -tag)
630
+ let labels: string[] | undefined;
631
+ if (values.labels) {
632
+ if (values.labels.startsWith("+")) {
633
+ // Add label (avoid duplicates)
634
+ const newLabel = values.labels.slice(1);
635
+ labels = task.labels.includes(newLabel) ? task.labels : [...task.labels, newLabel];
636
+ } else if (values.labels.startsWith("-")) {
637
+ // Remove label
638
+ const removeLabel = values.labels.slice(1);
639
+ labels = task.labels.filter((l) => l !== removeLabel);
640
+ } else {
641
+ // Replace labels
642
+ labels = parseLabels(values.labels);
643
+ }
644
+ }
645
+
646
+ // Resolve and validate parent if provided (and not clearing)
647
+ let resolvedParent: string | null | undefined;
648
+ if (values.parent === "-") {
649
+ resolvedParent = null;
650
+ } else if (values.parent) {
651
+ const resolved = storage.resolveId(values.parent);
652
+ if (!resolved) error(`Parent task not found: ${values.parent}`);
653
+ resolvedParent = resolved;
654
+ const parentResult = storage.validateParent(resolvedParent, id);
655
+ if (!parentResult.ok) error(parentResult.error!);
656
+ }
657
+
658
+ const updated = storage.updateTask(id, {
659
+ title: values.title?.trim() || undefined,
660
+ description: values.description,
661
+ priority: values.priority ? parsePriority(values.priority) : undefined,
662
+ labels,
663
+ assignees: parseAssignees(values.assignees),
664
+ parent: resolvedParent,
665
+ estimate: values.estimate === "-" ? null : (parseEstimate(values.estimate) ?? undefined),
666
+ due_date: values.due === "-" ? null : (parseDueDate(values.due) ?? undefined),
667
+ });
668
+ output(updated, `Updated: ${id}`);
669
+ break;
670
+ }
671
+
672
+ case "log": {
673
+ const id = resolveId(args[0], "log");
674
+ const message = args.slice(1).join(" ").trim();
675
+ if (!message) error("Message required: tk log <id> <message>");
676
+ const task = storage.getTask(id);
677
+ if (!task) error(`Task not found: ${id}`);
678
+ const entry = storage.addLogEntry(id, message);
679
+ output(entry, `Logged: ${id}`);
680
+ break;
681
+ }
682
+
683
+ case "block": {
684
+ if (!args[0] || !args[1]) error("Usage: tk block <task> <blocker>");
685
+ const taskId = resolveId(args[0], "block");
686
+ const blockerId = resolveId(args[1], "block");
687
+ if (taskId === blockerId) error("Task cannot block itself");
688
+ const result = storage.addBlock(taskId, blockerId);
689
+ if (!result.ok) error(result.error!);
690
+ output({ task_id: taskId, blocked_by: blockerId }, `${taskId} blocked by ${blockerId}`);
691
+ break;
692
+ }
693
+
694
+ case "unblock": {
695
+ if (!args[0] || !args[1]) error("Usage: tk unblock <task> <blocker>");
696
+ const taskId = resolveId(args[0], "unblock");
697
+ const blockerId = resolveId(args[1], "unblock");
698
+ const success = storage.removeBlock(taskId, blockerId);
699
+ if (!success) error("Block not found");
700
+ output({ task_id: taskId, blocked_by: blockerId }, `${taskId} unblocked from ${blockerId}`);
701
+ break;
702
+ }
703
+
704
+ case "rm":
705
+ case "remove": {
706
+ const id = resolveId(args[0], "rm");
707
+ const success = storage.deleteTask(id);
708
+ if (!success) error(`Task not found: ${id}`);
709
+ output({ id, deleted: true }, `Deleted: ${id}`);
710
+ break;
711
+ }
712
+
713
+ case "clean": {
714
+ const { values } = parseArgs({
715
+ args,
716
+ options: {
717
+ "older-than": { type: "string", default: "7d" },
718
+ done: { type: "boolean" },
719
+ all: { type: "boolean", short: "a" },
720
+ },
721
+ allowPositionals: true,
722
+ });
723
+ const olderThan = values["older-than"] || "7d";
724
+ const ms = parseDuration(olderThan);
725
+ const status = values.done ? ("done" as Status) : undefined;
726
+ const count = storage.cleanTasks({
727
+ olderThanMs: ms,
728
+ status,
729
+ all: values.all,
730
+ });
731
+ output({ deleted: count }, `Cleaned ${count} tasks`);
732
+ break;
733
+ }
734
+
735
+ case "config": {
736
+ const subcommand = args[0];
737
+ const config = storage.getConfig();
738
+
739
+ if (!subcommand) {
740
+ // Show all config
741
+ output(config, formatConfig(config));
742
+ break;
743
+ }
744
+
745
+ switch (subcommand) {
746
+ case "project": {
747
+ const { values, positionals } = parseArgs({
748
+ args: args.slice(1),
749
+ options: {
750
+ rename: { type: "string" },
751
+ },
752
+ allowPositionals: true,
753
+ });
754
+ const newProject = positionals[0];
755
+ if (!newProject) {
756
+ output({ project: config.project }, config.project);
757
+ } else if (values.rename) {
758
+ validateProject(newProject);
759
+ const result = storage.renameProject(values.rename, newProject);
760
+ output(
761
+ result,
762
+ `Renamed ${result.renamed.length} tasks: ${values.rename}-* → ${newProject}-*` +
763
+ (result.referencesUpdated > 0
764
+ ? `\nUpdated ${result.referencesUpdated} references`
765
+ : ""),
766
+ );
767
+ } else {
768
+ validateProject(newProject);
769
+ const updated = storage.setDefaultProject(newProject);
770
+ output(updated, `Default project: ${newProject}`);
771
+ }
772
+ break;
773
+ }
774
+ case "alias": {
775
+ const { values, positionals } = parseArgs({
776
+ args: args.slice(1),
777
+ options: {
778
+ rm: { type: "string" },
779
+ list: { type: "boolean" },
780
+ },
781
+ allowPositionals: true,
782
+ });
783
+
784
+ if (values.rm) {
785
+ const updated = storage.removeAlias(values.rm);
786
+ output(updated, `Removed alias: ${values.rm}`);
787
+ } else if (positionals.length >= 2) {
788
+ const alias = positionals[0];
789
+ const path = positionals[1];
790
+ if (!alias || !path || !alias.trim()) {
791
+ error("Alias name and path are required");
792
+ }
793
+ const updated = storage.setAlias(alias, path);
794
+ output(updated, `Added alias: ${alias} → ${path}`);
795
+ } else {
796
+ // List aliases
797
+ const aliases = config.aliases;
798
+ if (Object.keys(aliases).length === 0) {
799
+ output({ aliases: {} }, "No aliases defined.");
800
+ } else {
801
+ const lines = Object.entries(aliases)
802
+ .map(([a, p]) => `${a} → ${p}`)
803
+ .join("\n");
804
+ output({ aliases }, lines);
805
+ }
806
+ }
807
+ break;
808
+ }
809
+ default:
810
+ error(`Unknown config command: ${subcommand}`);
811
+ }
812
+ break;
813
+ }
814
+
815
+ case "completions": {
816
+ const shell = args[0];
817
+ switch (shell) {
818
+ case "bash":
819
+ console.log(BASH_COMPLETION);
820
+ break;
821
+ case "zsh":
822
+ console.log(ZSH_COMPLETION);
823
+ break;
824
+ case "fish":
825
+ console.log(FISH_COMPLETION);
826
+ break;
827
+ default:
828
+ error("Shell required: tk completions <bash|zsh|fish>");
829
+ }
830
+ break;
831
+ }
832
+
833
+ default:
834
+ error(`Unknown command: ${command}. Run 'tk --help' for usage.`);
835
+ }
836
+ }
837
+
838
+ function parseDuration(s: string): number {
839
+ const match = s.match(/^(\d+)(s|m|h|d|w)$/);
840
+ if (!match || !match[1] || !match[2]) {
841
+ throw new Error(`Invalid duration: ${s}. Use format like 7d, 24h, 30m, 90s, or 2w`);
842
+ }
843
+ const num = match[1];
844
+ const unit = match[2];
845
+ const n = parseInt(num);
846
+ switch (unit) {
847
+ case "s":
848
+ return n * 1000;
849
+ case "m":
850
+ return n * 60 * 1000;
851
+ case "h":
852
+ return n * 60 * 60 * 1000;
853
+ case "d":
854
+ return n * 24 * 60 * 60 * 1000;
855
+ case "w":
856
+ return n * 7 * 24 * 60 * 60 * 1000;
857
+ default:
858
+ throw new Error(`Invalid duration unit: ${unit}`);
859
+ }
860
+ }
861
+
862
+ try {
863
+ main();
864
+ } catch (e) {
865
+ if (e instanceof Error) {
866
+ console.error(`Error: ${e.message}`);
867
+ } else {
868
+ console.error("An unexpected error occurred");
869
+ }
870
+ process.exit(1);
871
+ }