@towles/tool 0.0.94 → 0.0.96

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@towles/tool",
3
- "version": "0.0.94",
3
+ "version": "0.0.96",
4
4
  "description": "One off quality of life scripts that I use on a daily basis.",
5
5
  "homepage": "https://github.com/ChrisTowles/towles-tool#readme",
6
6
  "bugs": {
@@ -1,333 +1,280 @@
1
- import { Args, Flags } from "@oclif/core";
2
- import { execSync, spawn } from "node:child_process";
3
- import { existsSync } from "node:fs";
4
- import { resolve, join } from "node:path";
5
- import { networkInterfaces } from "node:os";
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";
6
5
  import consola from "consola";
7
6
  import { colors } from "consola/utils";
8
- import prompts from "prompts";
9
7
  import { BaseCommand } from "./base.js";
10
8
 
11
- function getLocalIp(): string {
12
- const nets = networkInterfaces();
13
- for (const ifaces of Object.values(nets)) {
14
- if (!ifaces) continue;
15
- for (const iface of ifaces) {
16
- if (iface.family === "IPv4" && !iface.internal) {
17
- return iface.address;
18
- }
9
+ const PLUGIN_DIR = resolve(import.meta.dirname, "../../plugins/tt-agentboard");
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}/agentboard.tmux'`;
15
+ const MARKER = "# agentboard";
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;
19
28
  }
20
29
  }
21
- return "localhost";
30
+ return null;
22
31
  }
23
32
 
24
- export default class Agentboard extends BaseCommand {
25
- static override aliases = ["ag"];
26
- static override description = "Start AgentBoard agentic workflow orchestration IDE";
33
+ export default class Agentboard2 extends BaseCommand {
34
+ static override aliases = ["ag2"];
35
+ static override description = "AgentBoard2opensessions-style tmux TUI sidebar";
27
36
 
28
37
  static override examples = [
29
38
  {
30
- description: "Start AgentBoard on default port",
31
- command: "<%= config.bin %> agentboard",
32
- },
33
- {
34
- description: "Start on a custom port",
35
- command: "<%= config.bin %> ag --port 3000",
39
+ description: "Install agentboard into tmux",
40
+ command: "<%= config.bin %> agentboard setup",
36
41
  },
37
42
  {
38
- description: "Start without opening browser",
39
- command: "<%= config.bin %> ag --no-open",
43
+ description: "Uninstall from tmux",
44
+ command: "<%= config.bin %> agentboard uninstall",
40
45
  },
41
46
  {
42
- description: "Attach to a running card tmux session",
43
- command: "<%= config.bin %> ag attach 42",
47
+ description: "Launch the server",
48
+ command: "<%= config.bin %> agentboard server",
44
49
  },
45
50
  {
46
- description: "Selectively clear database (interactive)",
47
- command: "<%= config.bin %> ag reset",
48
- },
49
- {
50
- description: "Delete entire database without prompting",
51
- command: "<%= config.bin %> ag reset --all",
51
+ description: "Launch the TUI directly",
52
+ command: "<%= config.bin %> agentboard tui",
52
53
  },
53
54
  ];
54
55
 
55
- static override flags = {
56
- port: Flags.string({
57
- char: "p",
58
- description: "Port to serve on",
59
- default: "4200",
60
- }),
61
- open: Flags.boolean({
62
- description: "Open browser after starting",
63
- default: true,
64
- allowNo: true,
65
- }),
66
- "data-dir": Flags.string({
67
- char: "d",
68
- description: "Directory for AgentBoard data (SQLite DB, artifacts)",
69
- env: "AGENTBOARD_DATA_DIR",
70
- }),
71
- lan: Flags.boolean({
72
- description: "Listen on all interfaces (0.0.0.0) for LAN access. Default: localhost only.",
73
- default: false,
74
- }),
75
- all: Flags.boolean({
76
- description: "Reset entire database without prompting (for tt ag reset --all)",
77
- default: false,
78
- }),
79
- };
80
-
81
56
  static override args = {
82
57
  subcommand: Args.string({
83
- description: "Subcommand (attach, reset)",
84
- required: false,
85
- }),
86
- cardId: Args.string({
87
- description: "Card ID for attach subcommand",
58
+ description: "Subcommand: setup, uninstall, server, tui, keys",
88
59
  required: false,
60
+ options: ["setup", "uninstall", "server", "tui", "start", "keys"],
89
61
  }),
90
62
  };
91
63
 
92
64
  async run(): Promise<void> {
93
- const { args, flags } = await this.parse(Agentboard);
94
-
95
- if (args.subcommand === "attach") {
96
- if (!args.cardId) {
97
- this.error("Card ID is required for attach subcommand");
98
- }
99
- execSync(`tmux attach-session -t card-${args.cardId}`, {
100
- stdio: "inherit",
101
- });
102
- return;
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;
103
90
  }
91
+ }
104
92
 
105
- if (args.subcommand === "reset") {
106
- const defaultDataDir = resolve(
107
- process.env.XDG_CONFIG_HOME ?? resolve(process.env.HOME ?? "~", ".config"),
108
- "towles-tool",
109
- "agentboard",
110
- );
111
- const dataDir = flags["data-dir"] ? resolve(flags["data-dir"]) : defaultDataDir;
112
- const dbPath = join(dataDir, "agentboard.db");
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
+ }
113
100
 
114
- if (!existsSync(dbPath)) {
115
- consola.info("No database found nothing to reset.");
116
- return;
117
- }
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 agentboard dependencies...");
105
+ execSync("pnpm install", { cwd: PLUGIN_DIR, stdio: "inherit" });
106
+ }
107
+ }
118
108
 
119
- if (flags.all) {
120
- await this.resetEntireDatabase(dbPath);
121
- return;
122
- }
109
+ private setup(): void {
110
+ this.ensureDeps();
123
111
 
124
- await this.selectiveClear(dbPath);
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}`));
125
117
  return;
126
118
  }
127
119
 
128
- const agentboardDir = resolve(import.meta.dirname, "../../plugins/tt-agentboard");
129
- const port = flags.port;
130
- const defaultDataDir = resolve(
131
- process.env.XDG_CONFIG_HOME ?? resolve(process.env.HOME ?? "~", ".config"),
132
- "towles-tool",
133
- "agentboard",
134
- );
135
- const dataDir = flags["data-dir"] ? resolve(flags["data-dir"]) : defaultDataDir;
136
- const localIp = getLocalIp();
137
- const dbPath = join(dataDir, "agentboard.db");
138
- const isFirstRun = !existsSync(dbPath);
139
-
140
- const lanMode = flags.lan;
141
- const host = lanMode ? "0.0.0.0" : "127.0.0.1";
142
-
143
- const lines = [`AgentBoard\n\n Local: http://localhost:${port}`];
144
- if (lanMode) {
145
- lines.push(` Network: http://${localIp}:${port}`);
146
- } else {
147
- lines.push(` Network: disabled (use --lan to enable)`);
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
148
126
  }
149
- lines.push(` Data: ${dataDir}`);
150
- consola.box(lines.join("\n"));
151
-
152
- if (isFirstRun) {
153
- consola.info("First run detected — a new database will be created at startup.");
154
- consola.info(
155
- "Setup checklist:\n" +
156
- " 1. Ensure tmux is installed (sudo apt install tmux / brew install tmux)\n" +
157
- " 2. Set GITHUB_TOKEN for GitHub features (optional)\n" +
158
- " 3. Open the board → Workspaces → Add a workspace slot\n" +
159
- " 4. Create your first card and drag it to In Progress",
160
- );
127
+
128
+ // Check if already installed
129
+ const content = readFileSync(editPath, "utf8");
130
+ if (content.includes("agentboard.tmux")) {
131
+ consola.success("Already installed in tmux.conf");
132
+ this.reloadTmux();
133
+ return;
161
134
  }
162
135
 
163
- const proc = spawn("pnpm", ["dev", "--port", port], {
164
- cwd: agentboardDir,
165
- stdio: "inherit",
166
- env: {
167
- ...process.env,
168
- NUXT_DEV_HOST: host,
169
- AGENTBOARD_DATA_DIR: dataDir,
170
- AGENTBOARD_LAN: lanMode ? "1" : "0",
171
- },
172
- });
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`;
173
140
 
174
- if (flags.open) {
175
- setTimeout(() => {
176
- try {
177
- execSync(`xdg-open http://localhost:${port}`, { stdio: "ignore" });
178
- } catch {
179
- consola.debug("Could not open browser automatically");
180
- }
181
- }, 2000);
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;
182
149
  }
183
150
 
184
- proc.on("exit", (code) => process.exit(code ?? 0));
185
- }
151
+ writeFileSync(editPath, newContent);
152
+ consola.success(`Added agentboard to ${editPath}`);
186
153
 
187
- private async resetEntireDatabase(dbPath: string): Promise<void> {
188
- const walPath = `${dbPath}-wal`;
189
- const shmPath = `${dbPath}-shm`;
190
- consola.warn(`This will delete: ${dbPath}`);
191
- const { unlinkSync } = await import("node:fs");
192
- for (const f of [dbPath, walPath, shmPath]) {
193
- if (existsSync(f)) {
194
- unlinkSync(f);
195
- }
196
- }
197
- consola.success("Database reset. Start AgentBoard to create a fresh DB.");
154
+ this.reloadTmux();
155
+ this.showKeys();
198
156
  }
199
157
 
200
- private async selectiveClear(dbPath: string): Promise<void> {
201
- const { createRequire } = await import("node:module");
202
- const require = createRequire(import.meta.url);
203
- const Database = require("better-sqlite3");
204
- const sqlite = new Database(dbPath) as {
205
- pragma(stmt: string): void;
206
- prepare(sql: string): {
207
- get(): unknown;
208
- run(): { changes: number };
209
- };
210
- close(): void;
211
- };
212
- sqlite.pragma("foreign_keys = ON");
213
-
214
- const counts = {
215
- doneCards: sqlite.prepare("SELECT COUNT(*) as c FROM cards WHERE column = 'done'").get() as {
216
- c: number;
217
- },
218
- failedCards: sqlite
219
- .prepare("SELECT COUNT(*) as c FROM cards WHERE status = 'failed'")
220
- .get() as { c: number },
221
- allCards: sqlite.prepare("SELECT COUNT(*) as c FROM cards").get() as { c: number },
222
- workflowRuns: sqlite.prepare("SELECT COUNT(*) as c FROM workflow_runs").get() as {
223
- c: number;
224
- },
225
- cardEvents: sqlite.prepare("SELECT COUNT(*) as c FROM card_events").get() as { c: number },
226
- agentLogs: sqlite.prepare("SELECT COUNT(*) as c FROM agent_logs").get() as { c: number },
227
- };
228
-
229
- const choices = [
230
- {
231
- title: `Completed cards ${colors.dim(`(${counts.doneCards.c} cards in "done" column + events/runs)`)}`,
232
- value: "done_cards",
233
- disabled: counts.doneCards.c === 0,
234
- },
235
- {
236
- title: `Failed cards ${colors.dim(`(${counts.failedCards.c} cards with "failed" status + events/runs)`)}`,
237
- value: "failed_cards",
238
- disabled: counts.failedCards.c === 0,
239
- },
240
- {
241
- title: `Execution history ${colors.dim(`(${counts.workflowRuns.c} workflow runs, step runs, ${counts.agentLogs.c} agent logs)`)}`,
242
- value: "execution_history",
243
- disabled: counts.workflowRuns.c === 0,
244
- },
245
- {
246
- title: `Event logs ${colors.dim(`(${counts.cardEvents.c} card events)`)}`,
247
- value: "event_logs",
248
- disabled: counts.cardEvents.c === 0,
249
- },
250
- {
251
- title: `All cards ${colors.dim(`(${counts.allCards.c} cards — keeps repos, boards, slots)`)}`,
252
- value: "all_cards",
253
- disabled: counts.allCards.c === 0,
254
- },
255
- {
256
- title: colors.red(`Everything (delete entire database)`),
257
- value: "everything",
258
- },
259
- ];
260
-
261
- const result = await prompts(
262
- {
263
- name: "selected",
264
- message: "What would you like to clear?",
265
- type: "multiselect",
266
- choices,
267
- instructions: false,
268
- hint: "- Space to select, Enter to confirm",
269
- },
270
- {
271
- onCancel: () => {
272
- consola.info(colors.dim("Canceled"));
273
- process.exit(0);
274
- },
275
- },
276
- );
277
-
278
- const selected: string[] = result.selected;
279
- if (!selected || selected.length === 0) {
280
- consola.info("Nothing selected.");
281
- sqlite.close();
158
+ private uninstall(): void {
159
+ const confPath = findTmuxConf();
160
+ if (!confPath) {
161
+ consola.info("No tmux.conf found.");
282
162
  return;
283
163
  }
284
164
 
285
- if (selected.includes("everything")) {
286
- sqlite.close();
287
- await this.resetEntireDatabase(dbPath);
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("agentboard")) {
174
+ consola.info("agentboard not found in tmux.conf");
288
175
  return;
289
176
  }
290
177
 
291
- let totalDeleted = 0;
178
+ // Remove the marker line and run-shell line
179
+ const newContent = content
180
+ .split("\n")
181
+ .filter((line) => !line.includes("agentboard"))
182
+ .join("\n")
183
+ .replace(/\n{3,}/g, "\n\n");
292
184
 
293
- if (selected.includes("done_cards")) {
294
- // Cascade deletes handle events, dependencies, workflow_runs, step_runs, agent_logs
295
- const deleted = sqlite.prepare("DELETE FROM cards WHERE column = 'done'").run();
296
- consola.success(`Cleared ${deleted.changes} completed card(s)`);
297
- totalDeleted += deleted.changes;
298
- }
185
+ writeFileSync(editPath, newContent);
186
+ consola.success("Removed agentboard from tmux.conf");
187
+ this.reloadTmux();
188
+ }
299
189
 
300
- if (selected.includes("failed_cards")) {
301
- const deleted = sqlite.prepare("DELETE FROM cards WHERE status = 'failed'").run();
302
- consola.success(`Cleared ${deleted.changes} failed card(s)`);
303
- totalDeleted += deleted.changes;
304
- }
190
+ // Foreground command — blocks until server exits (Ctrl+C to stop)
191
+ private startServer(): void {
192
+ this.ensureDeps();
305
193
 
306
- if (selected.includes("execution_history")) {
307
- // agent_logs and step_runs cascade from workflow_runs
308
- const deleted = sqlite.prepare("DELETE FROM workflow_runs").run();
309
- consola.success(`Cleared ${deleted.changes} workflow run(s) and associated logs`);
310
- totalDeleted += deleted.changes;
311
- }
194
+ const serverEntry = resolve(PLUGIN_DIR, "apps/server/src/main.ts");
195
+ consola.info("Starting agentboard server (foreground, Ctrl+C to stop)...");
312
196
 
313
- if (selected.includes("event_logs")) {
314
- const deleted = sqlite.prepare("DELETE FROM card_events").run();
315
- consola.success(`Cleared ${deleted.changes} event log(s)`);
316
- totalDeleted += deleted.changes;
317
- }
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();
318
210
 
319
- if (selected.includes("all_cards")) {
320
- const deleted = sqlite.prepare("DELETE FROM cards").run();
321
- consola.success(`Cleared ${deleted.changes} card(s)`);
322
- totalDeleted += deleted.changes;
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 agentboard 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 @agentboard-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
323
242
  }
324
243
 
325
- sqlite.close();
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
+ }
326
266
 
327
- if (totalDeleted === 0) {
328
- consola.info("Nothing to clear.");
329
- } else {
330
- consola.success("Done.");
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");
331
278
  }
332
279
  }
333
280
  }