@nijaru/tk 0.0.5 → 0.1.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 DELETED
@@ -1,671 +0,0 @@
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 { parseDueDate, parseEstimate } from "./lib/time";
8
- import {
9
- formatTaskList,
10
- formatTaskDetail,
11
- formatJson,
12
- formatConfig,
13
- green,
14
- red,
15
- yellow,
16
- dim,
17
- } from "./lib/format";
18
- import { findRoot, setWorkingDir } from "./lib/root";
19
- import { parseId } from "./types";
20
- import type { Status, TaskWithMeta } from "./types";
21
- import { BASH_COMPLETION, ZSH_COMPLETION, FISH_COMPLETION } from "./lib/completions";
22
- import { MAIN_HELP, COMMAND_HELP } from "./lib/help";
23
-
24
- const VALID_STATUSES: Status[] = ["open", "active", "done"];
25
- const PROJECT_PATTERN = /^[a-z][a-z0-9]*$/;
26
-
27
- const COMMON_OPTIONS = {
28
- project: { type: "string", short: "P" },
29
- priority: { type: "string", short: "p" },
30
- labels: { type: "string", short: "l" },
31
- } as const;
32
-
33
- const TASK_MUTATION_OPTIONS = {
34
- ...COMMON_OPTIONS,
35
- description: { type: "string", short: "d" },
36
- assignees: { type: "string", short: "A" },
37
- parent: { type: "string" },
38
- estimate: { type: "string" },
39
- due: { type: "string" },
40
- } as const;
41
-
42
- function validateProject(name: string): void {
43
- if (!PROJECT_PATTERN.test(name)) {
44
- throw new Error(
45
- `Invalid project name: ${name}. Use lowercase letters and numbers, starting with a letter (e.g., 'api', 'web2').`,
46
- );
47
- }
48
- }
49
-
50
- function parseStatus(input: string | undefined): Status | undefined {
51
- if (!input) return undefined;
52
- if (!VALID_STATUSES.includes(input as Status)) {
53
- throw new Error(`Invalid status: ${input}. Use: ${VALID_STATUSES.join(", ")}`);
54
- }
55
- return input as Status;
56
- }
57
-
58
- function parseLimit(input: string | undefined): number | undefined {
59
- if (!input) return undefined;
60
- if (!/^\d+$/.test(input)) {
61
- throw new Error(`Invalid limit: ${input}. Must be a positive number.`);
62
- }
63
- const n = Number(input);
64
- if (n < 1) {
65
- throw new Error(`Invalid limit: ${input}. Must be a positive number.`);
66
- }
67
- return n;
68
- }
69
-
70
- function parseLabels(input: string | undefined): string[] | undefined {
71
- if (!input) return undefined;
72
- return input
73
- .split(",")
74
- .map((l) => l.trim())
75
- .filter(Boolean);
76
- }
77
-
78
- function parseAssignees(input: string | undefined): string[] | undefined {
79
- if (!input) return undefined;
80
- // Expand @me to git user
81
- const assignees = input
82
- .split(",")
83
- .map((a) => a.trim())
84
- .filter(Boolean);
85
- return assignees.map((a) => {
86
- if (a === "@me") {
87
- try {
88
- const result = Bun.spawnSync(["git", "config", "user.name"]);
89
- if (result.success) {
90
- return result.stdout.toString().trim() || a;
91
- }
92
- } catch {
93
- // Fall through to return @me
94
- }
95
- }
96
- return a;
97
- });
98
- }
99
-
100
- function resolveId(input: string | undefined, context: string): string {
101
- if (!input) {
102
- throw new Error(`ID required: tk ${context} <id>`);
103
- }
104
-
105
- // Try to resolve partial ID
106
- const resolved = storage.resolveId(input);
107
- if (resolved) return resolved;
108
-
109
- // Check for ambiguous matches
110
- const matches = storage.findMatchingIds(input);
111
- if (matches.length > 1) {
112
- throw new Error(
113
- `Ambiguous ID '${input}' matches ${matches.length} tasks: ${matches.join(", ")}. Use more characters to narrow it down.`,
114
- );
115
- }
116
-
117
- // If it's a valid full ID format but doesn't exist, we still return it
118
- // and let getTask handle the "not found" error consistently.
119
- if (parseId(input)) return input;
120
-
121
- throw new Error(`Task not found: ${input}. Run 'tk ls' to see available tasks.`);
122
- }
123
-
124
- /**
125
- * Resolves an ID and fetches the task.
126
- * Handles "Task not found" and cleanup output automatically.
127
- */
128
- function resolveTask(input: string | undefined, context: string): TaskWithMeta {
129
- const id = resolveId(input, context);
130
- const result = storage.getTask(id);
131
- if (!result) error(`Task not found: ${id}. Run 'tk ls' to see available tasks.`);
132
-
133
- outputCleanup(id, result.cleanup);
134
- return result.task;
135
- }
136
-
137
- const rawArgs = process.argv.slice(2);
138
-
139
- function isFlag(arg: string): boolean {
140
- return arg.startsWith("-");
141
- }
142
-
143
- // Extract -C <dir> flag (can appear anywhere)
144
- let dirFlag: string | null = null;
145
- const argsWithoutDir: string[] = [];
146
- for (let i = 0; i < rawArgs.length; i++) {
147
- const arg = rawArgs[i];
148
- if (arg === "-C" && rawArgs[i + 1]) {
149
- dirFlag = rawArgs[i + 1]!;
150
- i++; // skip next arg
151
- } else {
152
- argsWithoutDir.push(arg!);
153
- }
154
- }
155
- if (dirFlag) {
156
- setWorkingDir(dirFlag);
157
- }
158
-
159
- // Find command: first non-flag argument
160
- const commandIndex = argsWithoutDir.findIndex((arg) => !isFlag(arg));
161
- const command = commandIndex >= 0 ? argsWithoutDir[commandIndex] : undefined;
162
-
163
- // Get args for command (before stripping flags)
164
- const postCommandArgs = commandIndex >= 0 ? argsWithoutDir.slice(commandIndex + 1) : [];
165
- const preCommandArgs = commandIndex >= 0 ? argsWithoutDir.slice(0, commandIndex) : argsWithoutDir;
166
-
167
- // Global flag detection:
168
- // --json can appear anywhere and should be stripped from args
169
- // --help/-h detected before command OR as first arg after command (not in message content)
170
- // --version/-V only detected before command
171
- const jsonFlag = argsWithoutDir.includes("--json");
172
- const helpFlag =
173
- preCommandArgs.includes("--help") ||
174
- preCommandArgs.includes("-h") ||
175
- postCommandArgs[0] === "--help" ||
176
- postCommandArgs[0] === "-h";
177
- const versionFlag = preCommandArgs.includes("--version") || preCommandArgs.includes("-V");
178
-
179
- // Strip --json from args (works from any position)
180
- // Strip --help/-h only if it's the first arg (to allow "tk ls --help")
181
- const args = postCommandArgs
182
- .filter((arg) => arg !== "--json")
183
- .filter((arg, i) => i !== 0 || (arg !== "--help" && arg !== "-h"));
184
-
185
- function output(data: unknown, formatted: string) {
186
- console.log(jsonFlag ? formatJson(data) : formatted);
187
- }
188
-
189
- function outputCleanup(taskId: string, cleanup: storage.CleanupInfo | null) {
190
- if (cleanup && !jsonFlag) {
191
- console.error(storage.formatCleanupMessage(taskId, cleanup));
192
- }
193
- }
194
-
195
- function error(message: string): never {
196
- console.error(red(`Error: ${message}`));
197
- process.exit(1);
198
- }
199
-
200
- function showHelp(): void {
201
- console.log(MAIN_HELP);
202
- }
203
-
204
- function showCommandHelp(cmd: string): void {
205
- const help = COMMAND_HELP[cmd];
206
- if (help) {
207
- console.log(help);
208
- } else {
209
- console.error(`Unknown command: ${cmd}. Run 'tk --help' for usage.`);
210
- process.exit(1);
211
- }
212
- }
213
-
214
- function main() {
215
- if (versionFlag) {
216
- console.log(`tk v${version}`);
217
- return;
218
- }
219
-
220
- if (!command) {
221
- showHelp();
222
- return;
223
- }
224
-
225
- // Handle 'tk help' and 'tk help <command>'
226
- if (command === "help") {
227
- const subcommand = args[0];
228
- if (subcommand) {
229
- showCommandHelp(subcommand);
230
- } else {
231
- showHelp();
232
- }
233
- return;
234
- }
235
-
236
- if (helpFlag) {
237
- showCommandHelp(command);
238
- return;
239
- }
240
-
241
- switch (command) {
242
- case "init": {
243
- const { values } = parseArgs({
244
- args,
245
- options: {
246
- project: { type: "string", short: "P" },
247
- },
248
- allowPositionals: true,
249
- });
250
- if (values.project) {
251
- validateProject(values.project);
252
- }
253
- const info = findRoot();
254
- if (info.exists) {
255
- output(
256
- { path: info.tasksDir, created: false },
257
- dim(`Already initialized: ${info.tasksDir}`),
258
- );
259
- } else {
260
- const path = storage.initTasks(values.project);
261
- output({ path, created: true }, green(`Initialized: ${path}`));
262
- }
263
- break;
264
- }
265
-
266
- case "add": {
267
- const { values, positionals } = parseArgs({
268
- args,
269
- options: TASK_MUTATION_OPTIONS,
270
- allowPositionals: true,
271
- });
272
- const title = positionals[0]?.trim();
273
- if (!title) error("Title required: tk add <title>");
274
-
275
- // Validate project if provided
276
- if (values.project) {
277
- validateProject(values.project);
278
- }
279
-
280
- // Resolve and validate parent if provided
281
- let parentId: string | undefined;
282
- if (values.parent) {
283
- const resolved = storage.resolveId(values.parent);
284
- if (!resolved)
285
- error(`Parent task not found: ${values.parent}. Run 'tk ls' to see available tasks.`);
286
- parentId = resolved;
287
- const parentResult = storage.validateParent(parentId);
288
- if (!parentResult.ok) error(parentResult.error!);
289
- }
290
-
291
- const task = storage.createTask({
292
- title,
293
- description: values.description,
294
- priority: parsePriority(values.priority),
295
- project: values.project,
296
- labels: parseLabels(values.labels),
297
- assignees: parseAssignees(values.assignees),
298
- parent: parentId,
299
- estimate: parseEstimate(values.estimate),
300
- due_date: parseDueDate(values.due),
301
- });
302
- output(task, task.id);
303
- break;
304
- }
305
-
306
- case "ls":
307
- case "list": {
308
- const { values } = parseArgs({
309
- args,
310
- options: {
311
- project: { type: "string", short: "P" },
312
- priority: { type: "string", short: "p" },
313
- label: { type: "string", short: "l" },
314
- status: { type: "string", short: "s" },
315
- assignee: { type: "string" },
316
- parent: { type: "string" },
317
- roots: { type: "boolean" },
318
- overdue: { type: "boolean" },
319
- limit: { type: "string", short: "n" },
320
- all: { type: "boolean", short: "a" },
321
- },
322
- allowPositionals: true,
323
- });
324
- const status = parseStatus(values.status);
325
- const priority = values.priority ? parsePriority(values.priority) : undefined;
326
- const limit = values.all ? undefined : (parseLimit(values.limit) ?? 20);
327
-
328
- // Resolve parent filter if provided
329
- let parentFilter: string | undefined;
330
- if (values.parent) {
331
- const resolved = storage.resolveId(values.parent);
332
- if (!resolved)
333
- error(`Parent task not found: ${values.parent}. Run 'tk ls' to see available tasks.`);
334
- parentFilter = resolved;
335
- }
336
-
337
- const list = storage.listTasks({
338
- status,
339
- priority,
340
- project: values.project,
341
- label: values.label,
342
- assignee: values.assignee,
343
- parent: parentFilter,
344
- roots: values.roots,
345
- overdue: values.overdue,
346
- limit,
347
- });
348
- output(list, formatTaskList(list));
349
- break;
350
- }
351
-
352
- case "ready": {
353
- const list = storage.listReadyTasks();
354
- output(
355
- list,
356
- formatTaskList(list, undefined, "No ready tasks. All tasks are either done or blocked."),
357
- );
358
- break;
359
- }
360
-
361
- case "show": {
362
- const task = resolveTask(args[0], "show");
363
- output(task, formatTaskDetail(task, task.logs));
364
- break;
365
- }
366
-
367
- case "start": {
368
- const task = resolveTask(args[0], "start");
369
- if (task.status === "active")
370
- error(`Task already active. Use 'tk done ${task.id}' to complete it.`);
371
- if (task.status === "done") error(`Task already done. Use 'tk reopen ${task.id}' first.`);
372
- const updated = storage.updateTaskStatus(task.id, "active");
373
- output(updated, green(`Started: ${task.id}`));
374
- break;
375
- }
376
-
377
- case "done": {
378
- const task = resolveTask(args[0], "done");
379
- const updated = storage.updateTaskStatus(task.id, "done");
380
- output(updated, green(`Completed: ${task.id}`));
381
- break;
382
- }
383
-
384
- case "reopen": {
385
- const task = resolveTask(args[0], "reopen");
386
- const updated = storage.updateTaskStatus(task.id, "open");
387
- output(updated, green(`Reopened: ${task.id}`));
388
- break;
389
- }
390
-
391
- case "edit": {
392
- const { values, positionals } = parseArgs({
393
- args,
394
- options: {
395
- ...TASK_MUTATION_OPTIONS,
396
- title: { type: "string", short: "t" },
397
- },
398
- allowPositionals: true,
399
- });
400
- const task = resolveTask(positionals[0], "edit");
401
-
402
- // Handle label modifications (+tag, -tag)
403
- let labels: string[] | undefined;
404
- if (values.labels) {
405
- if (values.labels.startsWith("+")) {
406
- const newLabel = values.labels.slice(1).trim();
407
- if (!newLabel) throw new Error("Label name required after '+'");
408
- labels = task.labels.includes(newLabel) ? task.labels : [...task.labels, newLabel];
409
- } else if (values.labels.startsWith("-")) {
410
- const removeLabel = values.labels.slice(1).trim();
411
- if (!removeLabel) throw new Error("Label name required after '-'");
412
- labels = task.labels.filter((l: string) => l !== removeLabel);
413
- } else {
414
- // Replace labels
415
- labels = parseLabels(values.labels);
416
- }
417
- }
418
-
419
- // Resolve and validate parent if provided (and not clearing)
420
- let resolvedParent: string | null | undefined;
421
- if (values.parent === "-") {
422
- resolvedParent = null;
423
- } else if (values.parent) {
424
- const resolved = storage.resolveId(values.parent);
425
- if (!resolved)
426
- error(`Parent task not found: ${values.parent}. Run 'tk ls' to see available tasks.`);
427
- resolvedParent = resolved;
428
- const parentResult = storage.validateParent(resolvedParent, task.id);
429
- if (!parentResult.ok) error(parentResult.error!);
430
- }
431
-
432
- const updated = storage.updateTask(task.id, {
433
- title: values.title?.trim() || undefined,
434
- description: values.description === "-" ? null : values.description,
435
- priority: values.priority ? parsePriority(values.priority) : undefined,
436
- labels,
437
- assignees: parseAssignees(values.assignees),
438
- parent: resolvedParent,
439
- estimate: values.estimate === "-" ? null : (parseEstimate(values.estimate) ?? undefined),
440
- due_date: values.due === "-" ? null : (parseDueDate(values.due) ?? undefined),
441
- });
442
- output(updated, green(`Updated: ${task.id}`));
443
- break;
444
- }
445
-
446
- case "log": {
447
- const task = resolveTask(args[0], "log");
448
- const message = args[1]?.trim();
449
- if (!message) error('Message required: tk log <id> "<message>"');
450
- if (args.length > 2) {
451
- error(
452
- 'Message must be quoted: tk log <id> "<message>"\n' +
453
- ` Got ${args.length - 1} arguments instead of 1`,
454
- );
455
- }
456
- const entry = storage.addLogEntry(task.id, message);
457
- output(entry, green(`Logged: ${task.id}`));
458
- break;
459
- }
460
-
461
- case "block": {
462
- if (!args[0] || !args[1])
463
- error(
464
- "Two IDs required: tk block <task> <blocker>. The first task becomes blocked by the second.",
465
- );
466
- const taskId = resolveId(args[0], "block");
467
- const blockerId = resolveId(args[1], "block");
468
- if (taskId === blockerId) error("Task cannot block itself.");
469
- const result = storage.addBlock(taskId, blockerId);
470
- if (!result.ok) error(result.error!);
471
- output(
472
- { task_id: taskId, blocked_by: blockerId },
473
- green(`${taskId} blocked by ${blockerId}`),
474
- );
475
- break;
476
- }
477
-
478
- case "unblock": {
479
- if (!args[0] || !args[1]) error("Two IDs required: tk unblock <task> <blocker>.");
480
- const taskId = resolveId(args[0], "unblock");
481
- const blockerId = resolveId(args[1], "unblock");
482
- const removed = storage.removeBlock(taskId, blockerId);
483
- if (!removed) error(`${taskId} is not blocked by ${blockerId}`);
484
- output(
485
- { task_id: taskId, blocked_by: blockerId },
486
- green(`${taskId} unblocked from ${blockerId}`),
487
- );
488
- break;
489
- }
490
-
491
- case "rm":
492
- case "remove": {
493
- const id = resolveId(args[0], "rm");
494
- const deleted = storage.deleteTask(id);
495
- if (!deleted) error(`Task not found: ${id}. Run 'tk ls' to see available tasks.`);
496
- output({ id, deleted: true }, green(`Deleted: ${id}`));
497
- break;
498
- }
499
-
500
- case "mv":
501
- case "move": {
502
- const id = resolveId(args[0], "mv");
503
- const newProject = args[1];
504
- if (!newProject) error("Project required: tk mv <id> <project>");
505
- validateProject(newProject);
506
- const result = storage.moveTask(id, newProject);
507
- const refMsg =
508
- result.referencesUpdated > 0 ? `\nUpdated ${result.referencesUpdated} references` : "";
509
- output(result, green(`Moved: ${result.old_id} → ${result.new_id}${refMsg}`));
510
- break;
511
- }
512
-
513
- case "clean": {
514
- const config = storage.getConfig();
515
- const { values } = parseArgs({
516
- args,
517
- options: {
518
- "older-than": { type: "string" },
519
- force: { type: "boolean", short: "f" },
520
- },
521
- allowPositionals: true,
522
- });
523
-
524
- // Get days from CLI or config
525
- let days: number | false;
526
- if (values["older-than"] !== undefined) {
527
- if (!/^\d+$/.test(values["older-than"])) {
528
- error(`Invalid --older-than: ${values["older-than"]}. Use a number of days.`);
529
- }
530
- days = Number(values["older-than"]);
531
- } else {
532
- days = config.clean_after;
533
- // Validate config value at runtime
534
- if (days !== false && (typeof days !== "number" || days < 0 || !Number.isFinite(days))) {
535
- error(
536
- `Invalid clean_after in config: ${JSON.stringify(days)}. Must be a number or false.`,
537
- );
538
- }
539
- }
540
-
541
- if (days === false && !values.force) {
542
- error("Cleaning is disabled (clean_after: false). Use --force to override.");
543
- }
544
-
545
- const ms = days === false ? 0 : days * 24 * 60 * 60 * 1000;
546
- const count = storage.cleanTasks({
547
- olderThanMs: ms,
548
- force: values.force,
549
- });
550
- output({ deleted: count }, green(`Cleaned ${count} tasks`));
551
- break;
552
- }
553
-
554
- case "check": {
555
- const checkResult = storage.checkTasks();
556
-
557
- if (jsonFlag) {
558
- output(checkResult, "");
559
- } else {
560
- // Report cleaned issues
561
- if (checkResult.cleaned.length > 0) {
562
- for (const { task, info } of checkResult.cleaned) {
563
- console.log(storage.formatCleanupMessage(task, info));
564
- }
565
- }
566
-
567
- // Report unfixable issues
568
- if (checkResult.unfixable.length > 0) {
569
- console.log(red("\nUnfixable issues (require manual intervention):"));
570
- for (const { file, error: err } of checkResult.unfixable) {
571
- console.log(red(` ${file}: ${err}`));
572
- }
573
- }
574
-
575
- // Summary
576
- if (checkResult.cleaned.length === 0 && checkResult.unfixable.length === 0) {
577
- console.log(green(`All ${checkResult.totalTasks} tasks OK`));
578
- } else {
579
- const parts: string[] = [];
580
- if (checkResult.cleaned.length > 0) {
581
- parts.push(yellow(`${checkResult.cleaned.length} fixed`));
582
- }
583
- if (checkResult.unfixable.length > 0) {
584
- parts.push(red(`${checkResult.unfixable.length} unfixable`));
585
- }
586
- console.log(`\nChecked ${checkResult.totalTasks} tasks: ${parts.join(", ")}`);
587
- }
588
- }
589
- break;
590
- }
591
-
592
- case "config": {
593
- const subcommand = args[0];
594
- const config = storage.getConfig();
595
-
596
- if (!subcommand) {
597
- // Show all config
598
- output(config, formatConfig(config));
599
- break;
600
- }
601
-
602
- switch (subcommand) {
603
- case "project": {
604
- const { values, positionals } = parseArgs({
605
- args: args.slice(1),
606
- options: {
607
- rename: { type: "string" },
608
- },
609
- allowPositionals: true,
610
- });
611
- const newProject = positionals[0];
612
- if (!newProject) {
613
- output({ project: config.project }, config.project);
614
- } else if (values.rename) {
615
- validateProject(newProject);
616
- const result = storage.renameProject(values.rename, newProject);
617
- output(
618
- result,
619
- green(
620
- `Renamed ${result.renamed.length} tasks: ${values.rename}-* → ${newProject}-*` +
621
- (result.referencesUpdated > 0
622
- ? `\nUpdated ${result.referencesUpdated} references`
623
- : ""),
624
- ),
625
- );
626
- } else {
627
- validateProject(newProject);
628
- const updated = storage.setDefaultProject(newProject);
629
- output(updated, green(`Default project: ${newProject}`));
630
- }
631
- break;
632
- }
633
- default:
634
- error(`Unknown config command: ${subcommand}. Valid: project.`);
635
- }
636
- break;
637
- }
638
-
639
- case "completions": {
640
- const shell = args[0];
641
- switch (shell) {
642
- case "bash":
643
- console.log(BASH_COMPLETION);
644
- break;
645
- case "zsh":
646
- console.log(ZSH_COMPLETION);
647
- break;
648
- case "fish":
649
- console.log(FISH_COMPLETION);
650
- break;
651
- default:
652
- error("Shell required: tk completions <bash|zsh|fish>. Add to your shell's rc file.");
653
- }
654
- break;
655
- }
656
-
657
- default:
658
- error(`Unknown command: ${command}. Run 'tk --help' for usage.`);
659
- }
660
- }
661
-
662
- try {
663
- main();
664
- } catch (e) {
665
- if (e instanceof Error) {
666
- console.error(red(`Error: ${e.message}`));
667
- } else {
668
- console.error(red("An unexpected error occurred"));
669
- }
670
- process.exit(1);
671
- }