@nijaru/tk 0.0.1 → 0.0.2

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 CHANGED
@@ -4,7 +4,15 @@ import { version } from "../package.json";
4
4
 
5
5
  import * as storage from "./db/storage";
6
6
  import { parsePriority } from "./lib/priority";
7
- import { formatTaskList, formatTaskDetail, formatJson, formatConfig } from "./lib/format";
7
+ import {
8
+ formatTaskList,
9
+ formatTaskDetail,
10
+ formatJson,
11
+ formatConfig,
12
+ green,
13
+ red,
14
+ yellow,
15
+ } from "./lib/format";
8
16
  import { findRoot, setWorkingDir } from "./lib/root";
9
17
  import { parseId } from "./types";
10
18
  import type { Status } from "./types";
@@ -47,6 +55,13 @@ function parseEstimate(input: string | undefined): number | undefined {
47
55
  return n;
48
56
  }
49
57
 
58
+ function formatLocalDate(date: Date): string {
59
+ const year = date.getFullYear();
60
+ const month = String(date.getMonth() + 1).padStart(2, "0");
61
+ const day = String(date.getDate()).padStart(2, "0");
62
+ return `${year}-${month}-${day}`;
63
+ }
64
+
50
65
  function parseDueDate(input: string | undefined): string | undefined {
51
66
  if (!input) return undefined;
52
67
  if (input === "-") return undefined; // clear
@@ -73,17 +88,23 @@ function parseDueDate(input: string | undefined): string | undefined {
73
88
  now.setMonth(now.getMonth() + n);
74
89
  break;
75
90
  }
76
- return now.toISOString().split("T")[0];
91
+ return formatLocalDate(now);
77
92
  }
78
93
  throw new Error(`Invalid relative date: ${input}. Use format like +7d, +2w, +1m`);
79
94
  }
80
95
 
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.`);
96
+ // Validate YYYY-MM-DD format - return as-is to avoid timezone issues
97
+ const dateMatch = input.match(/^(\d{4})-(\d{2})-(\d{2})$/);
98
+ if (dateMatch) {
99
+ const [, , month, day] = dateMatch;
100
+ const m = parseInt(month!, 10);
101
+ const d = parseInt(day!, 10);
102
+ // Basic validation: month 1-12, day 1-31
103
+ if (m >= 1 && m <= 12 && d >= 1 && d <= 31) {
104
+ return input; // Return as-is, already in correct format
105
+ }
85
106
  }
86
- return date.toISOString().split("T")[0];
107
+ throw new Error(`Invalid date: ${input}. Use YYYY-MM-DD or +Nd format.`);
87
108
  }
88
109
 
89
110
  function parseLabels(input: string | undefined): string[] | undefined {
@@ -121,23 +142,26 @@ function resolveId(input: string | undefined, context: string): string {
121
142
  throw new Error(`ID required: tk ${context} <id>`);
122
143
  }
123
144
 
124
- // Try to resolve ambiguous ID (just a number)
145
+ // Try to resolve partial ID
125
146
  const resolved = storage.resolveId(input);
126
147
  if (resolved) return resolved;
127
148
 
128
- // Check if it's a valid full ID
149
+ // Check if it's a valid full ID format
129
150
  if (parseId(input)) return input;
130
151
 
131
- throw new Error(
132
- `Invalid task ID: ${input}. Use format like tk-a1b2, or just the ref (a1b2) if unambiguous.`,
133
- );
152
+ // Check for ambiguous matches
153
+ const matches = storage.findMatchingIds(input);
154
+ if (matches.length > 1) {
155
+ throw new Error(
156
+ `Ambiguous ID '${input}' matches ${matches.length} tasks: ${matches.join(", ")}`,
157
+ );
158
+ }
159
+
160
+ throw new Error(`Task not found: ${input}`);
134
161
  }
135
162
 
136
163
  const rawArgs = process.argv.slice(2);
137
164
 
138
- // Global flags that can appear anywhere in the command
139
- const GLOBAL_FLAGS = new Set(["--json", "--help", "-h", "--version", "-V"]);
140
-
141
165
  function isFlag(arg: string): boolean {
142
166
  return arg.startsWith("-");
143
167
  }
@@ -158,23 +182,44 @@ if (dirFlag) {
158
182
  setWorkingDir(dirFlag);
159
183
  }
160
184
 
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
185
  // Find command: first non-flag argument
167
- const command = argsWithoutDir.find((arg) => !isFlag(arg));
186
+ const commandIndex = argsWithoutDir.findIndex((arg) => !isFlag(arg));
187
+ const command = commandIndex >= 0 ? argsWithoutDir[commandIndex] : undefined;
168
188
 
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));
189
+ // Get args for command (before stripping flags)
190
+ const postCommandArgs = commandIndex >= 0 ? argsWithoutDir.slice(commandIndex + 1) : [];
191
+ const preCommandArgs = commandIndex >= 0 ? argsWithoutDir.slice(0, commandIndex) : argsWithoutDir;
192
+
193
+ // Global flag detection:
194
+ // --json can appear anywhere and should be stripped from args
195
+ // --help/-h detected before command OR as first arg after command (not in message content)
196
+ // --version/-V only detected before command
197
+ const jsonFlag = argsWithoutDir.includes("--json");
198
+ const helpFlag =
199
+ preCommandArgs.includes("--help") ||
200
+ preCommandArgs.includes("-h") ||
201
+ postCommandArgs[0] === "--help" ||
202
+ postCommandArgs[0] === "-h";
203
+ const versionFlag = preCommandArgs.includes("--version") || preCommandArgs.includes("-V");
204
+
205
+ // Strip --json from args (works from any position)
206
+ // Strip --help/-h only if it's the first arg (to allow "tk ls --help")
207
+ const args = postCommandArgs
208
+ .filter((arg) => arg !== "--json")
209
+ .filter((arg, i) => i !== 0 || (arg !== "--help" && arg !== "-h"));
171
210
 
172
211
  function output(data: unknown, formatted: string) {
173
212
  console.log(jsonFlag ? formatJson(data) : formatted);
174
213
  }
175
214
 
215
+ function outputCleanup(taskId: string, cleanup: storage.CleanupInfo | null) {
216
+ if (cleanup && !jsonFlag) {
217
+ console.error(storage.formatCleanupMessage(taskId, cleanup));
218
+ }
219
+ }
220
+
176
221
  function error(message: string): never {
177
- console.error(`Error: ${message}`);
222
+ console.error(red(`Error: ${message}`));
178
223
  process.exit(1);
179
224
  }
180
225
 
@@ -199,6 +244,7 @@ COMMANDS:
199
244
  unblock Remove blocker
200
245
  rm, remove Delete task
201
246
  clean Remove old done tasks
247
+ check Check for data issues
202
248
  config Show/set configuration
203
249
  completions Output shell completions
204
250
 
@@ -328,7 +374,9 @@ EXAMPLES:
328
374
  log: `tk log - Add a log entry to a task
329
375
 
330
376
  USAGE:
331
- tk log <id> <message>
377
+ tk log <id> "<message>"
378
+
379
+ Message must be quoted.
332
380
 
333
381
  EXAMPLES:
334
382
  tk log tk-1 "Started implementation"
@@ -371,16 +419,28 @@ USAGE:
371
419
  tk clean [options]
372
420
 
373
421
  OPTIONS:
374
- --older-than <duration> Age threshold (default: 7d)
375
- -a, --all Remove all done tasks (ignore age)
422
+ --older-than <days> Age threshold in days (default: from config, 14)
423
+ -f, --force Remove all done tasks (ignores age and disabled state)
424
+
425
+ EXAMPLES:
426
+ tk clean # Remove done tasks older than config.clean_after days
427
+ tk clean --older-than 30 # Remove done tasks older than 30 days
428
+ tk clean --force # Remove all done tasks regardless of age
429
+ `,
430
+ check: `tk check - Check for data issues
431
+
432
+ USAGE:
433
+ tk check
376
434
 
377
- DURATION FORMAT:
378
- 7d = 7 days, 2w = 2 weeks, 24h = 24 hours
435
+ Scans all tasks for issues. Auto-fixable issues (orphaned references, ID
436
+ mismatches) are fixed automatically. Unfixable issues (corrupted JSON) are
437
+ reported for manual intervention.
438
+
439
+ Note: Auto-fixing also happens during normal task operations (show, done,
440
+ edit, etc.) - this command is for bulk cleanup or diagnostics.
379
441
 
380
442
  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
443
+ tk check # Scan and fix all tasks
384
444
  `,
385
445
  config: `tk config - Show or set configuration
386
446
 
@@ -464,10 +524,13 @@ function main() {
464
524
  }
465
525
  const info = findRoot();
466
526
  if (info.exists) {
467
- output({ path: info.tasksDir, created: false }, `Already initialized: ${info.tasksDir}`);
527
+ output(
528
+ { path: info.tasksDir, created: false },
529
+ green(`Already initialized: ${info.tasksDir}`),
530
+ );
468
531
  } else {
469
532
  const path = storage.initTasks(values.project);
470
- output({ path, created: true }, `Initialized: ${path}`);
533
+ output({ path, created: true }, green(`Initialized: ${path}`));
471
534
  }
472
535
  break;
473
536
  }
@@ -573,37 +636,43 @@ function main() {
573
636
 
574
637
  case "show": {
575
638
  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));
639
+ const result = storage.getTaskWithMeta(id);
640
+ if (!result) error(`Task not found: ${id}`);
641
+ outputCleanup(id, result.cleanup);
642
+ output(result.task, formatTaskDetail(result.task, result.task.logs));
579
643
  break;
580
644
  }
581
645
 
582
646
  case "start": {
583
647
  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`);
648
+ const result = storage.getTask(id);
649
+ if (!result) error(`Task not found: ${id}`);
650
+ outputCleanup(id, result.cleanup);
651
+ if (result.task.status === "active")
652
+ error(`Task already active. Use 'tk done ${id}' to complete it.`);
653
+ if (result.task.status === "done") error(`Task already done. Use 'tk reopen ${id}' first.`);
587
654
  const updated = storage.updateTaskStatus(id, "active");
588
- output(updated, `Started: ${id}`);
655
+ output(updated, green(`Started: ${id}`));
589
656
  break;
590
657
  }
591
658
 
592
659
  case "done": {
593
660
  const id = resolveId(args[0], "done");
594
- const task = storage.getTask(id);
595
- if (!task) error(`Task not found: ${id}`);
661
+ const result = storage.getTask(id);
662
+ if (!result) error(`Task not found: ${id}`);
663
+ outputCleanup(id, result.cleanup);
596
664
  const updated = storage.updateTaskStatus(id, "done");
597
- output(updated, `Completed: ${id}`);
665
+ output(updated, green(`Completed: ${id}`));
598
666
  break;
599
667
  }
600
668
 
601
669
  case "reopen": {
602
670
  const id = resolveId(args[0], "reopen");
603
- const task = storage.getTask(id);
604
- if (!task) error(`Task not found: ${id}`);
671
+ const result = storage.getTask(id);
672
+ if (!result) error(`Task not found: ${id}`);
673
+ outputCleanup(id, result.cleanup);
605
674
  const updated = storage.updateTaskStatus(id, "open");
606
- output(updated, `Reopened: ${id}`);
675
+ output(updated, green(`Reopened: ${id}`));
607
676
  break;
608
677
  }
609
678
 
@@ -623,8 +692,9 @@ function main() {
623
692
  allowPositionals: true,
624
693
  });
625
694
  const id = resolveId(positionals[0], "edit");
626
- const task = storage.getTask(id);
627
- if (!task) error(`Task not found: ${id}`);
695
+ const result = storage.getTask(id);
696
+ if (!result) error(`Task not found: ${id}`);
697
+ outputCleanup(id, result.cleanup);
628
698
 
629
699
  // Handle label modifications (+tag, -tag)
630
700
  let labels: string[] | undefined;
@@ -632,11 +702,13 @@ function main() {
632
702
  if (values.labels.startsWith("+")) {
633
703
  // Add label (avoid duplicates)
634
704
  const newLabel = values.labels.slice(1);
635
- labels = task.labels.includes(newLabel) ? task.labels : [...task.labels, newLabel];
705
+ labels = result.task.labels.includes(newLabel)
706
+ ? result.task.labels
707
+ : [...result.task.labels, newLabel];
636
708
  } else if (values.labels.startsWith("-")) {
637
709
  // Remove label
638
710
  const removeLabel = values.labels.slice(1);
639
- labels = task.labels.filter((l) => l !== removeLabel);
711
+ labels = result.task.labels.filter((l) => l !== removeLabel);
640
712
  } else {
641
713
  // Replace labels
642
714
  labels = parseLabels(values.labels);
@@ -665,18 +737,25 @@ function main() {
665
737
  estimate: values.estimate === "-" ? null : (parseEstimate(values.estimate) ?? undefined),
666
738
  due_date: values.due === "-" ? null : (parseDueDate(values.due) ?? undefined),
667
739
  });
668
- output(updated, `Updated: ${id}`);
740
+ output(updated, green(`Updated: ${id}`));
669
741
  break;
670
742
  }
671
743
 
672
744
  case "log": {
673
745
  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}`);
746
+ const message = args[1]?.trim();
747
+ if (!message) error('Message required: tk log <id> "<message>"');
748
+ if (args.length > 2) {
749
+ error(
750
+ 'Message must be quoted: tk log <id> "<message>"\n' +
751
+ ` Got ${args.length - 1} arguments instead of 1`,
752
+ );
753
+ }
754
+ const result = storage.getTask(id);
755
+ if (!result) error(`Task not found: ${id}`);
756
+ outputCleanup(id, result.cleanup);
678
757
  const entry = storage.addLogEntry(id, message);
679
- output(entry, `Logged: ${id}`);
758
+ output(entry, green(`Logged: ${id}`));
680
759
  break;
681
760
  }
682
761
 
@@ -687,7 +766,10 @@ function main() {
687
766
  if (taskId === blockerId) error("Task cannot block itself");
688
767
  const result = storage.addBlock(taskId, blockerId);
689
768
  if (!result.ok) error(result.error!);
690
- output({ task_id: taskId, blocked_by: blockerId }, `${taskId} blocked by ${blockerId}`);
769
+ output(
770
+ { task_id: taskId, blocked_by: blockerId },
771
+ green(`${taskId} blocked by ${blockerId}`),
772
+ );
691
773
  break;
692
774
  }
693
775
 
@@ -695,40 +777,101 @@ function main() {
695
777
  if (!args[0] || !args[1]) error("Usage: tk unblock <task> <blocker>");
696
778
  const taskId = resolveId(args[0], "unblock");
697
779
  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}`);
780
+ const removed = storage.removeBlock(taskId, blockerId);
781
+ if (!removed) error(`${taskId} is not blocked by ${blockerId}`);
782
+ output(
783
+ { task_id: taskId, blocked_by: blockerId },
784
+ green(`${taskId} unblocked from ${blockerId}`),
785
+ );
701
786
  break;
702
787
  }
703
788
 
704
789
  case "rm":
705
790
  case "remove": {
706
791
  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}`);
792
+ const deleted = storage.deleteTask(id);
793
+ if (!deleted) error(`Task not found: ${id}`);
794
+ output({ id, deleted: true }, green(`Deleted: ${id}`));
710
795
  break;
711
796
  }
712
797
 
713
798
  case "clean": {
799
+ const config = storage.getConfig();
714
800
  const { values } = parseArgs({
715
801
  args,
716
802
  options: {
717
- "older-than": { type: "string", default: "7d" },
718
- done: { type: "boolean" },
719
- all: { type: "boolean", short: "a" },
803
+ "older-than": { type: "string" },
804
+ force: { type: "boolean", short: "f" },
720
805
  },
721
806
  allowPositionals: true,
722
807
  });
723
- const olderThan = values["older-than"] || "7d";
724
- const ms = parseDuration(olderThan);
725
- const status = values.done ? ("done" as Status) : undefined;
808
+
809
+ // Get days from CLI or config
810
+ let days: number | false;
811
+ if (values["older-than"] !== undefined) {
812
+ const n = parseInt(values["older-than"], 10);
813
+ if (isNaN(n) || n < 0) {
814
+ error(`Invalid --older-than: ${values["older-than"]}. Use a number of days.`);
815
+ }
816
+ days = n;
817
+ } else {
818
+ days = config.clean_after;
819
+ // Validate config value at runtime
820
+ if (days !== false && (typeof days !== "number" || days < 0 || !Number.isFinite(days))) {
821
+ error(
822
+ `Invalid clean_after in config: ${JSON.stringify(days)}. Must be a number or false.`,
823
+ );
824
+ }
825
+ }
826
+
827
+ if (days === false && !values.force) {
828
+ error("Cleaning is disabled (clean_after: false). Use --force to override.");
829
+ }
830
+
831
+ const ms = days === false ? 0 : days * 24 * 60 * 60 * 1000;
726
832
  const count = storage.cleanTasks({
727
833
  olderThanMs: ms,
728
- status,
729
- all: values.all,
834
+ force: values.force,
730
835
  });
731
- output({ deleted: count }, `Cleaned ${count} tasks`);
836
+ output({ deleted: count }, green(`Cleaned ${count} tasks`));
837
+ break;
838
+ }
839
+
840
+ case "check": {
841
+ const checkResult = storage.checkTasks();
842
+
843
+ if (jsonFlag) {
844
+ output(checkResult, "");
845
+ } else {
846
+ // Report cleaned issues
847
+ if (checkResult.cleaned.length > 0) {
848
+ for (const { task, info } of checkResult.cleaned) {
849
+ console.log(storage.formatCleanupMessage(task, info));
850
+ }
851
+ }
852
+
853
+ // Report unfixable issues
854
+ if (checkResult.unfixable.length > 0) {
855
+ console.log(red("\nUnfixable issues (require manual intervention):"));
856
+ for (const { file, error: err } of checkResult.unfixable) {
857
+ console.log(red(` ${file}: ${err}`));
858
+ }
859
+ }
860
+
861
+ // Summary
862
+ if (checkResult.cleaned.length === 0 && checkResult.unfixable.length === 0) {
863
+ console.log(green(`All ${checkResult.totalTasks} tasks OK`));
864
+ } else {
865
+ const parts: string[] = [];
866
+ if (checkResult.cleaned.length > 0) {
867
+ parts.push(yellow(`${checkResult.cleaned.length} fixed`));
868
+ }
869
+ if (checkResult.unfixable.length > 0) {
870
+ parts.push(red(`${checkResult.unfixable.length} unfixable`));
871
+ }
872
+ console.log(`\nChecked ${checkResult.totalTasks} tasks: ${parts.join(", ")}`);
873
+ }
874
+ }
732
875
  break;
733
876
  }
734
877
 
@@ -759,15 +902,17 @@ function main() {
759
902
  const result = storage.renameProject(values.rename, newProject);
760
903
  output(
761
904
  result,
762
- `Renamed ${result.renamed.length} tasks: ${values.rename}-* → ${newProject}-*` +
763
- (result.referencesUpdated > 0
764
- ? `\nUpdated ${result.referencesUpdated} references`
765
- : ""),
905
+ green(
906
+ `Renamed ${result.renamed.length} tasks: ${values.rename}-* → ${newProject}-*` +
907
+ (result.referencesUpdated > 0
908
+ ? `\nUpdated ${result.referencesUpdated} references`
909
+ : ""),
910
+ ),
766
911
  );
767
912
  } else {
768
913
  validateProject(newProject);
769
914
  const updated = storage.setDefaultProject(newProject);
770
- output(updated, `Default project: ${newProject}`);
915
+ output(updated, green(`Default project: ${newProject}`));
771
916
  }
772
917
  break;
773
918
  }
@@ -783,7 +928,7 @@ function main() {
783
928
 
784
929
  if (values.rm) {
785
930
  const updated = storage.removeAlias(values.rm);
786
- output(updated, `Removed alias: ${values.rm}`);
931
+ output(updated, green(`Removed alias: ${values.rm}`));
787
932
  } else if (positionals.length >= 2) {
788
933
  const alias = positionals[0];
789
934
  const path = positionals[1];
@@ -791,7 +936,7 @@ function main() {
791
936
  error("Alias name and path are required");
792
937
  }
793
938
  const updated = storage.setAlias(alias, path);
794
- output(updated, `Added alias: ${alias} → ${path}`);
939
+ output(updated, green(`Added alias: ${alias} → ${path}`));
795
940
  } else {
796
941
  // List aliases
797
942
  const aliases = config.aliases;
@@ -835,37 +980,13 @@ function main() {
835
980
  }
836
981
  }
837
982
 
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
983
  try {
863
984
  main();
864
985
  } catch (e) {
865
986
  if (e instanceof Error) {
866
- console.error(`Error: ${e.message}`);
987
+ console.error(red(`Error: ${e.message}`));
867
988
  } else {
868
- console.error("An unexpected error occurred");
989
+ console.error(red("An unexpected error occurred"));
869
990
  }
870
991
  process.exit(1);
871
992
  }