@towles/tool 0.0.62 → 0.0.63

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 (83) hide show
  1. package/package.json +50 -57
  2. package/src/commands/agentboard.ts +176 -0
  3. package/src/commands/{auto-claude.ts → auto-claude/index.ts} +18 -28
  4. package/src/commands/auto-claude/list.ts +114 -0
  5. package/src/commands/auto-claude/retry.test.ts +138 -0
  6. package/src/commands/auto-claude/retry.ts +139 -0
  7. package/src/commands/auto-claude/status.test.ts +147 -0
  8. package/src/commands/auto-claude/status.ts +123 -0
  9. package/src/commands/base.ts +7 -2
  10. package/src/commands/config.ts +5 -7
  11. package/src/commands/doctor.ts +111 -12
  12. package/src/commands/gh/branch.ts +4 -4
  13. package/src/commands/gh/pr.ts +1 -0
  14. package/src/commands/graph/index.ts +169 -0
  15. package/src/commands/graph.test.ts +1 -1
  16. package/src/commands/install.ts +40 -68
  17. package/src/commands/journal/daily-notes.ts +3 -3
  18. package/src/commands/journal/meeting.ts +3 -3
  19. package/src/commands/journal/note.ts +3 -3
  20. package/src/lib/auto-claude/claude-cli.ts +183 -0
  21. package/src/lib/auto-claude/config.test.ts +6 -8
  22. package/src/lib/auto-claude/config.ts +3 -4
  23. package/src/lib/auto-claude/index.ts +2 -3
  24. package/src/lib/auto-claude/labels.test.ts +85 -0
  25. package/src/lib/auto-claude/labels.ts +42 -0
  26. package/src/lib/auto-claude/pipeline-execution.test.ts +129 -33
  27. package/src/lib/auto-claude/pipeline.test.ts +2 -2
  28. package/src/lib/auto-claude/pipeline.ts +120 -36
  29. package/src/lib/auto-claude/prompt-templates/01_plan.prompt.md +68 -0
  30. package/src/lib/auto-claude/prompt-templates/{05_implement.prompt.md → 02_implement.prompt.md} +3 -2
  31. package/src/lib/auto-claude/prompt-templates/03_simplify.prompt.md +52 -0
  32. package/src/lib/auto-claude/prompt-templates/{06_review.prompt.md → 04_review.prompt.md} +29 -6
  33. package/src/lib/auto-claude/prompt-templates/index.test.ts +9 -42
  34. package/src/lib/auto-claude/prompt-templates/index.ts +13 -28
  35. package/src/lib/auto-claude/run-claude.test.ts +48 -68
  36. package/src/lib/auto-claude/shell.ts +6 -0
  37. package/src/lib/auto-claude/steps/create-pr.ts +89 -25
  38. package/src/lib/auto-claude/steps/fetch-issues.ts +4 -1
  39. package/src/lib/auto-claude/steps/implement.ts +9 -16
  40. package/src/lib/auto-claude/steps/simple-steps.ts +34 -0
  41. package/src/lib/auto-claude/steps/steps.test.ts +68 -63
  42. package/src/lib/auto-claude/templates.test.ts +91 -0
  43. package/src/lib/auto-claude/templates.ts +34 -0
  44. package/src/lib/auto-claude/test-helpers.ts +2 -1
  45. package/src/lib/auto-claude/utils-execution.test.ts +9 -57
  46. package/src/lib/auto-claude/utils.test.ts +5 -9
  47. package/src/lib/auto-claude/utils.ts +27 -253
  48. package/src/lib/graph/analyzer.test.ts +451 -0
  49. package/src/lib/graph/analyzer.ts +165 -0
  50. package/src/lib/graph/index.ts +24 -0
  51. package/src/lib/graph/labels.ts +87 -0
  52. package/src/lib/graph/parser.test.ts +150 -0
  53. package/src/lib/graph/parser.ts +65 -0
  54. package/src/lib/graph/render.ts +25 -0
  55. package/src/lib/graph/server.ts +70 -0
  56. package/src/lib/graph/sessions.ts +104 -0
  57. package/src/lib/graph/tools.ts +90 -0
  58. package/src/lib/graph/treemap.ts +211 -0
  59. package/src/lib/graph/types.ts +80 -0
  60. package/src/lib/install/claude-settings.ts +64 -0
  61. package/src/lib/journal/editor.ts +33 -0
  62. package/src/lib/journal/fs.ts +13 -0
  63. package/src/lib/journal/index.ts +11 -0
  64. package/src/lib/journal/paths.ts +106 -0
  65. package/src/lib/journal/{utils.ts → templates.ts} +3 -151
  66. package/src/utils/fs.ts +19 -0
  67. package/src/utils/git/exec.ts +18 -0
  68. package/src/utils/git/gh-cli-wrapper.test.ts +47 -8
  69. package/src/utils/git/gh-cli-wrapper.ts +31 -19
  70. package/src/utils/render.ts +3 -1
  71. package/src/commands/graph.ts +0 -970
  72. package/src/lib/auto-claude/prompt-templates/01_research.prompt.md +0 -21
  73. package/src/lib/auto-claude/prompt-templates/02_plan.prompt.md +0 -27
  74. package/src/lib/auto-claude/prompt-templates/03_plan-annotations.prompt.md +0 -15
  75. package/src/lib/auto-claude/prompt-templates/04_plan-implementation.prompt.md +0 -35
  76. package/src/lib/auto-claude/prompt-templates/07_refresh.prompt.md +0 -30
  77. package/src/lib/auto-claude/steps/plan-annotations.ts +0 -54
  78. package/src/lib/auto-claude/steps/plan-implementation.ts +0 -14
  79. package/src/lib/auto-claude/steps/plan.ts +0 -14
  80. package/src/lib/auto-claude/steps/refresh.ts +0 -114
  81. package/src/lib/auto-claude/steps/remove-label.ts +0 -22
  82. package/src/lib/auto-claude/steps/research.ts +0 -21
  83. package/src/lib/auto-claude/steps/review.ts +0 -14
package/package.json CHANGED
@@ -1,16 +1,7 @@
1
1
  {
2
2
  "name": "@towles/tool",
3
- "version": "0.0.62",
4
- "description": "CLI tool with auto-claude pipeline, developer tools, and journaling via markdown.",
5
- "keywords": [
6
- "auto-claude",
7
- "autonomic",
8
- "claude",
9
- "cli",
10
- "git",
11
- "journal",
12
- "oclif"
13
- ],
3
+ "version": "0.0.63",
4
+ "description": "One off quality of life scripts that I use on a daily basis.",
14
5
  "homepage": "https://github.com/ChrisTowles/towles-tool#readme",
15
6
  "bugs": {
16
7
  "url": "https://github.com/ChrisTowles/towles-tool/issues"
@@ -34,79 +25,81 @@
34
25
  "src"
35
26
  ],
36
27
  "type": "module",
37
- "main": "bin/run.ts",
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
38
31
  "scripts": {
39
- "start": "tsx ./bin/run.ts",
40
- "typecheck": "tsc --noEmit",
32
+ "version:sync": "pnpm tsx scripts/sync-versions.ts",
33
+ "prepublishOnly": "pnpm run version:sync",
34
+ "dev": "tsx bin/run.ts",
35
+ "format": "oxfmt --write .",
36
+ "format:check": "oxfmt --check .",
41
37
  "lint": "oxlint",
42
38
  "lint:fix": "oxlint --fix",
43
- "format": "oxfmt --write",
44
- "format:check": "oxfmt --check",
45
39
  "test": "vitest run",
46
- "test:watch": "vitest watch",
47
- "prepare": "simple-git-hooks",
48
- "version:sync": "tsx scripts/sync-versions.ts"
40
+ "test:prompts": "promptfoo eval && promptfoo eval -c plugins/tt-core/promptfooconfig.yaml && promptfoo eval -c plugins/tt-auto-claude/promptfooconfig.yaml",
41
+ "test:prompts:root": "promptfoo eval",
42
+ "test:prompts:tt-core": "promptfoo eval -c plugins/tt-core/promptfooconfig.yaml",
43
+ "test:prompts:tt-core:llm": "promptfoo eval -c plugins/tt-core/promptfooconfig.llm.yaml",
44
+ "test:prompts:tt-auto-claude": "promptfoo eval -c plugins/tt-auto-claude/promptfooconfig.yaml",
45
+ "test:watch": "CI=DisableCallingClaude vitest watch",
46
+ "typecheck": "tsgo --noEmit --incremental",
47
+ "prepare": "simple-git-hooks"
49
48
  },
50
49
  "dependencies": {
51
- "@oclif/core": "^4.3.16",
50
+ "@anthropic-ai/claude-code": "^2.1.4",
51
+ "@anthropic-ai/sdk": "^0.56.0",
52
+ "@oclif/core": "^4.3.2",
52
53
  "consola": "^3.4.2",
53
54
  "d3-hierarchy": "^3.1.2",
54
55
  "fzf": "^0.5.2",
55
- "globby": "^14.1.0",
56
- "luxon": "^3.5.0",
57
- "open": "^10.1.1",
56
+ "luxon": "^3.7.1",
57
+ "neverthrow": "^8.2.0",
58
58
  "picocolors": "^1.1.1",
59
59
  "prompts": "^2.4.2",
60
- "strip-ansi": "^7.1.2",
61
- "tinyexec": "^1.0.2",
62
- "zod": "^3.25.67"
60
+ "strip-ansi": "^7.1.0",
61
+ "tinyexec": "^0.3.2",
62
+ "zod": "^4.0.5"
63
63
  },
64
64
  "devDependencies": {
65
- "@oclif/test": "^4.1.13",
66
- "@total-typescript/tsconfig": "^1.0.4",
67
- "@tsconfig/strictest": "^2.0.5",
65
+ "@oclif/test": "^4.1.10",
68
66
  "@types/d3-hierarchy": "^3.1.7",
69
- "@types/luxon": "^3.4.2",
70
- "@types/node": "^22.10.10",
67
+ "@types/luxon": "^3.6.2",
68
+ "@types/node": "^22.16.3",
71
69
  "@types/prompts": "^2.4.9",
70
+ "@typescript/native-preview": "^7.0.0-dev.20260111.1",
72
71
  "bumpp": "^10.4.0",
73
- "lint-staged": "^15.5.1",
74
72
  "oxfmt": "^0.24.0",
75
- "oxlint": "^1.2.0",
76
- "simple-git-hooks": "^2.11.1",
77
- "tsx": "^4.19.2",
73
+ "oxlint": "^1.7.0",
74
+ "promptfoo": "^0.121.2",
75
+ "simple-git-hooks": "^2.13.0",
76
+ "tsx": "^4.19.4",
78
77
  "typescript": "^5.8.3",
79
- "vitest": "^3.1.3"
78
+ "vitest": "^4.0.17"
80
79
  },
81
80
  "simple-git-hooks": {
82
- "pre-commit": "pnpm lint-staged && pnpm format && pnpm typecheck"
83
- },
84
- "lint-staged": {
85
- "package.json": "oxfmt --write",
86
- "*.{ts,tsx,mts,cts,js,cjs,mjs}": [
87
- "oxlint --fix"
88
- ],
89
- "*.*": [
90
- "oxfmt --write"
91
- ]
81
+ "pre-commit": "pnpm format && pnpm lint:fix && pnpm typecheck && claude plugin validate ."
92
82
  },
93
83
  "oclif": {
94
84
  "bin": "tt",
95
- "commands": {
96
- "strategy": "pattern",
97
- "target": "./src/commands"
98
- },
85
+ "commands": "./src/commands",
99
86
  "dirname": "towles-tool",
100
- "plugins": [],
101
87
  "topicSeparator": " "
102
88
  },
103
- "engines": {
104
- "node": ">=18.0.0"
105
- },
106
- "packageManager": "pnpm@10.11.0",
89
+ "packageManager": "pnpm@10.27.0",
107
90
  "pnpm": {
108
91
  "patchedDependencies": {
109
92
  "prompts@2.4.2": "patches/prompts.patch"
110
- }
111
- }
93
+ },
94
+ "onlyBuiltDependencies": [
95
+ "@anthropic-ai/claude-code",
96
+ "better-sqlite3",
97
+ "esbuild",
98
+ "simple-git-hooks",
99
+ "@parcel/watcher"
100
+ ]
101
+ },
102
+ "trustedDependencies": [
103
+ "@anthropic-ai/claude-code"
104
+ ]
112
105
  }
@@ -0,0 +1,176 @@
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";
6
+ import consola from "consola";
7
+ import { BaseCommand } from "./base.js";
8
+
9
+ function getLocalIp(): string {
10
+ const nets = networkInterfaces();
11
+ for (const ifaces of Object.values(nets)) {
12
+ if (!ifaces) continue;
13
+ for (const iface of ifaces) {
14
+ if (iface.family === "IPv4" && !iface.internal) {
15
+ return iface.address;
16
+ }
17
+ }
18
+ }
19
+ return "localhost";
20
+ }
21
+
22
+ export default class Agentboard extends BaseCommand {
23
+ static override aliases = ["ag"];
24
+ static override description = "Start AgentBoard — agentic workflow orchestration IDE";
25
+
26
+ static override examples = [
27
+ {
28
+ description: "Start AgentBoard on default port",
29
+ command: "<%= config.bin %> agentboard",
30
+ },
31
+ {
32
+ description: "Start on a custom port",
33
+ command: "<%= config.bin %> ag --port 3000",
34
+ },
35
+ {
36
+ description: "Start without opening browser",
37
+ command: "<%= config.bin %> ag --no-open",
38
+ },
39
+ {
40
+ description: "Attach to a running card tmux session",
41
+ command: "<%= config.bin %> ag attach 42",
42
+ },
43
+ ];
44
+
45
+ static override flags = {
46
+ port: Flags.string({
47
+ char: "p",
48
+ description: "Port to serve on",
49
+ default: "4200",
50
+ }),
51
+ open: Flags.boolean({
52
+ description: "Open browser after starting",
53
+ default: true,
54
+ allowNo: true,
55
+ }),
56
+ "data-dir": Flags.string({
57
+ char: "d",
58
+ description: "Directory for AgentBoard data (SQLite DB, artifacts)",
59
+ env: "AGENTBOARD_DATA_DIR",
60
+ }),
61
+ lan: Flags.boolean({
62
+ description: "Listen on all interfaces (0.0.0.0) for LAN access. Default: localhost only.",
63
+ default: false,
64
+ }),
65
+ };
66
+
67
+ static override args = {
68
+ subcommand: Args.string({
69
+ description: "Subcommand (attach, reset)",
70
+ required: false,
71
+ }),
72
+ cardId: Args.string({
73
+ description: "Card ID for attach subcommand",
74
+ required: false,
75
+ }),
76
+ };
77
+
78
+ async run(): Promise<void> {
79
+ const { args, flags } = await this.parse(Agentboard);
80
+
81
+ if (args.subcommand === "attach") {
82
+ if (!args.cardId) {
83
+ this.error("Card ID is required for attach subcommand");
84
+ }
85
+ execSync(`tmux attach-session -t card-${args.cardId}`, {
86
+ stdio: "inherit",
87
+ });
88
+ return;
89
+ }
90
+
91
+ if (args.subcommand === "reset") {
92
+ const defaultDataDir = resolve(
93
+ process.env.XDG_CONFIG_HOME ?? resolve(process.env.HOME ?? "~", ".config"),
94
+ "towles-tool",
95
+ "agentboard",
96
+ );
97
+ const dataDir = flags["data-dir"] ? resolve(flags["data-dir"]) : defaultDataDir;
98
+ const dbPath = join(dataDir, "agentboard.db");
99
+ const walPath = `${dbPath}-wal`;
100
+ const shmPath = `${dbPath}-shm`;
101
+
102
+ if (!existsSync(dbPath)) {
103
+ consola.info("No database found — nothing to reset.");
104
+ return;
105
+ }
106
+
107
+ consola.warn(`This will delete: ${dbPath}`);
108
+ for (const f of [dbPath, walPath, shmPath]) {
109
+ if (existsSync(f)) {
110
+ const { unlinkSync } = await import("node:fs");
111
+ unlinkSync(f);
112
+ }
113
+ }
114
+ consola.success("Database reset. Start AgentBoard to create a fresh DB.");
115
+ return;
116
+ }
117
+
118
+ const agentboardDir = resolve(import.meta.dirname, "../../plugins/tt-agentboard");
119
+ const port = flags.port;
120
+ const defaultDataDir = resolve(
121
+ process.env.XDG_CONFIG_HOME ?? resolve(process.env.HOME ?? "~", ".config"),
122
+ "towles-tool",
123
+ "agentboard",
124
+ );
125
+ const dataDir = flags["data-dir"] ? resolve(flags["data-dir"]) : defaultDataDir;
126
+ const localIp = getLocalIp();
127
+ const dbPath = join(dataDir, "agentboard.db");
128
+ const isFirstRun = !existsSync(dbPath);
129
+
130
+ const lanMode = flags.lan;
131
+ const host = lanMode ? "0.0.0.0" : "127.0.0.1";
132
+
133
+ const lines = [`AgentBoard\n\n Local: http://localhost:${port}`];
134
+ if (lanMode) {
135
+ lines.push(` Network: http://${localIp}:${port}`);
136
+ } else {
137
+ lines.push(` Network: disabled (use --lan to enable)`);
138
+ }
139
+ lines.push(` Data: ${dataDir}`);
140
+ consola.box(lines.join("\n"));
141
+
142
+ if (isFirstRun) {
143
+ consola.info("First run detected — a new database will be created at startup.");
144
+ consola.info(
145
+ "Setup checklist:\n" +
146
+ " 1. Ensure tmux is installed (sudo apt install tmux / brew install tmux)\n" +
147
+ " 2. Set GITHUB_TOKEN for GitHub features (optional)\n" +
148
+ " 3. Open the board → Workspaces → Add a workspace slot\n" +
149
+ " 4. Create your first card and drag it to In Progress",
150
+ );
151
+ }
152
+
153
+ const proc = spawn("pnpm", ["dev", "--port", port], {
154
+ cwd: agentboardDir,
155
+ stdio: "inherit",
156
+ env: {
157
+ ...process.env,
158
+ NUXT_DEV_HOST: host,
159
+ AGENTBOARD_DATA_DIR: dataDir,
160
+ AGENTBOARD_LAN: lanMode ? "1" : "0",
161
+ },
162
+ });
163
+
164
+ if (flags.open) {
165
+ setTimeout(() => {
166
+ try {
167
+ execSync(`xdg-open http://localhost:${port}`, { stdio: "ignore" });
168
+ } catch {
169
+ consola.debug("Could not open browser automatically");
170
+ }
171
+ }, 2000);
172
+ }
173
+
174
+ proc.on("exit", (code) => process.exit(code ?? 0));
175
+ }
176
+ }
@@ -4,10 +4,9 @@ import { join } from "node:path";
4
4
  import { Flags } from "@oclif/core";
5
5
  import consola from "consola";
6
6
 
7
- import { BaseCommand } from "./base.js";
7
+ import { BaseCommand } from "../base.js";
8
8
  import {
9
9
  STEP_NAMES,
10
- buildIssueContext,
11
10
  fetchIssue,
12
11
  fetchIssues,
13
12
  getConfig,
@@ -17,9 +16,8 @@ import {
17
16
  logBanner,
18
17
  runPipeline,
19
18
  sleep,
20
- stepRefresh,
21
- } from "../lib/auto-claude/index.js";
22
- import type { IssueContext, StepName } from "../lib/auto-claude/index.js";
19
+ } from "../../lib/auto-claude/index.js";
20
+ import type { IssueContext, StepName } from "../../lib/auto-claude/index.js";
23
21
 
24
22
  export default class AutoClaude extends BaseCommand {
25
23
  static override aliases = ["ac"];
@@ -39,10 +37,6 @@ export default class AutoClaude extends BaseCommand {
39
37
  description: "Reset local state for an issue",
40
38
  command: "<%= config.bin %> auto-claude --reset 42",
41
39
  },
42
- {
43
- description: "Refresh a stale PR branch",
44
- command: "<%= config.bin %> auto-claude --refresh --issue 42",
45
- },
46
40
  {
47
41
  description: "Loop mode: poll for labeled issues",
48
42
  command: "<%= config.bin %> auto-claude --loop",
@@ -67,9 +61,9 @@ export default class AutoClaude extends BaseCommand {
67
61
  reset: Flags.integer({
68
62
  description: "Delete local state for an issue (force restart)",
69
63
  }),
70
- refresh: Flags.boolean({
71
- description: "Rebase a stale PR branch onto current main",
72
- default: false,
64
+ model: Flags.string({
65
+ description: "Claude model to use (default: opus)",
66
+ default: "opus",
73
67
  }),
74
68
  loop: Flags.boolean({
75
69
  description: "Poll for labeled issues continuously",
@@ -100,7 +94,7 @@ export default class AutoClaude extends BaseCommand {
100
94
  triggerLabel: flags.label,
101
95
  mainBranch: flags["main-branch"],
102
96
  scopePath: flags["scope-path"],
103
- loopRetryEnabled: flags.loop || undefined,
97
+ model: flags.model,
104
98
  });
105
99
 
106
100
  if (flags.reset) {
@@ -111,19 +105,6 @@ export default class AutoClaude extends BaseCommand {
111
105
  return;
112
106
  }
113
107
 
114
- if (flags.refresh) {
115
- if (!flags.issue) {
116
- this.error("--refresh requires --issue <number>");
117
- }
118
- const ctx = buildIssueContext(
119
- { number: flags.issue, title: `Issue #${flags.issue}`, body: "" },
120
- cfg.repo,
121
- cfg.scopePath,
122
- );
123
- await stepRefresh(ctx);
124
- return;
125
- }
126
-
127
108
  const untilStep = flags.until as StepName | undefined;
128
109
  const loopMode = flags.loop;
129
110
  const intervalMs = (flags.interval ?? cfg.loopIntervalMinutes) * 60_000;
@@ -156,6 +137,7 @@ export default class AutoClaude extends BaseCommand {
156
137
  throw e;
157
138
  }
158
139
 
140
+ log("Fetching labeled issues…");
159
141
  let contexts: IssueContext[];
160
142
  if (flags.issue) {
161
143
  const ctx = await fetchIssue(flags.issue);
@@ -170,10 +152,14 @@ export default class AutoClaude extends BaseCommand {
170
152
  log(`Processing ${contexts.length} issue(s)...\n`);
171
153
 
172
154
  for (const ctx of contexts) {
155
+ const issueStart = Date.now();
173
156
  try {
174
157
  await runPipeline(ctx, untilStep);
175
158
  } catch (e) {
176
159
  consola.error(`Pipeline error for ${ctx.repo}#${ctx.number}:`, e);
160
+ } finally {
161
+ const elapsed = ((Date.now() - issueStart) / 1000).toFixed(1);
162
+ log(`Completed ${ctx.repo}#${ctx.number} in ${elapsed}s`);
177
163
  }
178
164
  }
179
165
  }
@@ -198,7 +184,9 @@ async function syncWithRemote(): Promise<void> {
198
184
  const branch = await git(["rev-parse", "--abbrev-ref", "HEAD"]);
199
185
  if (branch !== cfg.mainBranch) {
200
186
  log(`Warning: on branch "${branch}", switching to ${cfg.mainBranch}...`);
201
- await git(["checkout", cfg.mainBranch]).catch(() => {});
187
+ await git(["checkout", cfg.mainBranch]).catch(() => {
188
+ // Best-effort checkout — may fail if working tree is dirty
189
+ });
202
190
  }
203
191
  const status = await git(["status", "--porcelain"]);
204
192
  if (status.length > 0) {
@@ -217,7 +205,9 @@ function registerShutdownHandlers(): void {
217
205
  log(`Received ${signal}, shutting down...`);
218
206
  setTimeout(() => process.exit(1), 5_000).unref();
219
207
  git(["checkout", getConfig().mainBranch])
220
- .catch(() => {})
208
+ .catch(() => {
209
+ // Best-effort cleanup on shutdown — ignore failures
210
+ })
221
211
  .then(() => process.exit(0));
222
212
  });
223
213
  }
@@ -0,0 +1,114 @@
1
+ import { Flags } from "@oclif/core";
2
+ import consola from "consola";
3
+ import { colors } from "consola/utils";
4
+ import { Fzf } from "fzf";
5
+ import prompts from "prompts";
6
+ import type { Choice } from "prompts";
7
+
8
+ import { BaseCommand } from "../base.js";
9
+ import { buildIssueChoices, computeColumnLayout } from "../gh/branch.js";
10
+ import { STEP_NAMES, fetchIssue, initConfig, runPipeline } from "../../lib/auto-claude/index.js";
11
+ import type { StepName } from "../../lib/auto-claude/index.js";
12
+ import { getIssues, isGithubCliInstalled } from "../../utils/git/gh-cli-wrapper.js";
13
+ import { getTerminalColumns } from "../../utils/render.js";
14
+
15
+ export default class AutoClaudeList extends BaseCommand {
16
+ static override description = "Interactively pick an auto-claude issue to process";
17
+
18
+ static override examples = [
19
+ {
20
+ description: "Browse auto-claude labeled issues",
21
+ command: "<%= config.bin %> auto-claude list",
22
+ },
23
+ {
24
+ description: "Pick an issue and run until plan step",
25
+ command: "<%= config.bin %> auto-claude list --until plan",
26
+ },
27
+ ];
28
+
29
+ static override flags = {
30
+ ...BaseCommand.baseFlags,
31
+ until: Flags.string({
32
+ char: "u",
33
+ description: `Stop after this step (${STEP_NAMES.join(", ")})`,
34
+ options: [...STEP_NAMES],
35
+ }),
36
+ label: Flags.string({
37
+ description: "Trigger label (default: auto-claude)",
38
+ }),
39
+ "main-branch": Flags.string({
40
+ description: "Override main branch detection",
41
+ }),
42
+ "scope-path": Flags.string({
43
+ description: "Path within repo to scope work (default: .)",
44
+ }),
45
+ };
46
+
47
+ async run(): Promise<void> {
48
+ const { flags } = await this.parse(AutoClaudeList);
49
+
50
+ const cfg = await initConfig({
51
+ triggerLabel: flags.label,
52
+ mainBranch: flags["main-branch"],
53
+ scopePath: flags["scope-path"],
54
+ });
55
+
56
+ const cliInstalled = await isGithubCliInstalled();
57
+ if (!cliInstalled) {
58
+ this.error("GitHub CLI (gh) is not installed");
59
+ }
60
+
61
+ const issues = await getIssues({ cwd: process.cwd(), label: cfg.triggerLabel });
62
+ if (issues.length === 0) {
63
+ consola.info(`No open issues with '${cfg.triggerLabel}' label`);
64
+ return;
65
+ }
66
+
67
+ consola.info(colors.green(`${issues.length} issue(s) with '${cfg.triggerLabel}' label`));
68
+
69
+ const layout = computeColumnLayout(issues, getTerminalColumns());
70
+ const choices = buildIssueChoices(issues, layout);
71
+
72
+ const fzf = new Fzf(choices, {
73
+ selector: (item) => `${item.value} ${item.description}`,
74
+ casing: "case-insensitive",
75
+ });
76
+
77
+ try {
78
+ const result = await prompts(
79
+ {
80
+ name: "issueNumber",
81
+ message: "Pick an issue to process:",
82
+ type: "autocomplete",
83
+ choices,
84
+ async suggest(input: string, choices: Choice[]) {
85
+ const results = fzf.find(input);
86
+ return results.map((r) => choices.find((c) => c.value === r.item.value));
87
+ },
88
+ },
89
+ {
90
+ onCancel: () => {
91
+ consola.info(colors.dim("Canceled"));
92
+ this.exit(0);
93
+ },
94
+ },
95
+ );
96
+
97
+ if (result.issueNumber === "cancel") {
98
+ consola.info(colors.dim("Canceled"));
99
+ return;
100
+ }
101
+
102
+ const ctx = await fetchIssue(result.issueNumber);
103
+ if (!ctx) {
104
+ this.error(`Could not fetch issue #${result.issueNumber}`);
105
+ }
106
+
107
+ const untilStep = flags.until as StepName | undefined;
108
+ await runPipeline(ctx, untilStep);
109
+ } catch (e) {
110
+ consola.error(e);
111
+ this.exit(1);
112
+ }
113
+ }
114
+ }
@@ -0,0 +1,138 @@
1
+ import { mkdirSync, existsSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { tmpdir } from "node:os";
4
+
5
+ import consola from "consola";
6
+ import { x } from "tinyexec";
7
+ import { describe, it, expect, vi, beforeEach } from "vitest";
8
+
9
+ import { LABELS } from "../../lib/auto-claude/labels.js";
10
+ import { retryIssues } from "./retry.js";
11
+
12
+ // Suppress consola output during tests
13
+ consola.level = -999;
14
+
15
+ // Mock tinyexec so setLabel/removeLabel (which use execSafe -> x) work
16
+ vi.mock("tinyexec", () => ({
17
+ x: vi.fn().mockResolvedValue({ stdout: "", exitCode: 0, stderr: "" }),
18
+ }));
19
+
20
+ function getGhEditCalls() {
21
+ return vi
22
+ .mocked(x)
23
+ .mock.calls.filter(
24
+ ([cmd, args]) => cmd === "gh" && args?.[0] === "issue" && args?.[1] === "edit",
25
+ )
26
+ .map(([, args]) => args as string[]);
27
+ }
28
+
29
+ describe("retryIssues", () => {
30
+ beforeEach(() => {
31
+ vi.clearAllMocks();
32
+ });
33
+
34
+ it("removes failed label and adds trigger label for each issue", async () => {
35
+ const issues = [
36
+ {
37
+ number: 42,
38
+ title: "Fix bug",
39
+ state: "OPEN",
40
+ labels: [{ name: LABELS.failed, color: "red" }],
41
+ },
42
+ ];
43
+
44
+ const count = await retryIssues("owner/repo", "auto-claude", issues, false);
45
+
46
+ expect(count).toBe(1);
47
+ const editCalls = getGhEditCalls();
48
+ expect(editCalls.length).toBe(2);
49
+ expect(editCalls[0]).toEqual(
50
+ expect.arrayContaining(["42", "--repo", "owner/repo", "--remove-label", LABELS.failed]),
51
+ );
52
+ expect(editCalls[1]).toEqual(
53
+ expect.arrayContaining(["42", "--repo", "owner/repo", "--add-label", "auto-claude"]),
54
+ );
55
+ });
56
+
57
+ it("retries multiple issues", async () => {
58
+ const issues = [
59
+ {
60
+ number: 10,
61
+ title: "Issue A",
62
+ state: "OPEN",
63
+ labels: [{ name: LABELS.failed, color: "red" }],
64
+ },
65
+ {
66
+ number: 20,
67
+ title: "Issue B",
68
+ state: "OPEN",
69
+ labels: [{ name: LABELS.failed, color: "red" }],
70
+ },
71
+ ];
72
+
73
+ const count = await retryIssues("owner/repo", "auto-claude", issues, false);
74
+
75
+ expect(count).toBe(2);
76
+ const editCalls = getGhEditCalls();
77
+ // 2 issues * 2 label ops = 4
78
+ expect(editCalls.length).toBe(4);
79
+ });
80
+
81
+ it("returns 0 when given empty selection", async () => {
82
+ const count = await retryIssues("owner/repo", "auto-claude", [], false);
83
+ expect(count).toBe(0);
84
+ expect(getGhEditCalls().length).toBe(0);
85
+ });
86
+
87
+ it("cleans artifact directory when clean=true", async () => {
88
+ const tmpDir = join(tmpdir(), `retry-test-${Date.now()}`);
89
+ const issueDir = join(tmpDir, ".auto-claude", "issue-42");
90
+ mkdirSync(issueDir, { recursive: true });
91
+ writeFileSync(join(issueDir, "test.txt"), "data");
92
+
93
+ const originalCwd = process.cwd();
94
+ process.chdir(tmpDir);
95
+
96
+ try {
97
+ const issues = [
98
+ {
99
+ number: 42,
100
+ title: "Fix bug",
101
+ state: "OPEN",
102
+ labels: [{ name: LABELS.failed, color: "red" }],
103
+ },
104
+ ];
105
+
106
+ await retryIssues("owner/repo", "auto-claude", issues, true);
107
+ expect(existsSync(issueDir)).toBe(false);
108
+ } finally {
109
+ process.chdir(originalCwd);
110
+ }
111
+ });
112
+
113
+ it("does not clean artifacts when clean=false", async () => {
114
+ const tmpDir = join(tmpdir(), `retry-test-${Date.now()}`);
115
+ const issueDir = join(tmpDir, ".auto-claude", "issue-42");
116
+ mkdirSync(issueDir, { recursive: true });
117
+ writeFileSync(join(issueDir, "test.txt"), "data");
118
+
119
+ const originalCwd = process.cwd();
120
+ process.chdir(tmpDir);
121
+
122
+ try {
123
+ const issues = [
124
+ {
125
+ number: 42,
126
+ title: "Fix bug",
127
+ state: "OPEN",
128
+ labels: [{ name: LABELS.failed, color: "red" }],
129
+ },
130
+ ];
131
+
132
+ await retryIssues("owner/repo", "auto-claude", issues, false);
133
+ expect(existsSync(issueDir)).toBe(true);
134
+ } finally {
135
+ process.chdir(originalCwd);
136
+ }
137
+ });
138
+ });