@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/README.md CHANGED
@@ -1,22 +1,20 @@
1
1
  # tk
2
2
 
3
- Task tracker for AI agents. Simple, fast, git-friendly.
3
+ Minimal task tracker. Simple, fast, git-friendly.
4
4
 
5
- **Requires [Bun](https://bun.sh) runtime.**
5
+ - Plain JSON files in `.tasks/`
6
+ - No daemons, no merge conflicts, no viruses
6
7
 
7
8
  ## Install
8
9
 
10
+ Requires [Bun](https://bun.sh) runtime.
11
+
9
12
  ```bash
10
13
  # Install Bun first (if not installed)
11
14
  curl -fsSL https://bun.sh/install | bash
12
15
 
13
16
  # Then install tk globally
14
17
  bun add -g @nijaru/tk
15
-
16
- # Or run from source
17
- git clone https://github.com/nijaru/tk.git
18
- cd tk && bun install
19
- bun run src/cli.ts --help
20
18
  ```
21
19
 
22
20
  ## Quick Start
@@ -35,9 +33,9 @@ myapp-x9k2
35
33
  $ tk block x9k2 a7b3 # tests blocked by auth (just use ref)
36
34
 
37
35
  $ tk ready # what can I work on?
38
- ID PRI STATUS TITLE
36
+ ID | PRIO | STATUS | TITLE
39
37
  ------------------------------------------------------------
40
- myapp-a7b3 p1 open Implement auth
38
+ myapp-a7b3 | p1 | open | Implement auth
41
39
 
42
40
  $ tk start a7b3 # just the ref works everywhere
43
41
  Started: myapp-a7b3
@@ -49,9 +47,9 @@ $ tk done a7b3
49
47
  Completed: myapp-a7b3
50
48
 
51
49
  $ tk ready # tests now unblocked
52
- ID PRI STATUS TITLE
50
+ ID | PRIO | STATUS | TITLE
53
51
  ------------------------------------------------------------
54
- myapp-x9k2 p2 open Write tests
52
+ myapp-x9k2 | p2 | open | Write tests
55
53
  ```
56
54
 
57
55
  ## Commands
@@ -67,11 +65,12 @@ myapp-x9k2 p2 open Write tests
67
65
  | `tk done <id>` | Complete task |
68
66
  | `tk reopen <id>` | Reopen task |
69
67
  | `tk edit <id>` | Edit task |
70
- | `tk log <id> <msg>` | Add log entry |
68
+ | `tk log <id> "<msg>"` | Add log entry |
71
69
  | `tk block <id> <blocker>` | Add dependency (id blocked by blocker) |
72
70
  | `tk unblock <id> <blocker>` | Remove dependency |
73
71
  | `tk rm` / `tk remove <id>` | Delete task |
74
- | `tk clean` | Remove old done tasks (default: 7d) |
72
+ | `tk clean` | Remove old done tasks (default: 14 days) |
73
+ | `tk check` | Check task integrity |
75
74
  | `tk config` | Show/set configuration |
76
75
  | `tk completions <shell>` | Output shell completions (bash, zsh, fish) |
77
76
  | `tk help [command]` | Show help (or command-specific help) |
@@ -120,9 +119,9 @@ tk edit a7b3 --parent - # Clear parent
120
119
  ## Clean Options
121
120
 
122
121
  ```bash
123
- tk clean # Remove done tasks older than 7 days
124
- tk clean --older-than 30d # Custom retention (Nd/Nw/Nm/Nh)
125
- tk clean -a # Remove all done tasks (ignore age)
122
+ tk clean # Remove done tasks older than config (default: 14 days)
123
+ tk clean --older-than 30 # Custom threshold (days)
124
+ tk clean --force # Force clean even if disabled in config
126
125
  ```
127
126
 
128
127
  ## Config
@@ -137,6 +136,20 @@ tk config alias web src/web # Add alias
137
136
  tk config alias --rm web # Remove alias
138
137
  ```
139
138
 
139
+ Config file (`.tasks/config.json`):
140
+
141
+ ```json
142
+ {
143
+ "version": 1,
144
+ "project": "myapp",
145
+ "clean_after": 14,
146
+ "defaults": { "priority": 3, "labels": [], "assignees": [] },
147
+ "aliases": {}
148
+ }
149
+ ```
150
+
151
+ - `clean_after`: Days to keep done tasks (default: 14). Set to `false` to disable cleaning.
152
+
140
153
  ## Task IDs
141
154
 
142
155
  Format: `project-ref` (e.g., `myapp-a7b3`, `api-x9k2`, `web-3m8p`)
@@ -226,4 +239,4 @@ tk completions fish | source
226
239
 
227
240
  ## License
228
241
 
229
- MIT
242
+ [MIT](LICENSE)
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@nijaru/tk",
3
- "version": "0.0.1",
4
- "description": "Task tracker for AI agents",
3
+ "version": "0.0.2",
4
+ "description": "Minimal task tracker",
5
5
  "type": "module",
6
6
  "bin": {
7
- "tk": "./src/cli.ts"
7
+ "tk": "src/cli.ts"
8
8
  },
9
9
  "files": [
10
10
  "src",
@@ -37,7 +37,7 @@
37
37
  "license": "MIT",
38
38
  "repository": {
39
39
  "type": "git",
40
- "url": "https://github.com/nijaru/tk"
40
+ "url": "git+https://github.com/nijaru/tk.git"
41
41
  },
42
42
  "author": "nijaru",
43
43
  "homepage": "https://github.com/nijaru/tk#readme",
package/src/cli.test.ts CHANGED
@@ -298,13 +298,24 @@ describe("tk CLI", () => {
298
298
  expect(task.completed_at).toBeNull();
299
299
  });
300
300
 
301
- test("start errors if not open", async () => {
301
+ test("start errors if already active", async () => {
302
302
  const { stdout: id } = await run(["add", "Task"], testDir);
303
303
  await run(["start", id.trim()], testDir);
304
304
 
305
305
  const { stderr, exitCode } = await run(["start", id.trim()], testDir);
306
306
  expect(exitCode).toBe(1);
307
- expect(stderr).toContain("not open");
307
+ expect(stderr).toContain("already active");
308
+ expect(stderr).toContain("tk done"); // suggests next action
309
+ });
310
+
311
+ test("start errors if already done", async () => {
312
+ const { stdout: id } = await run(["add", "Task"], testDir);
313
+ await run(["done", id.trim()], testDir);
314
+
315
+ const { stderr, exitCode } = await run(["start", id.trim()], testDir);
316
+ expect(exitCode).toBe(1);
317
+ expect(stderr).toContain("already done");
318
+ expect(stderr).toContain("tk reopen"); // suggests next action
308
319
  });
309
320
  });
310
321
 
@@ -458,13 +469,29 @@ describe("tk CLI", () => {
458
469
  const task = JSON.parse(stdout);
459
470
  expect(task.blocked_by).not.toContain(id1.trim());
460
471
  });
472
+
473
+ test("clears parent references on child tasks", async () => {
474
+ const { stdout: parentId } = await run(["add", "Parent"], testDir);
475
+ const { stdout: childId } = await run(["add", "Child", "--parent", parentId.trim()], testDir);
476
+
477
+ // Verify child has parent
478
+ const { stdout: before } = await run(["show", childId.trim(), "--json"], testDir);
479
+ expect(JSON.parse(before).parent).toBe(parentId.trim());
480
+
481
+ // Delete parent
482
+ await run(["rm", parentId.trim()], testDir);
483
+
484
+ // Child's parent should be cleared
485
+ const { stdout: after } = await run(["show", childId.trim(), "--json"], testDir);
486
+ expect(JSON.parse(after).parent).toBeNull();
487
+ });
461
488
  });
462
489
 
463
490
  describe("clean", () => {
464
491
  test("removes completed tasks", async () => {
465
492
  const { stdout: id } = await run(["add", "Task"], testDir);
466
493
  await run(["done", id.trim()], testDir);
467
- await run(["clean", "--all"], testDir);
494
+ await run(["clean", "--force"], testDir);
468
495
 
469
496
  const { exitCode } = await run(["show", id.trim()], testDir);
470
497
  expect(exitCode).toBe(1);
@@ -472,11 +499,26 @@ describe("tk CLI", () => {
472
499
 
473
500
  test("keeps open tasks", async () => {
474
501
  const { stdout: id } = await run(["add", "Open task"], testDir);
475
- await run(["clean", "--all"], testDir);
502
+ await run(["clean", "--force"], testDir);
476
503
 
477
504
  const { exitCode } = await run(["show", id.trim()], testDir);
478
505
  expect(exitCode).toBe(0);
479
506
  });
507
+
508
+ test("--older-than respects age threshold", async () => {
509
+ const { stdout: id } = await run(["add", "Task"], testDir);
510
+ await run(["done", id.trim()], testDir);
511
+
512
+ // Task was just completed, so --older-than 1 should keep it
513
+ await run(["clean", "--older-than", "1"], testDir);
514
+ const { exitCode: stillExists } = await run(["show", id.trim()], testDir);
515
+ expect(stillExists).toBe(0);
516
+
517
+ // --older-than 0 should remove it (0 days = remove all)
518
+ await run(["clean", "--older-than", "0"], testDir);
519
+ const { exitCode: gone } = await run(["show", id.trim()], testDir);
520
+ expect(gone).toBe(1);
521
+ });
480
522
  });
481
523
 
482
524
  describe("init", () => {
@@ -547,6 +589,38 @@ describe("tk CLI", () => {
547
589
  expect(stdout).toContain("api");
548
590
  expect(stdout).toContain("packages/api");
549
591
  });
592
+
593
+ test("renames project and updates references", async () => {
594
+ await run(["init", "-P", "old"], testDir);
595
+
596
+ // Create tasks with references
597
+ const { stdout: id1 } = await run(["add", "Task 1"], testDir);
598
+ const { stdout: id2 } = await run(["add", "Task 2"], testDir);
599
+ await run(["block", id2.trim(), id1.trim()], testDir);
600
+
601
+ // Rename project
602
+ const { stdout, exitCode } = await run(
603
+ ["config", "project", "new", "--rename", "old"],
604
+ testDir,
605
+ );
606
+ expect(exitCode).toBe(0);
607
+ expect(stdout).toContain("Renamed 2 tasks");
608
+ expect(stdout).toContain("old-*");
609
+ expect(stdout).toContain("new-*");
610
+
611
+ // Verify new IDs work
612
+ const newId1 = id1.trim().replace("old-", "new-");
613
+ const { exitCode: showCode } = await run(["show", newId1], testDir);
614
+ expect(showCode).toBe(0);
615
+
616
+ // Verify references updated
617
+ const { stdout: showOut } = await run(
618
+ ["show", id2.trim().replace("old-", "new-"), "--json"],
619
+ testDir,
620
+ );
621
+ const task = JSON.parse(showOut);
622
+ expect(task.blocked_by[0]).toBe(newId1);
623
+ });
550
624
  });
551
625
 
552
626
  describe("error handling", () => {
@@ -586,7 +660,7 @@ describe("tk CLI", () => {
586
660
  test("rejects invalid ID format", async () => {
587
661
  const { stderr, exitCode } = await run(["show", "invalid"], testDir);
588
662
  expect(exitCode).toBe(1);
589
- expect(stderr).toContain("Invalid task ID");
663
+ expect(stderr).toContain("not found");
590
664
  });
591
665
  });
592
666
 
@@ -615,6 +689,22 @@ describe("tk CLI", () => {
615
689
  const tasks = JSON.parse(stdout);
616
690
  expect(Array.isArray(tasks)).toBe(true);
617
691
  });
692
+
693
+ test("log requires quoted message", async () => {
694
+ const { stdout: id } = await run(["add", "Task"], testDir);
695
+ const taskId = id.trim();
696
+
697
+ // Unquoted multiple words should error
698
+ const { stderr, exitCode } = await run(["log", taskId, "word1", "word2"], testDir);
699
+ expect(exitCode).toBe(1);
700
+ expect(stderr).toContain("must be quoted");
701
+
702
+ // Quoted message works (shell passes as single arg)
703
+ await run(["log", taskId, "Quoted message works"], testDir);
704
+ const { stdout } = await run(["show", "--json", taskId], testDir);
705
+ const task = JSON.parse(stdout);
706
+ expect(task.logs[0].msg).toBe("Quoted message works");
707
+ });
618
708
  });
619
709
 
620
710
  describe("ID resolution", () => {
@@ -632,5 +722,175 @@ describe("tk CLI", () => {
632
722
  const { exitCode } = await run(["show", id.trim()], testDir);
633
723
  expect(exitCode).toBe(0);
634
724
  });
725
+
726
+ test("ambiguous ID shows matching tasks", async () => {
727
+ // Create tasks with same ref prefix in different projects
728
+ const { stdout: id1 } = await run(["add", "Task 1", "-P", "api"], testDir);
729
+ const { stdout: id2 } = await run(["add", "Task 2", "-P", "web"], testDir);
730
+ const ref1 = id1.trim().split("-")[1] ?? "";
731
+ const ref2 = id2.trim().split("-")[1] ?? "";
732
+
733
+ // Use first char which might match both
734
+ const prefix = ref1[0] ?? "";
735
+
736
+ // Only test if both refs start with same char (otherwise not ambiguous)
737
+ if (ref2.startsWith(prefix)) {
738
+ const { stderr, exitCode } = await run(["show", prefix], testDir);
739
+ expect(exitCode).toBe(1);
740
+ expect(stderr).toContain("Ambiguous");
741
+ expect(stderr).toContain("matches");
742
+ }
743
+ });
744
+
745
+ test("not found shows clear error", async () => {
746
+ const { stderr, exitCode } = await run(["show", "zzzz"], testDir);
747
+ expect(exitCode).toBe(1);
748
+ expect(stderr).toContain("not found");
749
+ });
750
+ });
751
+
752
+ describe("tk check", () => {
753
+ test("reports all OK when no issues", async () => {
754
+ await run(["add", "Task 1"], testDir);
755
+ await run(["add", "Task 2"], testDir);
756
+
757
+ const { stdout, exitCode } = await run(["check"], testDir);
758
+ expect(exitCode).toBe(0);
759
+ expect(stdout).toContain("2 tasks OK");
760
+ });
761
+
762
+ test("auto-fixes orphaned blocker reference", async () => {
763
+ // Create two tasks and block one by the other
764
+ const { stdout: id1 } = await run(["add", "Blocker"], testDir);
765
+ const { stdout: id2 } = await run(["add", "Blocked"], testDir);
766
+ const blockerId = id1.trim();
767
+ const blockedId = id2.trim();
768
+
769
+ await run(["block", blockedId, blockerId], testDir);
770
+
771
+ // Manually delete blocker file to simulate merge
772
+ const fs = await import("fs");
773
+ const path = await import("path");
774
+ const blockerFile = path.join(testDir, ".tasks", `${blockerId}.json`);
775
+ fs.unlinkSync(blockerFile);
776
+
777
+ // Check should fix it
778
+ const { stdout } = await run(["check"], testDir);
779
+ expect(stdout).toContain("cleaned");
780
+ expect(stdout).toContain("orphaned");
781
+
782
+ // Verify the blocked_by is now empty
783
+ const { stdout: showOut } = await run(["show", "--json", blockedId], testDir);
784
+ const task = JSON.parse(showOut);
785
+ expect(task.blocked_by).toEqual([]);
786
+ });
787
+
788
+ test("auto-fixes orphaned parent reference", async () => {
789
+ // Create parent and child
790
+ const { stdout: parentId } = await run(["add", "Parent"], testDir);
791
+ const parent = parentId.trim();
792
+ const { stdout: childId } = await run(["add", "Child", "--parent", parent], testDir);
793
+ const child = childId.trim();
794
+
795
+ // Manually delete parent file
796
+ const fs = await import("fs");
797
+ const path = await import("path");
798
+ const parentFile = path.join(testDir, ".tasks", `${parent}.json`);
799
+ fs.unlinkSync(parentFile);
800
+
801
+ // Check should fix it
802
+ const { stdout } = await run(["check"], testDir);
803
+ expect(stdout).toContain("cleaned");
804
+ expect(stdout).toContain("orphaned parent");
805
+
806
+ // Verify parent is now null
807
+ const { stdout: showOut } = await run(["show", "--json", child], testDir);
808
+ const task = JSON.parse(showOut);
809
+ expect(task.parent).toBeNull();
810
+ });
811
+
812
+ test("auto-fix happens on show command", async () => {
813
+ // Create two tasks and block one
814
+ const { stdout: id1 } = await run(["add", "Blocker"], testDir);
815
+ const { stdout: id2 } = await run(["add", "Blocked"], testDir);
816
+ const blockerId = id1.trim();
817
+ const blockedId = id2.trim();
818
+
819
+ await run(["block", blockedId, blockerId], testDir);
820
+
821
+ // Manually delete blocker file
822
+ const fs = await import("fs");
823
+ const path = await import("path");
824
+ const blockerFile = path.join(testDir, ".tasks", `${blockerId}.json`);
825
+ fs.unlinkSync(blockerFile);
826
+
827
+ // Show should auto-fix and output cleanup message
828
+ const { stderr } = await run(["show", blockedId], testDir);
829
+ expect(stderr).toContain("cleaned");
830
+ expect(stderr).toContain("orphaned");
831
+ });
832
+
833
+ test("reports unfixable corrupted JSON", async () => {
834
+ await run(["add", "Good task"], testDir);
835
+
836
+ // Create a corrupted JSON file
837
+ const fs = await import("fs");
838
+ const path = await import("path");
839
+ const corruptFile = path.join(testDir, ".tasks", "test-bad1.json");
840
+ fs.writeFileSync(corruptFile, "{ invalid json");
841
+
842
+ const { stdout } = await run(["check"], testDir);
843
+ expect(stdout).toContain("Unfixable");
844
+ expect(stdout).toContain("test-bad1.json");
845
+ });
846
+
847
+ test("reports unfixable invalid task structure", async () => {
848
+ await run(["add", "Good task"], testDir);
849
+
850
+ // Create valid JSON but invalid task structure
851
+ const fs = await import("fs");
852
+ const path = await import("path");
853
+ const badFile = path.join(testDir, ".tasks", "test-bad2.json");
854
+ fs.writeFileSync(badFile, '{"foo": "bar"}');
855
+
856
+ const { stdout } = await run(["check"], testDir);
857
+ expect(stdout).toContain("Unfixable");
858
+ expect(stdout).toContain("test-bad2.json");
859
+ expect(stdout).toContain("Invalid task structure");
860
+ });
861
+
862
+ test("auto-fixes ID mismatch (filename vs content)", async () => {
863
+ // Create a task
864
+ const { stdout: id } = await run(["add", "Test task", "-P", "api"], testDir);
865
+ const taskId = id.trim();
866
+
867
+ // Manually rename the file to create a mismatch
868
+ const fs = await import("fs");
869
+ const path = await import("path");
870
+ const oldPath = path.join(testDir, ".tasks", `${taskId}.json`);
871
+ const newPath = path.join(testDir, ".tasks", "web-x1y2.json");
872
+ fs.renameSync(oldPath, newPath);
873
+
874
+ // Check should fix it
875
+ const { stdout } = await run(["check"], testDir);
876
+ expect(stdout).toContain("cleaned");
877
+ expect(stdout).toContain("ID mismatch");
878
+
879
+ // Verify the content was updated to match filename
880
+ const { stdout: showOut } = await run(["show", "--json", "web-x1y2"], testDir);
881
+ const task = JSON.parse(showOut);
882
+ expect(task.project).toBe("web");
883
+ expect(task.ref).toBe("x1y2");
884
+ });
885
+
886
+ test("check --json returns structured output", async () => {
887
+ await run(["add", "Task"], testDir);
888
+
889
+ const { stdout } = await run(["check", "--json"], testDir);
890
+ const result = JSON.parse(stdout);
891
+ expect(result).toHaveProperty("totalTasks");
892
+ expect(result).toHaveProperty("cleaned");
893
+ expect(result).toHaveProperty("unfixable");
894
+ });
635
895
  });
636
896
  });