@towles/tool 0.0.95 → 0.0.103

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.
@@ -1,9 +1,10 @@
1
1
  import { describe, expect, it, vi } from "vitest";
2
+ import type { Mock } from "vitest";
2
3
 
3
4
  import type { ReadFileFn } from "./parser";
4
5
  import { calculateCutoffMs, filterByDays, parseJsonl, quickTokenCount } from "./parser";
5
6
 
6
- const mockReadFileSync: ReadFileFn = vi.fn();
7
+ const mockReadFileSync = vi.fn() as Mock & ReadFileFn;
7
8
 
8
9
  // ── Pure functions (no mocking needed) ──
9
10
 
@@ -67,7 +68,7 @@ describe("filterByDays", () => {
67
68
 
68
69
  describe("parseJsonl", () => {
69
70
  it("parses valid JSONL lines", () => {
70
- vi.mocked(mockReadFileSync).mockReturnValue(
71
+ mockReadFileSync.mockReturnValue(
71
72
  '{"type":"user","sessionId":"s1","timestamp":"2025-01-01T00:00:00Z"}\n{"type":"assistant","sessionId":"s1","timestamp":"2025-01-01T00:01:00Z"}\n',
72
73
  );
73
74
  const entries = parseJsonl("/fake/path.jsonl", mockReadFileSync);
@@ -77,7 +78,7 @@ describe("parseJsonl", () => {
77
78
  });
78
79
 
79
80
  it("skips empty lines", () => {
80
- vi.mocked(mockReadFileSync).mockReturnValue(
81
+ mockReadFileSync.mockReturnValue(
81
82
  '{"type":"user","sessionId":"s1","timestamp":"t"}\n\n\n{"type":"assistant","sessionId":"s1","timestamp":"t"}\n',
82
83
  );
83
84
  const entries = parseJsonl("/fake/path.jsonl", mockReadFileSync);
@@ -85,7 +86,7 @@ describe("parseJsonl", () => {
85
86
  });
86
87
 
87
88
  it("skips invalid JSON lines", () => {
88
- vi.mocked(mockReadFileSync).mockReturnValue(
89
+ mockReadFileSync.mockReturnValue(
89
90
  '{"type":"user","sessionId":"s1","timestamp":"t"}\nnot-json\n{"type":"assistant","sessionId":"s1","timestamp":"t"}\n',
90
91
  );
91
92
  const entries = parseJsonl("/fake/path.jsonl", mockReadFileSync);
@@ -93,7 +94,7 @@ describe("parseJsonl", () => {
93
94
  });
94
95
 
95
96
  it("returns empty array for empty file", () => {
96
- vi.mocked(mockReadFileSync).mockReturnValue("");
97
+ mockReadFileSync.mockReturnValue("");
97
98
  const entries = parseJsonl("/fake/path.jsonl", mockReadFileSync);
98
99
  expect(entries).toHaveLength(0);
99
100
  });
@@ -109,7 +110,7 @@ describe("quickTokenCount", () => {
109
110
  message: { usage: { input_tokens: 200, output_tokens: 75 } },
110
111
  }),
111
112
  ].join("\n");
112
- vi.mocked(mockReadFileSync).mockReturnValue(lines);
113
+ mockReadFileSync.mockReturnValue(lines);
113
114
  expect(quickTokenCount("/fake/path.jsonl", mockReadFileSync)).toBe(425);
114
115
  });
115
116
 
@@ -118,12 +119,12 @@ describe("quickTokenCount", () => {
118
119
  JSON.stringify({ message: { content: "text" } }),
119
120
  JSON.stringify({ message: { usage: { input_tokens: 100, output_tokens: 50 } } }),
120
121
  ].join("\n");
121
- vi.mocked(mockReadFileSync).mockReturnValue(lines);
122
+ mockReadFileSync.mockReturnValue(lines);
122
123
  expect(quickTokenCount("/fake/path.jsonl", mockReadFileSync)).toBe(150);
123
124
  });
124
125
 
125
126
  it("returns 0 for unreadable files", () => {
126
- vi.mocked(mockReadFileSync).mockImplementation(() => {
127
+ mockReadFileSync.mockImplementation(() => {
127
128
  throw new Error("ENOENT");
128
129
  });
129
130
  expect(quickTokenCount("/missing/file.jsonl", mockReadFileSync)).toBe(0);
@@ -133,12 +134,12 @@ describe("quickTokenCount", () => {
133
134
  const lines = JSON.stringify({
134
135
  message: { usage: { input_tokens: 100 } },
135
136
  });
136
- vi.mocked(mockReadFileSync).mockReturnValue(lines);
137
+ mockReadFileSync.mockReturnValue(lines);
137
138
  expect(quickTokenCount("/fake/path.jsonl", mockReadFileSync)).toBe(100);
138
139
  });
139
140
 
140
141
  it("skips invalid JSON lines gracefully", () => {
141
- vi.mocked(mockReadFileSync).mockReturnValue(
142
+ mockReadFileSync.mockReturnValue(
142
143
  '{"message":{"usage":{"input_tokens":50,"output_tokens":50}}}\nbadline\n',
143
144
  );
144
145
  expect(quickTokenCount("/fake/path.jsonl", mockReadFileSync)).toBe(100);
@@ -1,14 +1,15 @@
1
1
  import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import type { Mock } from "vitest";
2
3
 
3
4
  import type { XFn } from "./gh-cli-wrapper";
4
5
  import { getIssues, isGithubCliInstalled } from "./gh-cli-wrapper";
5
6
 
6
- const mockX: XFn = vi.fn().mockResolvedValue({ stdout: "[]", stderr: "", exitCode: 0 });
7
+ const mockX = vi.fn().mockResolvedValue({ stdout: "[]", stderr: "", exitCode: 0 }) as Mock & XFn;
7
8
 
8
9
  describe("gh-cli-wrapper", () => {
9
10
  beforeEach(() => {
10
11
  vi.clearAllMocks();
11
- vi.mocked(mockX).mockResolvedValue({ stdout: "[]", stderr: "", exitCode: 0 });
12
+ mockX.mockResolvedValue({ stdout: "[]", stderr: "", exitCode: 0 });
12
13
  });
13
14
 
14
15
  describe("getIssues", () => {
@@ -21,7 +22,7 @@ describe("gh-cli-wrapper", () => {
21
22
  it("does not pass --label flag when label not provided", async () => {
22
23
  await getIssues({ cwd: ".", exec: mockX });
23
24
 
24
- const args = vi.mocked(mockX).mock.calls[0]![1] as string[];
25
+ const args = mockX.mock.calls[0]![1] as string[];
25
26
  expect(args).not.toContain("--label");
26
27
  });
27
28
 
@@ -34,7 +35,7 @@ describe("gh-cli-wrapper", () => {
34
35
 
35
36
  describe("isGithubCliInstalled", () => {
36
37
  it("returns true when gh CLI outputs expected string", async () => {
37
- vi.mocked(mockX).mockResolvedValue({
38
+ mockX.mockResolvedValue({
38
39
  stdout: "gh version 2.0.0 (https://github.com/cli/cli)",
39
40
  stderr: "",
40
41
  exitCode: 0,
@@ -45,7 +46,7 @@ describe("gh-cli-wrapper", () => {
45
46
  });
46
47
 
47
48
  it("returns false when gh CLI is not available", async () => {
48
- vi.mocked(mockX).mockRejectedValue(new Error("command not found"));
49
+ mockX.mockRejectedValue(new Error("command not found"));
49
50
 
50
51
  const result = await isGithubCliInstalled(mockX);
51
52
  expect(result).toBe(false);
@@ -1,280 +0,0 @@
1
- import { Args } from "@oclif/core";
2
- import { execSync } from "node:child_process";
3
- import { readFileSync, writeFileSync, existsSync, realpathSync } from "node:fs";
4
- import { resolve } from "node:path";
5
- import consola from "consola";
6
- import { colors } from "consola/utils";
7
- import { BaseCommand } from "./base.js";
8
-
9
- const PLUGIN_DIR = resolve(import.meta.dirname, "../../plugins/tt-agentboard2");
10
-
11
- // Keybinding defaults
12
- const DEFAULT_KEY = "a";
13
- const TMUX_BINDINGS = { toggle: "t", focus: "s" } as const;
14
- const RUN_SHELL_LINE = `run-shell '${PLUGIN_DIR}/agentboard2.tmux'`;
15
- const MARKER = "# agentboard2";
16
-
17
- function findTmuxConf(): string | null {
18
- const candidates = [
19
- resolve(process.env.HOME ?? "~", ".tmux.conf"),
20
- resolve(process.env.HOME ?? "~", ".config/tmux/tmux.conf"),
21
- ];
22
- for (const path of candidates) {
23
- try {
24
- const real = existsSync(path) ? path : null;
25
- if (real) return real;
26
- } catch {
27
- continue;
28
- }
29
- }
30
- return null;
31
- }
32
-
33
- export default class Agentboard2 extends BaseCommand {
34
- static override aliases = ["ag2"];
35
- static override description = "AgentBoard2 — opensessions-style tmux TUI sidebar";
36
-
37
- static override examples = [
38
- {
39
- description: "Install agentboard2 into tmux",
40
- command: "<%= config.bin %> agentboard2 setup",
41
- },
42
- {
43
- description: "Uninstall from tmux",
44
- command: "<%= config.bin %> agentboard2 uninstall",
45
- },
46
- {
47
- description: "Launch the server",
48
- command: "<%= config.bin %> agentboard2 server",
49
- },
50
- {
51
- description: "Launch the TUI directly",
52
- command: "<%= config.bin %> agentboard2 tui",
53
- },
54
- ];
55
-
56
- static override args = {
57
- subcommand: Args.string({
58
- description: "Subcommand: setup, uninstall, server, tui, keys",
59
- required: false,
60
- options: ["setup", "uninstall", "server", "tui", "start", "keys"],
61
- }),
62
- };
63
-
64
- async run(): Promise<void> {
65
- const { args } = await this.parse(Agentboard2);
66
-
67
- switch (args.subcommand) {
68
- case "setup":
69
- this.setup();
70
- break;
71
- case "uninstall":
72
- this.uninstall();
73
- break;
74
- case "server":
75
- this.startServer();
76
- break;
77
- case "tui":
78
- this.startTui();
79
- break;
80
- case "start":
81
- // For backwards compat, start = tui
82
- this.startTui();
83
- break;
84
- case "keys":
85
- this.showKeys();
86
- break;
87
- default:
88
- this.showKeys();
89
- break;
90
- }
91
- }
92
-
93
- private ensureDeps(): void {
94
- // Check bun is installed
95
- try {
96
- execSync("bun --version", { stdio: "pipe" });
97
- } catch {
98
- this.error("bun is required but not found. Install: https://bun.sh");
99
- }
100
-
101
- // Install deps if needed for runtime package
102
- const runtimeNodeModules = resolve(PLUGIN_DIR, "packages/runtime/node_modules");
103
- if (!existsSync(runtimeNodeModules)) {
104
- consola.info("Installing agentboard2 dependencies...");
105
- execSync("pnpm install", { cwd: PLUGIN_DIR, stdio: "inherit" });
106
- }
107
- }
108
-
109
- private setup(): void {
110
- this.ensureDeps();
111
-
112
- // Find tmux.conf
113
- const confPath = findTmuxConf();
114
- if (!confPath) {
115
- consola.warn("No tmux.conf found. Add this line manually:");
116
- consola.info(colors.cyan(` ${RUN_SHELL_LINE}`));
117
- return;
118
- }
119
-
120
- // If it's a symlink, resolve to the real file for editing
121
- let editPath = confPath;
122
- try {
123
- editPath = realpathSync(confPath);
124
- } catch {
125
- // keep confPath
126
- }
127
-
128
- // Check if already installed
129
- const content = readFileSync(editPath, "utf8");
130
- if (content.includes("agentboard2.tmux")) {
131
- consola.success("Already installed in tmux.conf");
132
- this.reloadTmux();
133
- return;
134
- }
135
-
136
- // Add run-shell line before TPM init
137
- const tpmLine = "run '~/.config/tmux/plugins/tpm/tpm'";
138
- const altTpmLine = "run-shell '~/.tmux/plugins/tpm/tpm'";
139
- const insertLines = `\n${MARKER}\n${RUN_SHELL_LINE}\n`;
140
-
141
- let newContent: string;
142
- if (content.includes(tpmLine)) {
143
- newContent = content.replace(tpmLine, `${insertLines}\n${tpmLine}`);
144
- } else if (content.includes(altTpmLine)) {
145
- newContent = content.replace(altTpmLine, `${insertLines}\n${altTpmLine}`);
146
- } else {
147
- // No TPM found, append to end
148
- newContent = content + insertLines;
149
- }
150
-
151
- writeFileSync(editPath, newContent);
152
- consola.success(`Added agentboard2 to ${editPath}`);
153
-
154
- this.reloadTmux();
155
- this.showKeys();
156
- }
157
-
158
- private uninstall(): void {
159
- const confPath = findTmuxConf();
160
- if (!confPath) {
161
- consola.info("No tmux.conf found.");
162
- return;
163
- }
164
-
165
- let editPath = confPath;
166
- try {
167
- editPath = realpathSync(confPath);
168
- } catch {
169
- // keep confPath
170
- }
171
-
172
- const content = readFileSync(editPath, "utf8");
173
- if (!content.includes("agentboard2")) {
174
- consola.info("agentboard2 not found in tmux.conf");
175
- return;
176
- }
177
-
178
- // Remove the marker line and run-shell line
179
- const newContent = content
180
- .split("\n")
181
- .filter((line) => !line.includes("agentboard2"))
182
- .join("\n")
183
- .replace(/\n{3,}/g, "\n\n");
184
-
185
- writeFileSync(editPath, newContent);
186
- consola.success("Removed agentboard2 from tmux.conf");
187
- this.reloadTmux();
188
- }
189
-
190
- // Foreground command — blocks until server exits (Ctrl+C to stop)
191
- private startServer(): void {
192
- this.ensureDeps();
193
-
194
- const serverEntry = resolve(PLUGIN_DIR, "apps/server/src/main.ts");
195
- consola.info("Starting agentboard2 server (foreground, Ctrl+C to stop)...");
196
-
197
- execSync(`bun run ${serverEntry}`, {
198
- stdio: "inherit",
199
- cwd: PLUGIN_DIR,
200
- env: {
201
- ...process.env,
202
- AGENTBOARD2_DIR: PLUGIN_DIR,
203
- },
204
- });
205
- }
206
-
207
- // Foreground command — blocks until TUI exits
208
- private startTui(): void {
209
- this.ensureDeps();
210
-
211
- const tuiEntry = resolve(PLUGIN_DIR, "apps/tui/src/index.tsx");
212
-
213
- execSync(`bun run ${tuiEntry}`, {
214
- stdio: "inherit",
215
- cwd: resolve(PLUGIN_DIR, "apps/tui"),
216
- env: {
217
- ...process.env,
218
- AGENTBOARD2_DIR: PLUGIN_DIR,
219
- },
220
- });
221
- }
222
-
223
- private showKeys(): void {
224
- // Get tmux prefix and agentboard2 key from tmux
225
- let prefix = "C-a";
226
- let key = DEFAULT_KEY;
227
- try {
228
- prefix = execSync("tmux show-option -gv prefix", {
229
- encoding: "utf8",
230
- stdio: ["pipe", "pipe", "pipe"],
231
- }).trim();
232
- const ab2Key = execSync(
233
- `tmux show-option -gv @agentboard2-key 2>/dev/null || echo ${DEFAULT_KEY}`,
234
- {
235
- encoding: "utf8",
236
- stdio: ["pipe", "pipe", "pipe"],
237
- },
238
- ).trim();
239
- if (ab2Key) key = ab2Key;
240
- } catch {
241
- // use defaults
242
- }
243
-
244
- const { toggle, focus } = TMUX_BINDINGS;
245
- consola.box(
246
- [
247
- `${colors.bold("AgentBoard2 Keybindings")}\n`,
248
- `${colors.cyan(`tmux (prefix = ${prefix}, C = Ctrl):`)}`,
249
- ` ${prefix} ${key} ${toggle} toggle sidebar`,
250
- ` ${prefix} ${key} ${focus} focus sidebar`,
251
- ` ${prefix} ${key} 1-9 jump to session\n`,
252
- `${colors.cyan("In sidebar:")}`,
253
- ` Tab cycle sessions`,
254
- ` j / ↓ move down`,
255
- ` k / ↑ move up`,
256
- ` Enter / l switch to selected session`,
257
- ` 1-9 jump to session`,
258
- ` d hide session`,
259
- ` x kill session`,
260
- ` t theme picker`,
261
- ` r refresh`,
262
- ` q quit`,
263
- ].join("\n"),
264
- );
265
- }
266
-
267
- private reloadTmux(): void {
268
- try {
269
- execSync(
270
- "tmux source-file ~/.config/tmux/tmux.conf 2>/dev/null || tmux source-file ~/.tmux.conf 2>/dev/null",
271
- {
272
- stdio: "pipe",
273
- },
274
- );
275
- consola.success("tmux config reloaded");
276
- } catch {
277
- consola.info("Reload tmux manually: tmux source-file ~/.config/tmux/tmux.conf");
278
- }
279
- }
280
- }
@@ -1,32 +0,0 @@
1
- import { Command, Flags } from "@oclif/core";
2
- import type { SettingsFile } from "../config/settings.js";
3
- import { loadSettings } from "../config/settings.js";
4
-
5
- /**
6
- * Base command that all towles-tool commands extend.
7
- * Provides shared functionality like settings loading and debug flag.
8
- */
9
- export abstract class BaseCommand extends Command {
10
- static baseFlags = {
11
- debug: Flags.boolean({
12
- char: "d",
13
- description: "Enable debug output",
14
- default: false,
15
- }),
16
- };
17
-
18
- protected settingsFile!: SettingsFile;
19
-
20
- /** Shortcut to avoid `this.settingsFile.settings.X` stutter */
21
- protected get userSettings() {
22
- return this.settingsFile.settings;
23
- }
24
-
25
- /**
26
- * Called before run(). Loads user settings.
27
- */
28
- async init(): Promise<void> {
29
- await super.init();
30
- this.settingsFile = await loadSettings();
31
- }
32
- }