@towles/tool 0.0.108 → 0.0.110

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.
Files changed (190) hide show
  1. package/package.json +9 -4
  2. package/{plugins/tt-agentboard → packages/agentboard}/README.md +1 -1
  3. package/{plugins/tt-agentboard → packages/agentboard}/apps/server/package.json +2 -1
  4. package/{plugins/tt-agentboard → packages/agentboard}/apps/server/src/main.ts +6 -20
  5. package/{plugins/tt-agentboard → packages/agentboard}/apps/tui/package.json +4 -0
  6. package/{plugins/tt-agentboard → packages/agentboard}/apps/tui/src/components/DetailPanel.tsx +3 -2
  7. package/{plugins/tt-agentboard → packages/agentboard}/apps/tui/src/components/StatusBar.tsx +35 -0
  8. package/{plugins/tt-agentboard → packages/agentboard}/apps/tui/src/constants.ts +1 -0
  9. package/{plugins/tt-agentboard → packages/agentboard}/apps/tui/src/index.tsx +204 -225
  10. package/packages/agentboard/apps/tui/src/session-status.test.ts +70 -0
  11. package/packages/agentboard/apps/tui/src/session-status.ts +19 -0
  12. package/{plugins/tt-agentboard → packages/agentboard}/package.json +2 -6
  13. package/{plugins/tt-agentboard → packages/agentboard}/packages/runtime/package.json +3 -0
  14. package/{plugins/tt-agentboard/packages/runtime/test → packages/agentboard/packages/runtime/src/agents}/tracker.test.ts +2 -2
  15. package/packages/agentboard/packages/runtime/src/agents/watchers/claude-code.test.ts +63 -0
  16. package/{plugins/tt-agentboard → packages/agentboard}/packages/runtime/src/agents/watchers/claude-code.ts +26 -2
  17. package/packages/agentboard/packages/runtime/src/config.test.ts +107 -0
  18. package/packages/agentboard/packages/runtime/src/config.ts +80 -0
  19. package/{plugins/tt-agentboard → packages/agentboard}/packages/runtime/src/index.ts +1 -1
  20. package/{plugins/tt-agentboard → packages/agentboard}/packages/runtime/src/plugins/loader.ts +1 -33
  21. package/{plugins/tt-agentboard → packages/agentboard}/packages/runtime/src/server/git-info.ts +3 -2
  22. package/{plugins/tt-agentboard → packages/agentboard}/packages/runtime/src/server/index.ts +23 -37
  23. package/{plugins/tt-agentboard → packages/agentboard}/packages/runtime/src/server/launcher.ts +6 -18
  24. package/{plugins/tt-agentboard → packages/agentboard}/packages/runtime/src/server/pane-scanner.ts +6 -0
  25. package/{plugins/tt-agentboard → packages/agentboard}/packages/runtime/src/shared.ts +2 -0
  26. package/{plugins/tt-agentboard → packages/agentboard}/packages/runtime/tsconfig.json +1 -1
  27. package/{plugins/tt-core → packages/core}/.claude-plugin/plugin.json +1 -1
  28. package/packages/shared/package.json +15 -0
  29. package/packages/shared/src/git/exec.ts +41 -0
  30. package/{src/utils → packages/shared/src}/git/gh-cli-wrapper.ts +13 -18
  31. package/packages/shared/src/index.ts +8 -0
  32. package/packages/shared/tsconfig.json +16 -0
  33. package/src/cli.ts +3 -2
  34. package/src/commands/agentboard.ts +42 -59
  35. package/src/{lib → commands}/auto-claude/claude-cli.ts +1 -1
  36. package/src/commands/auto-claude/config-init-helpers.ts +79 -0
  37. package/src/commands/auto-claude/config-init.test.ts +137 -0
  38. package/src/commands/auto-claude/config-init.ts +159 -0
  39. package/src/{lib → commands}/auto-claude/config.ts +4 -8
  40. package/src/{lib → commands}/auto-claude/e2e.test.ts +6 -6
  41. package/src/commands/auto-claude/explain.test.ts +58 -0
  42. package/src/commands/auto-claude/explain.ts +97 -0
  43. package/src/commands/auto-claude/index.ts +37 -14
  44. package/src/{lib → commands}/auto-claude/labels.ts +1 -1
  45. package/src/commands/auto-claude/list.ts +5 -4
  46. package/src/{lib → commands}/auto-claude/pipeline-execution.test.ts +1 -1
  47. package/src/{lib → commands}/auto-claude/pipeline.ts +1 -3
  48. package/src/commands/auto-claude/retry.test.ts +2 -2
  49. package/src/commands/auto-claude/retry.ts +5 -5
  50. package/src/commands/auto-claude/shell.ts +3 -0
  51. package/src/commands/auto-claude/status.test.ts +2 -2
  52. package/src/commands/auto-claude/status.ts +4 -4
  53. package/src/{lib → commands}/auto-claude/steps/create-pr.ts +1 -3
  54. package/src/{lib → commands}/auto-claude/steps/fetch-issues.ts +1 -1
  55. package/src/{lib → commands}/auto-claude/steps/implement.ts +1 -2
  56. package/src/{lib → commands}/auto-claude/utils-execution.test.ts +6 -6
  57. package/src/{lib → commands}/auto-claude/utils.ts +10 -4
  58. package/src/{lib/install → commands}/claude-settings.ts +1 -1
  59. package/src/commands/config/config.test.ts +129 -0
  60. package/src/commands/config/index.ts +11 -0
  61. package/src/commands/config/reset.ts +53 -0
  62. package/src/commands/config/schema.ts +19 -0
  63. package/src/commands/{config.ts → config/show.ts} +2 -2
  64. package/src/commands/config/validate.ts +51 -0
  65. package/src/commands/doctor/checks.ts +167 -0
  66. package/src/commands/doctor/format.test.ts +63 -0
  67. package/src/commands/doctor/format.ts +5 -0
  68. package/src/commands/doctor/history.test.ts +161 -0
  69. package/src/commands/doctor/history.ts +130 -0
  70. package/src/commands/doctor.ts +80 -151
  71. package/src/commands/gh/branch-clean.ts +4 -4
  72. package/src/commands/gh/branch.test.ts +4 -5
  73. package/src/commands/gh/branch.ts +10 -5
  74. package/src/commands/gh/pr.ts +6 -7
  75. package/src/{lib → commands}/graph/analyzer.test.ts +4 -4
  76. package/src/commands/graph/format.test.ts +130 -0
  77. package/src/commands/graph/format.ts +94 -0
  78. package/src/commands/graph/index.ts +69 -41
  79. package/src/{lib → commands}/graph/labels.ts +4 -4
  80. package/src/{lib → commands}/graph/server.ts +2 -2
  81. package/src/{lib → commands}/graph/types.ts +2 -0
  82. package/src/commands/graph.test.ts +1 -1
  83. package/src/commands/install.ts +6 -6
  84. package/src/commands/journal/daily-notes.ts +4 -7
  85. package/src/{lib → commands}/journal/fs.ts +1 -1
  86. package/src/commands/journal/index.ts +2 -0
  87. package/src/commands/journal/list.test.ts +174 -0
  88. package/src/commands/journal/list.ts +213 -0
  89. package/src/commands/journal/meeting.ts +4 -7
  90. package/src/commands/journal/note.ts +4 -7
  91. package/src/{lib → commands}/journal/paths.ts +1 -1
  92. package/src/commands/journal/search.test.ts +156 -0
  93. package/src/commands/journal/search.ts +256 -0
  94. package/src/{lib → commands}/journal/templates.ts +1 -1
  95. package/src/config/settings.ts +35 -26
  96. package/plugins/tt-agentboard/bun.lock +0 -444
  97. package/plugins/tt-agentboard/packages/runtime/src/config.ts +0 -70
  98. package/plugins/tt-agentboard/packages/runtime/test/config.test.ts +0 -83
  99. package/plugins/tt-auto-claude/.claude-plugin/plugin.json +0 -8
  100. package/plugins/tt-auto-claude/commands/create-issue.md +0 -20
  101. package/plugins/tt-auto-claude/commands/list.md +0 -21
  102. package/plugins/tt-auto-claude/skills/auto-claude/SKILL.md +0 -71
  103. package/plugins/tt-core/promptfooconfig.interview-me.yaml +0 -155
  104. package/plugins/tt-core/promptfooconfig.refine-text.yaml +0 -242
  105. package/plugins/tt-core/promptfooconfig.tdd.yaml +0 -144
  106. package/plugins/tt-core/promptfooconfig.write-prd.yaml +0 -145
  107. package/src/commands/config.test.ts +0 -9
  108. package/src/lib/auto-claude/index.ts +0 -15
  109. package/src/lib/auto-claude/shell.ts +0 -6
  110. package/src/lib/graph/index.ts +0 -24
  111. package/src/lib/journal/index.ts +0 -11
  112. package/src/utils/git/exec.ts +0 -18
  113. /package/{plugins/tt-agentboard → packages/agentboard}/apps/tui/build.ts +0 -0
  114. /package/{plugins/tt-agentboard → packages/agentboard}/apps/tui/bunfig.toml +0 -0
  115. /package/{plugins/tt-agentboard → packages/agentboard}/apps/tui/scripts/sessionizer.sh +0 -0
  116. /package/{plugins/tt-agentboard → packages/agentboard}/apps/tui/src/components/DiffStats.tsx +0 -0
  117. /package/{plugins/tt-agentboard → packages/agentboard}/apps/tui/src/components/SessionCard.tsx +0 -0
  118. /package/{plugins/tt-agentboard → packages/agentboard}/apps/tui/src/detail-panel-height.ts +0 -0
  119. /package/{plugins/tt-agentboard → packages/agentboard}/apps/tui/src/mux-context.ts +0 -0
  120. /package/{plugins/tt-agentboard → packages/agentboard}/apps/tui/tsconfig.json +0 -0
  121. /package/{plugins/tt-agentboard → packages/agentboard}/packages/mux-tmux/package.json +0 -0
  122. /package/{plugins/tt-agentboard → packages/agentboard}/packages/mux-tmux/src/client.ts +0 -0
  123. /package/{plugins/tt-agentboard → packages/agentboard}/packages/mux-tmux/src/index.ts +0 -0
  124. /package/{plugins/tt-agentboard → packages/agentboard}/packages/mux-tmux/src/provider.ts +0 -0
  125. /package/{plugins/tt-agentboard → packages/agentboard}/packages/mux-tmux/tsconfig.json +0 -0
  126. /package/{plugins/tt-agentboard → packages/agentboard}/packages/runtime/src/agents/tracker.ts +0 -0
  127. /package/{plugins/tt-agentboard → packages/agentboard}/packages/runtime/src/agents/watchers/amp.ts +0 -0
  128. /package/{plugins/tt-agentboard → packages/agentboard}/packages/runtime/src/agents/watchers/codex.ts +0 -0
  129. /package/{plugins/tt-agentboard → packages/agentboard}/packages/runtime/src/agents/watchers/opencode.ts +0 -0
  130. /package/{plugins/tt-agentboard → packages/agentboard}/packages/runtime/src/contracts/agent-watcher.ts +0 -0
  131. /package/{plugins/tt-agentboard → packages/agentboard}/packages/runtime/src/contracts/agent.ts +0 -0
  132. /package/{plugins/tt-agentboard → packages/agentboard}/packages/runtime/src/contracts/index.ts +0 -0
  133. /package/{plugins/tt-agentboard → packages/agentboard}/packages/runtime/src/contracts/mux.ts +0 -0
  134. /package/{plugins/tt-agentboard → packages/agentboard}/packages/runtime/src/debug.ts +0 -0
  135. /package/{plugins/tt-agentboard → packages/agentboard}/packages/runtime/src/mux/detect.ts +0 -0
  136. /package/{plugins/tt-agentboard → packages/agentboard}/packages/runtime/src/mux/registry.ts +0 -0
  137. /package/{plugins/tt-agentboard → packages/agentboard}/packages/runtime/src/server/context.ts +0 -0
  138. /package/{plugins/tt-agentboard → packages/agentboard}/packages/runtime/src/server/metadata-store.ts +0 -0
  139. /package/{plugins/tt-agentboard → packages/agentboard}/packages/runtime/src/server/port-scanner.ts +0 -0
  140. /package/{plugins/tt-agentboard → packages/agentboard}/packages/runtime/src/server/session-order.ts +0 -0
  141. /package/{plugins/tt-agentboard → packages/agentboard}/packages/runtime/src/server/sidebar-manager.ts +0 -0
  142. /package/{plugins/tt-agentboard → packages/agentboard}/packages/runtime/src/server/sidebar-width-sync.ts +0 -0
  143. /package/{plugins/tt-agentboard → packages/agentboard}/packages/runtime/src/themes.ts +0 -0
  144. /package/{plugins/tt-agentboard → packages/agentboard}/tsconfig.json +0 -0
  145. /package/{plugins/tt-core → packages/core}/README.md +0 -0
  146. /package/{plugins/tt-core → packages/core}/commands/improve-architecture.md +0 -0
  147. /package/{plugins/tt-core → packages/core}/commands/interview-me.md +0 -0
  148. /package/{plugins/tt-core → packages/core}/commands/prd-to-issues.md +0 -0
  149. /package/{plugins/tt-core → packages/core}/commands/refine-text.md +0 -0
  150. /package/{plugins/tt-core → packages/core}/commands/task.md +0 -0
  151. /package/{plugins/tt-core → packages/core}/commands/tdd.md +0 -0
  152. /package/{plugins/tt-core → packages/core}/commands/write-prd.md +0 -0
  153. /package/{plugins/tt-core → packages/core}/skills/towles-tool/SKILL.md +0 -0
  154. /package/{src/utils → packages/shared/src}/date-utils.test.ts +0 -0
  155. /package/{src/utils → packages/shared/src}/date-utils.ts +0 -0
  156. /package/{src/utils → packages/shared/src}/fs.ts +0 -0
  157. /package/{src/utils → packages/shared/src}/git/branch-name.test.ts +0 -0
  158. /package/{src/utils → packages/shared/src}/git/branch-name.ts +0 -0
  159. /package/{src/utils → packages/shared/src}/git/gh-cli-wrapper.test.ts +0 -0
  160. /package/{src/utils → packages/shared/src}/render.test.ts +0 -0
  161. /package/{src/utils → packages/shared/src}/render.ts +0 -0
  162. /package/src/{lib → commands}/auto-claude/config.test.ts +0 -0
  163. /package/src/{lib → commands}/auto-claude/labels.test.ts +0 -0
  164. /package/src/{lib → commands}/auto-claude/pipeline.test.ts +0 -0
  165. /package/src/{lib → commands}/auto-claude/prompt-templates/01_plan.prompt.md +0 -0
  166. /package/src/{lib → commands}/auto-claude/prompt-templates/02_implement.prompt.md +0 -0
  167. /package/src/{lib → commands}/auto-claude/prompt-templates/03_simplify.prompt.md +0 -0
  168. /package/src/{lib → commands}/auto-claude/prompt-templates/04_review.prompt.md +0 -0
  169. /package/src/{lib → commands}/auto-claude/prompt-templates/CLAUDE.md +0 -0
  170. /package/src/{lib → commands}/auto-claude/prompt-templates/index.test.ts +0 -0
  171. /package/src/{lib → commands}/auto-claude/prompt-templates/index.ts +0 -0
  172. /package/src/{lib → commands}/auto-claude/run-claude.test.ts +0 -0
  173. /package/src/{lib → commands}/auto-claude/spawn-claude.ts +0 -0
  174. /package/src/{lib → commands}/auto-claude/steps/simple-steps.ts +0 -0
  175. /package/src/{lib → commands}/auto-claude/steps/steps.test.ts +0 -0
  176. /package/src/{lib → commands}/auto-claude/stream-parser.test.ts +0 -0
  177. /package/src/{lib → commands}/auto-claude/stream-parser.ts +0 -0
  178. /package/src/{lib → commands}/auto-claude/templates.test.ts +0 -0
  179. /package/src/{lib → commands}/auto-claude/templates.ts +0 -0
  180. /package/src/{lib → commands}/auto-claude/test-helpers.ts +0 -0
  181. /package/src/{lib → commands}/auto-claude/utils.test.ts +0 -0
  182. /package/src/{lib → commands}/graph/analyzer.ts +0 -0
  183. /package/src/{lib → commands}/graph/graph-template.html +0 -0
  184. /package/src/{lib → commands}/graph/parser.test.ts +0 -0
  185. /package/src/{lib → commands}/graph/parser.ts +0 -0
  186. /package/src/{lib → commands}/graph/render.ts +0 -0
  187. /package/src/{lib → commands}/graph/sessions.ts +0 -0
  188. /package/src/{lib → commands}/graph/tools.ts +0 -0
  189. /package/src/{lib → commands}/graph/treemap.ts +0 -0
  190. /package/src/{lib → commands}/journal/editor.ts +0 -0
@@ -0,0 +1,161 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { loadHistory, saveHistory, diffRuns } from "./history.js";
6
+ import type { DoctorRunResult } from "./checks.js";
7
+
8
+ function makeResult(overrides: Partial<DoctorRunResult> = {}): DoctorRunResult {
9
+ return {
10
+ timestamp: new Date().toISOString(),
11
+ tools: [
12
+ { name: "git", version: "2.40.0", ok: true },
13
+ { name: "node", version: "20.11.0", ok: true },
14
+ { name: "bun", version: "1.1.0", ok: true },
15
+ ],
16
+ ghAuth: true,
17
+ plugins: [{ name: "code-simplifier", ok: true }],
18
+ agentboard: [{ name: "database", ok: true }],
19
+ ...overrides,
20
+ };
21
+ }
22
+
23
+ describe("loadHistory / saveHistory", () => {
24
+ let tmpDir: string;
25
+ let historyPath: string;
26
+
27
+ beforeEach(() => {
28
+ tmpDir = resolve(tmpdir(), `doctor-test-${Date.now()}`);
29
+ mkdirSync(tmpDir, { recursive: true });
30
+ historyPath = resolve(tmpDir, "doctor-history.json");
31
+ });
32
+
33
+ afterEach(() => {
34
+ rmSync(tmpDir, { recursive: true, force: true });
35
+ });
36
+
37
+ it("returns empty array when no file exists", () => {
38
+ expect(loadHistory(historyPath)).toEqual([]);
39
+ });
40
+
41
+ it("saves and loads a run", () => {
42
+ const result = makeResult();
43
+ saveHistory(result, historyPath);
44
+
45
+ const history = loadHistory(historyPath);
46
+ expect(history).toHaveLength(1);
47
+ expect(history[0].timestamp).toBe(result.timestamp);
48
+ expect(history[0].tools).toEqual(result.tools);
49
+ });
50
+
51
+ it("appends multiple runs", () => {
52
+ saveHistory(makeResult({ timestamp: "2024-01-01T00:00:00Z" }), historyPath);
53
+ saveHistory(makeResult({ timestamp: "2024-01-02T00:00:00Z" }), historyPath);
54
+
55
+ const history = loadHistory(historyPath);
56
+ expect(history).toHaveLength(2);
57
+ expect(history[0].timestamp).toBe("2024-01-01T00:00:00Z");
58
+ expect(history[1].timestamp).toBe("2024-01-02T00:00:00Z");
59
+ });
60
+
61
+ it("caps history at 50 entries", () => {
62
+ for (let i = 0; i < 55; i++) {
63
+ saveHistory(
64
+ makeResult({ timestamp: `2024-01-${String(i + 1).padStart(2, "0")}T00:00:00Z` }),
65
+ historyPath,
66
+ );
67
+ }
68
+
69
+ const history = loadHistory(historyPath);
70
+ expect(history).toHaveLength(50);
71
+ expect(history[0].timestamp).toBe("2024-01-06T00:00:00Z");
72
+ });
73
+
74
+ it("handles corrupted JSON gracefully", () => {
75
+ writeFileSync(historyPath, "not json", "utf-8");
76
+ expect(loadHistory(historyPath)).toEqual([]);
77
+ });
78
+ });
79
+
80
+ describe("diffRuns", () => {
81
+ it("detects version upgrades", () => {
82
+ const prev = makeResult({ tools: [{ name: "bun", version: "1.0.0", ok: true }] });
83
+ const curr = makeResult({ tools: [{ name: "bun", version: "1.1.0", ok: true }] });
84
+
85
+ const diffs = diffRuns(prev, curr);
86
+ const bunDiff = diffs.find((d) => d.name === "bun" && d.change === "upgraded");
87
+ expect(bunDiff).toBeDefined();
88
+ expect(bunDiff!.oldValue).toBe("1.0.0");
89
+ expect(bunDiff!.newValue).toBe("1.1.0");
90
+ });
91
+
92
+ it("detects version downgrades", () => {
93
+ const prev = makeResult({ tools: [{ name: "node", version: "22.0.0", ok: true }] });
94
+ const curr = makeResult({ tools: [{ name: "node", version: "20.11.0", ok: true }] });
95
+
96
+ const diffs = diffRuns(prev, curr);
97
+ const nodeDiff = diffs.find((d) => d.name === "node" && d.change === "downgraded");
98
+ expect(nodeDiff).toBeDefined();
99
+ });
100
+
101
+ it("detects new tools", () => {
102
+ const prev = makeResult({ tools: [] });
103
+ const curr = makeResult({ tools: [{ name: "git", version: "2.40.0", ok: true }] });
104
+
105
+ const diffs = diffRuns(prev, curr);
106
+ expect(diffs).toContainEqual(
107
+ expect.objectContaining({ name: "git", change: "added", newValue: "2.40.0" }),
108
+ );
109
+ });
110
+
111
+ it("detects removed tools", () => {
112
+ const prev = makeResult({ tools: [{ name: "ttyd", version: "1.7.0", ok: true }] });
113
+ const curr = makeResult({ tools: [] });
114
+
115
+ const diffs = diffRuns(prev, curr);
116
+ expect(diffs).toContainEqual(
117
+ expect.objectContaining({ name: "ttyd", change: "removed", oldValue: "1.7.0" }),
118
+ );
119
+ });
120
+
121
+ it("detects check status flips", () => {
122
+ const prev = makeResult({ tools: [{ name: "bun", version: "1.1.0", ok: true }] });
123
+ const curr = makeResult({ tools: [{ name: "bun", version: "1.1.0", ok: false }] });
124
+
125
+ const diffs = diffRuns(prev, curr);
126
+ expect(diffs).toContainEqual(expect.objectContaining({ name: "bun", change: "failed" }));
127
+ });
128
+
129
+ it("detects gh auth changes", () => {
130
+ const prev = makeResult({ ghAuth: true });
131
+ const curr = makeResult({ ghAuth: false });
132
+
133
+ const diffs = diffRuns(prev, curr);
134
+ expect(diffs).toContainEqual(expect.objectContaining({ name: "gh auth", change: "failed" }));
135
+ });
136
+
137
+ it("detects plugin status changes", () => {
138
+ const prev = makeResult({ plugins: [{ name: "code-simplifier", ok: false }] });
139
+ const curr = makeResult({ plugins: [{ name: "code-simplifier", ok: true }] });
140
+
141
+ const diffs = diffRuns(prev, curr);
142
+ expect(diffs).toContainEqual(
143
+ expect.objectContaining({ category: "plugin", name: "code-simplifier", change: "passed" }),
144
+ );
145
+ });
146
+
147
+ it("detects agentboard status changes", () => {
148
+ const prev = makeResult({ agentboard: [{ name: "database", ok: false }] });
149
+ const curr = makeResult({ agentboard: [{ name: "database", ok: true }] });
150
+
151
+ const diffs = diffRuns(prev, curr);
152
+ expect(diffs).toContainEqual(
153
+ expect.objectContaining({ category: "agentboard", name: "database", change: "passed" }),
154
+ );
155
+ });
156
+
157
+ it("returns empty array when nothing changed", () => {
158
+ const run = makeResult();
159
+ expect(diffRuns(run, run)).toEqual([]);
160
+ });
161
+ });
@@ -0,0 +1,130 @@
1
+ import { resolve } from "node:path";
2
+ import consola from "consola";
3
+ import { readFile, writeFile, fileExists } from "@towles/shared";
4
+ import type { DoctorRunResult } from "./checks.js";
5
+
6
+ const MAX_HISTORY = 50;
7
+
8
+ export interface DiffEntry {
9
+ category: string;
10
+ name: string;
11
+ change: "added" | "removed" | "upgraded" | "downgraded" | "passed" | "failed" | "unchanged";
12
+ oldValue?: string | null;
13
+ newValue?: string | null;
14
+ }
15
+
16
+ function getHistoryPath(): string {
17
+ const configDir = process.env.XDG_CONFIG_HOME ?? resolve(process.env.HOME ?? "~", ".config");
18
+ return resolve(configDir, "tt", "doctor-history.json");
19
+ }
20
+
21
+ export function loadHistory(historyPath?: string): DoctorRunResult[] {
22
+ const path = historyPath ?? getHistoryPath();
23
+ if (!fileExists(path)) return [];
24
+ try {
25
+ return JSON.parse(readFile(path));
26
+ } catch (err) {
27
+ consola.debug(`Failed to parse doctor history at ${path}:`, err);
28
+ return [];
29
+ }
30
+ }
31
+
32
+ export function saveHistory(result: DoctorRunResult, historyPath?: string): void {
33
+ const path = historyPath ?? getHistoryPath();
34
+ const history = loadHistory(path);
35
+ history.push(result);
36
+ const trimmed = history.slice(-MAX_HISTORY);
37
+ writeFile(path, JSON.stringify(trimmed, null, 2));
38
+ }
39
+
40
+ export function diffRuns(previous: DoctorRunResult, current: DoctorRunResult): DiffEntry[] {
41
+ const entries: DiffEntry[] = [];
42
+
43
+ const prevToolMap = new Map(previous.tools.map((t) => [t.name, t]));
44
+ const currToolMap = new Map(current.tools.map((t) => [t.name, t]));
45
+
46
+ for (const [name, curr] of currToolMap) {
47
+ const prev = prevToolMap.get(name);
48
+ if (!prev) {
49
+ entries.push({ category: "tool", name, change: "added", newValue: curr.version });
50
+ continue;
51
+ }
52
+ if (prev.version !== curr.version && prev.version && curr.version) {
53
+ entries.push({
54
+ category: "tool",
55
+ name,
56
+ change: compareVersions(prev.version, curr.version) > 0 ? "downgraded" : "upgraded",
57
+ oldValue: prev.version,
58
+ newValue: curr.version,
59
+ });
60
+ }
61
+ if (prev.ok !== curr.ok) {
62
+ entries.push({
63
+ category: "tool",
64
+ name,
65
+ change: curr.ok ? "passed" : "failed",
66
+ oldValue: prev.ok ? "pass" : "fail",
67
+ newValue: curr.ok ? "pass" : "fail",
68
+ });
69
+ }
70
+ }
71
+
72
+ for (const [name, prev] of prevToolMap) {
73
+ if (!currToolMap.has(name)) {
74
+ entries.push({ category: "tool", name, change: "removed", oldValue: prev.version });
75
+ }
76
+ }
77
+
78
+ if (previous.ghAuth !== current.ghAuth) {
79
+ entries.push({
80
+ category: "auth",
81
+ name: "gh auth",
82
+ change: current.ghAuth ? "passed" : "failed",
83
+ oldValue: previous.ghAuth ? "pass" : "fail",
84
+ newValue: current.ghAuth ? "pass" : "fail",
85
+ });
86
+ }
87
+
88
+ const prevPluginMap = new Map(previous.plugins.map((p) => [p.name, p]));
89
+ for (const curr of current.plugins) {
90
+ const prev = prevPluginMap.get(curr.name);
91
+ if (!prev) {
92
+ entries.push({ category: "plugin", name: curr.name, change: "added" });
93
+ } else if (prev.ok !== curr.ok) {
94
+ entries.push({
95
+ category: "plugin",
96
+ name: curr.name,
97
+ change: curr.ok ? "passed" : "failed",
98
+ });
99
+ }
100
+ }
101
+
102
+ const prevAbMap = new Map(previous.agentboard.map((a) => [a.name, a]));
103
+ for (const curr of current.agentboard) {
104
+ const prev = prevAbMap.get(curr.name);
105
+ if (!prev) {
106
+ entries.push({ category: "agentboard", name: curr.name, change: "added" });
107
+ } else if (prev.ok !== curr.ok) {
108
+ entries.push({
109
+ category: "agentboard",
110
+ name: curr.name,
111
+ change: curr.ok ? "passed" : "failed",
112
+ });
113
+ }
114
+ }
115
+
116
+ return entries;
117
+ }
118
+
119
+ function compareVersions(a: string, b: string): number {
120
+ const pa = a.split(".").map(Number);
121
+ const pb = b.split(".").map(Number);
122
+ const len = Math.max(pa.length, pb.length);
123
+ for (let i = 0; i < len; i++) {
124
+ const na = pa[i] ?? 0;
125
+ const nb = pb[i] ?? 0;
126
+ if (na > nb) return 1;
127
+ if (na < nb) return -1;
128
+ }
129
+ return 0;
130
+ }
@@ -1,159 +1,66 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
- import { resolve, join } from "node:path";
3
1
  import { defineCommand } from "citty";
4
2
  import consola from "consola";
5
- import { x } from "tinyexec";
6
3
  import { colors } from "consola/utils";
7
4
  import { debugArg } from "./shared.js";
8
-
9
- interface CheckResult {
10
- name: string;
11
- version: string | null;
12
- ok: boolean;
13
- warning?: string;
14
- }
15
-
16
- async function checkCommand(
17
- name: string,
18
- args: string[],
19
- versionPattern: RegExp,
20
- optional = false,
21
- ): Promise<CheckResult> {
22
- try {
23
- const result = await x(name, args);
24
- const output = result.stdout + result.stderr;
25
- const match = output.match(versionPattern);
26
- return {
27
- name,
28
- version: match?.[1] ?? output.trim().slice(0, 20),
29
- ok: true,
30
- };
31
- } catch {
32
- consola.debug(`Tool check failed for "${name}"`);
33
- return {
34
- name,
35
- version: null,
36
- ok: optional,
37
- warning: optional ? "optional, not installed" : undefined,
38
- };
5
+ import { runAllChecks, checkAgentBoard, checkClaudePlugins } from "./doctor/checks.js";
6
+ import { formatDoctorJson } from "./doctor/format.js";
7
+ import { loadHistory, saveHistory, diffRuns } from "./doctor/history.js";
8
+ import type { DiffEntry } from "./doctor/history.js";
9
+
10
+ function formatDiffEntry(entry: DiffEntry): string {
11
+ switch (entry.change) {
12
+ case "added":
13
+ return `${colors.green("+")} ${entry.category}/${entry.name}: added${entry.newValue ? ` (${entry.newValue})` : ""}`;
14
+ case "removed":
15
+ return `${colors.red("-")} ${entry.category}/${entry.name}: removed${entry.oldValue ? ` (was ${entry.oldValue})` : ""}`;
16
+ case "upgraded":
17
+ return `${colors.green("↑")} ${entry.category}/${entry.name}: ${entry.oldValue} → ${entry.newValue}`;
18
+ case "downgraded":
19
+ return `${colors.yellow("↓")} ${entry.category}/${entry.name}: ${entry.oldValue} → ${entry.newValue}`;
20
+ case "passed":
21
+ return `${colors.green("✓")} ${entry.category}/${entry.name}: now passing`;
22
+ case "failed":
23
+ return `${colors.red("✗")} ${entry.category}/${entry.name}: now failing`;
24
+ default:
25
+ return ` ${entry.category}/${entry.name}: unchanged`;
39
26
  }
40
27
  }
41
28
 
42
- async function checkGhAuth(): Promise<{ ok: boolean }> {
43
- try {
44
- const result = await x("gh", ["auth", "status"]);
45
- return { ok: result.exitCode === 0 };
46
- } catch {
47
- consola.debug("GitHub CLI auth check failed");
48
- return { ok: false };
49
- }
50
- }
51
-
52
- function checkAgentBoard(): {
53
- name: string;
54
- value: string;
55
- ok: boolean;
56
- warning?: string;
57
- hint?: string;
58
- }[] {
59
- const results: { name: string; value: string; ok: boolean; warning?: string; hint?: string }[] =
60
- [];
61
-
62
- const defaultDataDir = resolve(
63
- process.env.XDG_CONFIG_HOME ?? resolve(process.env.HOME ?? "~", ".config"),
64
- "towles-tool",
65
- "agentboard",
66
- );
67
- const dataDir = process.env.AGENTBOARD_DATA_DIR ?? defaultDataDir;
68
- const dbPath = join(dataDir, "agentboard.db");
69
- const configPath = join(dataDir, "config.json");
70
-
71
- const dbExists = existsSync(dbPath);
72
- results.push({
73
- name: "database",
74
- value: dbExists ? dbPath : "not found",
75
- ok: dbExists,
76
- hint: dbExists ? undefined : "Run: tt ag (starts server and creates DB automatically)",
77
- });
78
-
79
- let repoPaths: string[] = [];
80
- if (existsSync(configPath)) {
81
- try {
82
- const config = JSON.parse(readFileSync(configPath, "utf-8"));
83
- repoPaths = config.repoPaths ?? [];
84
- } catch {
85
- // Corrupted config
86
- }
87
- }
88
-
89
- results.push({
90
- name: "scan paths",
91
- value: repoPaths.length > 0 ? repoPaths.join(", ") : "none configured",
92
- ok: repoPaths.length > 0,
93
- warning: repoPaths.length === 0 ? "no scan paths" : undefined,
94
- hint:
95
- repoPaths.length === 0
96
- ? "Run: tt ag → open Workspaces → run the onboarding wizard"
97
- : undefined,
98
- });
99
-
100
- results.push({
101
- name: "data dir",
102
- value: dataDir,
103
- ok: true,
104
- });
105
-
106
- return results;
107
- }
108
-
109
- async function checkClaudePlugins(): Promise<
110
- { name: string; ok: boolean; installHint?: string }[]
111
- > {
112
- const requiredPlugins = [
113
- {
114
- id: "code-simplifier@claude-plugins-official",
115
- name: "code-simplifier",
116
- installCmd: "claude plugin install code-simplifier@claude-plugins-official --scope user",
29
+ export default defineCommand({
30
+ meta: { name: "doctor", description: "Check system dependencies and environment" },
31
+ args: {
32
+ debug: debugArg,
33
+ track: {
34
+ type: "boolean",
35
+ description: "Save check results to history",
36
+ default: false,
117
37
  },
118
- ];
119
-
120
- try {
121
- const result = await x("claude", ["plugin", "list", "--json"]);
122
- const plugins: { id: string }[] = JSON.parse(result.stdout);
123
- const installedIds = new Set(plugins.map((p) => p.id));
38
+ diff: {
39
+ type: "boolean",
40
+ description: "Compare current run against last tracked run",
41
+ default: false,
42
+ },
43
+ format: {
44
+ type: "string",
45
+ description: "Output format: text (default) or json",
46
+ default: "text",
47
+ },
48
+ },
49
+ async run({ args }) {
50
+ const isJson = args.format === "json";
124
51
 
125
- return requiredPlugins.map((p) => ({
126
- name: p.name,
127
- ok: installedIds.has(p.id),
128
- installHint: installedIds.has(p.id) ? undefined : `Run: ${p.installCmd}`,
129
- }));
130
- } catch {
131
- consola.debug("Failed to list Claude plugins");
132
- return requiredPlugins.map((p) => ({
133
- name: p.name,
134
- ok: false,
135
- installHint: `Run: ${p.installCmd}`,
136
- }));
137
- }
138
- }
52
+ if (!isJson) {
53
+ consola.info("Checking dependencies...\n");
54
+ }
139
55
 
140
- export default defineCommand({
141
- meta: { name: "doctor", description: "Check system dependencies and environment" },
142
- args: { debug: debugArg },
143
- async run() {
144
- consola.info("Checking dependencies...\n");
56
+ const result = await runAllChecks();
145
57
 
146
- const checks: CheckResult[] = await Promise.all([
147
- checkCommand("git", ["--version"], /git version ([\d.]+)/),
148
- checkCommand("gh", ["--version"], /gh version ([\d.]+)/),
149
- checkCommand("node", ["--version"], /v?([\d.]+)/),
150
- checkCommand("bun", ["--version"], /([\d.]+)/),
151
- checkCommand("claude", ["--version"], /([\d.]+)/),
152
- checkCommand("tmux", ["-V"], /tmux ([\d.]+)/),
153
- checkCommand("ttyd", ["--version"], /ttyd version ([\d.]+)/, true),
154
- ]);
58
+ if (isJson) {
59
+ console.log(formatDoctorJson(result));
60
+ return;
61
+ }
155
62
 
156
- for (const check of checks) {
63
+ for (const check of result.tools) {
157
64
  const icon = check.ok
158
65
  ? colors.green("✓")
159
66
  : check.warning
@@ -167,14 +74,13 @@ export default defineCommand({
167
74
  }
168
75
 
169
76
  consola.log("");
170
- const ghAuth = await checkGhAuth();
171
- const authIcon = ghAuth.ok ? colors.green("") : colors.yellow("");
172
- consola.log(`${authIcon} gh auth: ${ghAuth.ok ? "authenticated" : "not authenticated"}`);
173
- if (!ghAuth.ok) {
77
+ const authIcon = result.ghAuth ? colors.green("✓") : colors.yellow("⚠");
78
+ consola.log(`${authIcon} gh auth: ${result.ghAuth ? "authenticated" : "not authenticated"}`);
79
+ if (!result.ghAuth) {
174
80
  consola.log(` ${colors.dim("Run: gh auth login")}`);
175
81
  }
176
82
 
177
- const nodeCheck = checks.find((c) => c.name === "node");
83
+ const nodeCheck = result.tools.find((c) => c.name === "node");
178
84
  if (nodeCheck?.version) {
179
85
  const major = Number.parseInt(nodeCheck.version.split(".")[0], 10);
180
86
  if (major < 18) {
@@ -210,8 +116,8 @@ export default defineCommand({
210
116
  }
211
117
 
212
118
  const allOk =
213
- checks.every((c) => c.ok || !!c.warning) &&
214
- ghAuth.ok &&
119
+ result.tools.every((c) => c.ok || !!c.warning) &&
120
+ result.ghAuth &&
215
121
  pluginChecks.every((c) => c.ok) &&
216
122
  agentboardChecks.every((c) => c.ok || !!c.warning);
217
123
  consola.log("");
@@ -220,5 +126,28 @@ export default defineCommand({
220
126
  } else {
221
127
  consola.log(colors.yellow("Some checks failed. See above for details."));
222
128
  }
129
+
130
+ if (args.track) {
131
+ saveHistory(result);
132
+ consola.log(colors.dim("\nResults saved to history."));
133
+ }
134
+
135
+ if (args.diff) {
136
+ const history = loadHistory();
137
+ if (history.length === 0) {
138
+ consola.log(colors.yellow("\nNo previous runs tracked. Use --track to save a run first."));
139
+ } else {
140
+ const previous = history[history.length - 1];
141
+ const diffs = diffRuns(previous, result);
142
+ consola.log(colors.bold(`\nChanges since last tracked run (${previous.timestamp}):`));
143
+ if (diffs.length === 0) {
144
+ consola.log(colors.dim(" No changes detected."));
145
+ } else {
146
+ for (const entry of diffs) {
147
+ consola.log(` ${formatDiffEntry(entry)}`);
148
+ }
149
+ }
150
+ }
151
+ }
223
152
  },
224
153
  });
@@ -1,7 +1,7 @@
1
1
  import { defineCommand } from "citty";
2
2
  import { colors } from "consola/utils";
3
3
  import consola from "consola";
4
- import { x } from "tinyexec";
4
+ import { run } from "@towles/shared";
5
5
 
6
6
  import { debugArg } from "../shared.js";
7
7
 
@@ -34,11 +34,11 @@ export default defineCommand({
34
34
  const baseBranch = args.base;
35
35
 
36
36
  // Get current branch
37
- const currentResult = await x("git", ["branch", "--show-current"]);
37
+ const currentResult = await run("git", ["branch", "--show-current"]);
38
38
  const currentBranch = currentResult.stdout.trim();
39
39
 
40
40
  // Get merged branches
41
- const mergedResult = await x("git", ["branch", "--merged", baseBranch]);
41
+ const mergedResult = await run("git", ["branch", "--merged", baseBranch]);
42
42
  const allMerged = mergedResult.stdout
43
43
  .split("\n")
44
44
  .map((b) => b.trim().replace(/^\* /, ""))
@@ -81,7 +81,7 @@ export default defineCommand({
81
81
 
82
82
  for (const branch of toDelete) {
83
83
  try {
84
- await x("git", ["branch", "-d", branch]);
84
+ await run("git", ["branch", "-d", branch]);
85
85
  consola.log(colors.green(`✓ Deleted ${branch}`));
86
86
  deleted++;
87
87
  } catch {
@@ -1,6 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import stripAnsi from "strip-ansi";
3
- import type { Issue } from "../../utils/git/gh-cli-wrapper";
2
+ import type { Issue } from "@towles/shared";
4
3
  import { buildIssueChoices, computeColumnLayout } from "./branch";
5
4
 
6
5
  const issues: Issue[] = [
@@ -92,13 +91,13 @@ describe("buildIssueChoices", () => {
92
91
 
93
92
  it("includes issue title text in description", () => {
94
93
  const choices = buildIssueChoices(issues, layout);
95
- const desc = stripAnsi(choices[0].description!);
94
+ const desc = Bun.stripANSI(choices[0].description!);
96
95
  expect(desc).toContain("Short bug");
97
96
  });
98
97
 
99
98
  it("includes label names in description", () => {
100
99
  const choices = buildIssueChoices(issues, layout);
101
- const desc = stripAnsi(choices[1].description!);
100
+ const desc = Bun.stripANSI(choices[1].description!);
102
101
  expect(desc).toContain("enhancement");
103
102
  expect(desc).toContain("priority");
104
103
  });
@@ -106,7 +105,7 @@ describe("buildIssueChoices", () => {
106
105
  it("handles issues with no labels", () => {
107
106
  const choices = buildIssueChoices(issues, layout);
108
107
  // Issue #7 has no labels — description should still contain the title
109
- const desc = stripAnsi(choices[2].description!);
108
+ const desc = Bun.stripANSI(choices[2].description!);
110
109
  expect(desc).toContain("Docs update");
111
110
  });
112
111
 
@@ -6,11 +6,16 @@ import { colors } from "consola/utils";
6
6
  import { Fzf } from "fzf";
7
7
 
8
8
  import { debugArg } from "../shared.js";
9
- import type { Issue } from "../../utils/git/gh-cli-wrapper.js";
10
- import { getIssues, isGithubCliInstalled } from "../../utils/git/gh-cli-wrapper.js";
11
- import { git } from "../../utils/git/exec.js";
12
- import { createBranchNameFromIssue } from "../../utils/git/branch-name.js";
13
- import { getTerminalColumns, limitText, printWithHexColor } from "../../utils/render.js";
9
+ import type { Issue } from "@towles/shared";
10
+ import {
11
+ createBranchNameFromIssue,
12
+ getIssues,
13
+ getTerminalColumns,
14
+ git,
15
+ isGithubCliInstalled,
16
+ limitText,
17
+ printWithHexColor,
18
+ } from "@towles/shared";
14
19
 
15
20
  export interface ColumnLayout {
16
21
  longestNumber: number;