agent-gauntlet 0.1.9 → 0.1.11

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 (48) hide show
  1. package/README.md +1 -1
  2. package/package.json +4 -2
  3. package/src/cli-adapters/claude.ts +139 -108
  4. package/src/cli-adapters/codex.ts +141 -117
  5. package/src/cli-adapters/cursor.ts +152 -0
  6. package/src/cli-adapters/gemini.ts +171 -139
  7. package/src/cli-adapters/github-copilot.ts +153 -0
  8. package/src/cli-adapters/index.ts +77 -48
  9. package/src/commands/check.test.ts +24 -20
  10. package/src/commands/check.ts +65 -59
  11. package/src/commands/detect.test.ts +38 -32
  12. package/src/commands/detect.ts +74 -61
  13. package/src/commands/health.test.ts +67 -53
  14. package/src/commands/health.ts +167 -145
  15. package/src/commands/help.test.ts +37 -37
  16. package/src/commands/help.ts +30 -22
  17. package/src/commands/index.ts +9 -9
  18. package/src/commands/init.test.ts +118 -107
  19. package/src/commands/init.ts +515 -417
  20. package/src/commands/list.test.ts +87 -70
  21. package/src/commands/list.ts +28 -24
  22. package/src/commands/rerun.ts +142 -119
  23. package/src/commands/review.test.ts +26 -20
  24. package/src/commands/review.ts +65 -59
  25. package/src/commands/run.test.ts +22 -20
  26. package/src/commands/run.ts +64 -58
  27. package/src/commands/shared.ts +44 -35
  28. package/src/config/loader.test.ts +112 -90
  29. package/src/config/loader.ts +132 -123
  30. package/src/config/schema.ts +49 -47
  31. package/src/config/types.ts +15 -13
  32. package/src/config/validator.ts +521 -454
  33. package/src/core/change-detector.ts +122 -104
  34. package/src/core/entry-point.test.ts +60 -62
  35. package/src/core/entry-point.ts +76 -67
  36. package/src/core/job.ts +69 -59
  37. package/src/core/runner.ts +261 -221
  38. package/src/gates/check.ts +78 -69
  39. package/src/gates/result.ts +7 -6
  40. package/src/gates/review.test.ts +188 -0
  41. package/src/gates/review.ts +717 -506
  42. package/src/index.ts +16 -15
  43. package/src/output/console.ts +253 -198
  44. package/src/output/logger.ts +65 -51
  45. package/src/templates/run_gauntlet.template.md +18 -0
  46. package/src/utils/diff-parser.ts +64 -62
  47. package/src/utils/log-parser.ts +227 -206
  48. package/src/utils/sanitizer.ts +1 -1
@@ -1,10 +1,10 @@
1
- import type { Command } from 'commander';
2
- import chalk from 'chalk';
3
- import fs from 'node:fs/promises';
4
- import path from 'node:path';
5
- import readline from 'node:readline';
6
- import { exists } from './shared.js';
7
- import { getAllAdapters, getProjectCommandAdapters, getUserCommandAdapters, type CLIAdapter } from '../cli-adapters/index.js';
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import readline from "node:readline";
4
+ import chalk from "chalk";
5
+ import type { Command } from "commander";
6
+ import { type CLIAdapter, getAllAdapters } from "../cli-adapters/index.js";
7
+ import { exists } from "./shared.js";
8
8
 
9
9
  const MAX_PROMPT_ATTEMPTS = 10;
10
10
 
@@ -25,105 +25,119 @@ Execute the autonomous verification suite.
25
25
  - All gates pass
26
26
  - You disagree with remaining failures (ask the human how to proceed)
27
27
  - Still failing after 3 rerun attempts
28
+ 8. Once all gates pass, do NOT commit or push your changes—await the human's review and explicit instruction to commit.
28
29
  `;
29
30
 
30
- type InstallLevel = 'none' | 'project' | 'user';
31
+ type InstallLevel = "none" | "project" | "user";
31
32
 
32
33
  interface InitOptions {
33
- yes?: boolean;
34
+ yes?: boolean;
34
35
  }
35
36
 
36
37
  interface InitConfig {
37
- sourceDir: string;
38
- lintCmd: string | null; // null means not selected, empty string means selected but blank (TODO)
39
- testCmd: string | null; // null means not selected, empty string means selected but blank (TODO)
40
- selectedAdapters: CLIAdapter[];
38
+ sourceDir: string;
39
+ lintCmd: string | null; // null means not selected, empty string means selected but blank (TODO)
40
+ testCmd: string | null; // null means not selected, empty string means selected but blank (TODO)
41
+ selectedAdapters: CLIAdapter[];
41
42
  }
42
43
 
43
44
  export function registerInitCommand(program: Command): void {
44
- program
45
- .command('init')
46
- .description('Initialize .gauntlet configuration')
47
- .option('-y, --yes', 'Skip prompts and use defaults (all available CLIs, source: ., no extra checks)')
48
- .action(async (options: InitOptions) => {
49
- const projectRoot = process.cwd();
50
- const targetDir = path.join(projectRoot, '.gauntlet');
51
-
52
- if (await exists(targetDir)) {
53
- console.log(chalk.yellow('.gauntlet directory already exists.'));
54
- return;
55
- }
56
-
57
- // 1. CLI Detection
58
- console.log('Detecting available CLI agents...');
59
- const availableAdapters = await detectAvailableCLIs();
60
-
61
- if (availableAdapters.length === 0) {
62
- console.log();
63
- console.log(chalk.red('Error: No CLI agents found. Install at least one:'));
64
- console.log(' - Claude: https://docs.anthropic.com/en/docs/claude-code');
65
- console.log(' - Gemini: https://github.com/google-gemini/gemini-cli');
66
- console.log(' - Codex: https://github.com/openai/codex');
67
- console.log();
68
- return;
69
- }
70
-
71
- let config: InitConfig;
72
-
73
- if (options.yes) {
74
- config = {
75
- sourceDir: '.',
76
- lintCmd: null,
77
- testCmd: null,
78
- selectedAdapters: availableAdapters,
79
- };
80
- } else {
81
- config = await promptForConfig(availableAdapters);
82
- }
83
-
84
- // Create base config structure
85
- await fs.mkdir(targetDir);
86
- await fs.mkdir(path.join(targetDir, 'checks'));
87
- await fs.mkdir(path.join(targetDir, 'reviews'));
88
-
89
- // 4. Commented Config Templates
90
- // Generate config.yml
91
- const configContent = generateConfigYml(config);
92
- await fs.writeFile(path.join(targetDir, 'config.yml'), configContent);
93
- console.log(chalk.green('Created .gauntlet/config.yml'));
94
-
95
- // Generate check files if selected
96
- if (config.lintCmd !== null) {
97
- const lintContent = `name: lint
98
- command: ${config.lintCmd || '# command: TODO - add your lint command (e.g., npm run lint)'}
45
+ program
46
+ .command("init")
47
+ .description("Initialize .gauntlet configuration")
48
+ .option(
49
+ "-y, --yes",
50
+ "Skip prompts and use defaults (all available CLIs, source: ., no extra checks)",
51
+ )
52
+ .action(async (options: InitOptions) => {
53
+ const projectRoot = process.cwd();
54
+ const targetDir = path.join(projectRoot, ".gauntlet");
55
+
56
+ if (await exists(targetDir)) {
57
+ console.log(chalk.yellow(".gauntlet directory already exists."));
58
+ return;
59
+ }
60
+
61
+ // 1. CLI Detection
62
+ console.log("Detecting available CLI agents...");
63
+ const availableAdapters = await detectAvailableCLIs();
64
+
65
+ if (availableAdapters.length === 0) {
66
+ console.log();
67
+ console.log(
68
+ chalk.red("Error: No CLI agents found. Install at least one:"),
69
+ );
70
+ console.log(
71
+ " - Claude: https://docs.anthropic.com/en/docs/claude-code",
72
+ );
73
+ console.log(" - Gemini: https://github.com/google-gemini/gemini-cli");
74
+ console.log(" - Codex: https://github.com/openai/codex");
75
+ console.log();
76
+ return;
77
+ }
78
+
79
+ let config: InitConfig;
80
+
81
+ if (options.yes) {
82
+ config = {
83
+ sourceDir: ".",
84
+ lintCmd: null,
85
+ testCmd: null,
86
+ selectedAdapters: availableAdapters,
87
+ };
88
+ } else {
89
+ config = await promptForConfig(availableAdapters);
90
+ }
91
+
92
+ // Create base config structure
93
+ await fs.mkdir(targetDir);
94
+ await fs.mkdir(path.join(targetDir, "checks"));
95
+ await fs.mkdir(path.join(targetDir, "reviews"));
96
+
97
+ // 4. Commented Config Templates
98
+ // Generate config.yml
99
+ const configContent = generateConfigYml(config);
100
+ await fs.writeFile(path.join(targetDir, "config.yml"), configContent);
101
+ console.log(chalk.green("Created .gauntlet/config.yml"));
102
+
103
+ // Generate check files if selected
104
+ if (config.lintCmd !== null) {
105
+ const lintContent = `name: lint
106
+ command: ${config.lintCmd || "# command: TODO - add your lint command (e.g., npm run lint)"}
99
107
  # parallel: false
100
108
  # run_in_ci: true
101
109
  # run_locally: true
102
110
  # timeout: 300
103
111
  `;
104
- await fs.writeFile(path.join(targetDir, 'checks', 'lint.yml'), lintContent);
105
- console.log(chalk.green('Created .gauntlet/checks/lint.yml'));
106
- }
107
-
108
- if (config.testCmd !== null) {
109
- const testContent = `name: unit-tests
110
- command: ${config.testCmd || '# command: TODO - add your test command (e.g., npm test)'}
112
+ await fs.writeFile(
113
+ path.join(targetDir, "checks", "lint.yml"),
114
+ lintContent,
115
+ );
116
+ console.log(chalk.green("Created .gauntlet/checks/lint.yml"));
117
+ }
118
+
119
+ if (config.testCmd !== null) {
120
+ const testContent = `name: unit-tests
121
+ command: ${config.testCmd || "# command: TODO - add your test command (e.g., npm test)"}
111
122
  # parallel: false
112
123
  # run_in_ci: true
113
124
  # run_locally: true
114
125
  # timeout: 300
115
126
  `;
116
- await fs.writeFile(path.join(targetDir, 'checks', 'unit-tests.yml'), testContent);
117
- console.log(chalk.green('Created .gauntlet/checks/unit-tests.yml'));
118
- }
119
-
120
- // 5. Improved Default Code Review Prompt
121
- const reviewContent = `---
127
+ await fs.writeFile(
128
+ path.join(targetDir, "checks", "unit-tests.yml"),
129
+ testContent,
130
+ );
131
+ console.log(chalk.green("Created .gauntlet/checks/unit-tests.yml"));
132
+ }
133
+
134
+ // 5. Improved Default Code Review Prompt
135
+ const reviewContent = `---
122
136
  num_reviews: 1
123
137
  # parallel: true
124
138
  # timeout: 300
125
139
  # cli_preference:
126
- # - ${config.selectedAdapters[0]?.name || 'claude'}
140
+ # - ${config.selectedAdapters[0]?.name || "claude"}
127
141
  ---
128
142
 
129
143
  # Code Review
@@ -137,160 +151,191 @@ Review the diff for quality issues:
137
151
 
138
152
  For each issue: cite file:line, explain the problem, suggest a fix.
139
153
  `;
140
- await fs.writeFile(path.join(targetDir, 'reviews', 'code-quality.md'), reviewContent);
141
- console.log(chalk.green('Created .gauntlet/reviews/code-quality.md'));
142
-
143
- // Write the canonical gauntlet command file
144
- const canonicalCommandPath = path.join(targetDir, 'run_gauntlet.md');
145
- await fs.writeFile(canonicalCommandPath, GAUNTLET_COMMAND_CONTENT);
146
- console.log(chalk.green('Created .gauntlet/run_gauntlet.md'));
147
-
148
- // Handle command installation
149
- if (options.yes) {
150
- // Default: install at project level for all selected agents (if they support it)
151
- const adaptersToInstall = config.selectedAdapters.filter(a => a.getProjectCommandDir() !== null);
152
- if (adaptersToInstall.length > 0) {
153
- await installCommands('project', adaptersToInstall.map(a => a.name), projectRoot, canonicalCommandPath);
154
- }
155
- } else {
156
- // Interactive prompts - passing available adapters to avoid re-checking or offering unavailable ones
157
- await promptAndInstallCommands(projectRoot, canonicalCommandPath, availableAdapters);
158
- }
159
- });
154
+ await fs.writeFile(
155
+ path.join(targetDir, "reviews", "code-quality.md"),
156
+ reviewContent,
157
+ );
158
+ console.log(chalk.green("Created .gauntlet/reviews/code-quality.md"));
159
+
160
+ // Write the canonical gauntlet command file
161
+ const canonicalCommandPath = path.join(targetDir, "run_gauntlet.md");
162
+ await fs.writeFile(canonicalCommandPath, GAUNTLET_COMMAND_CONTENT);
163
+ console.log(chalk.green("Created .gauntlet/run_gauntlet.md"));
164
+
165
+ // Handle command installation
166
+ if (options.yes) {
167
+ // Default: install at project level for all selected agents (if they support it)
168
+ const adaptersToInstall = config.selectedAdapters.filter(
169
+ (a) => a.getProjectCommandDir() !== null,
170
+ );
171
+ if (adaptersToInstall.length > 0) {
172
+ await installCommands(
173
+ "project",
174
+ adaptersToInstall.map((a) => a.name),
175
+ projectRoot,
176
+ canonicalCommandPath,
177
+ );
178
+ }
179
+ } else {
180
+ // Interactive prompts - passing available adapters to avoid re-checking or offering unavailable ones
181
+ await promptAndInstallCommands(
182
+ projectRoot,
183
+ canonicalCommandPath,
184
+ availableAdapters,
185
+ );
186
+ }
187
+ });
160
188
  }
161
189
 
162
190
  async function detectAvailableCLIs(): Promise<CLIAdapter[]> {
163
- const allAdapters = getAllAdapters();
164
- const available: CLIAdapter[] = [];
165
-
166
- for (const adapter of allAdapters) {
167
- const isAvailable = await adapter.isAvailable();
168
- if (isAvailable) {
169
- console.log(chalk.green(` ✓ ${adapter.name}`));
170
- available.push(adapter);
171
- } else {
172
- console.log(chalk.dim(` ✗ ${adapter.name} (not installed)`));
173
- }
174
- }
175
- return available;
191
+ const allAdapters = getAllAdapters();
192
+ const available: CLIAdapter[] = [];
193
+
194
+ for (const adapter of allAdapters) {
195
+ const isAvailable = await adapter.isAvailable();
196
+ if (isAvailable) {
197
+ console.log(chalk.green(` ✓ ${adapter.name}`));
198
+ available.push(adapter);
199
+ } else {
200
+ console.log(chalk.dim(` ✗ ${adapter.name} (not installed)`));
201
+ }
202
+ }
203
+ return available;
176
204
  }
177
205
 
178
- async function promptForConfig(availableAdapters: CLIAdapter[]): Promise<InitConfig> {
179
- const rl = readline.createInterface({
180
- input: process.stdin,
181
- output: process.stdout
182
- });
183
-
184
- const question = (prompt: string): Promise<string> => {
185
- return new Promise((resolve) => {
186
- rl.question(prompt, (answer) => {
187
- resolve(answer?.trim() ?? '');
188
- });
189
- });
190
- };
191
-
192
- try {
193
- // CLI Selection
194
- console.log();
195
- console.log('Which CLIs would you like to use?');
196
- availableAdapters.forEach((adapter, i) => {
197
- console.log(` ${i + 1}) ${adapter.name}`);
198
- });
199
- console.log(` ${availableAdapters.length + 1}) All`);
200
-
201
- let selectedAdapters: CLIAdapter[] = [];
202
- let attempts = 0;
203
- while (true) {
204
- attempts++;
205
- if (attempts > MAX_PROMPT_ATTEMPTS) throw new Error('Too many invalid attempts');
206
- const answer = await question(`(comma-separated, e.g., 1,2): `);
207
- const selections = answer.split(',').map(s => s.trim()).filter(s => s);
208
-
209
- if (selections.length === 0) {
210
- // Default to all if empty? Or force selection? Plan says "Which CLIs...".
211
- // Let's assume user must pick or we default to all if they just hit enter?
212
- // Actually, usually enter means default. Let's make All the default if just Enter.
213
- selectedAdapters = availableAdapters;
214
- break;
215
- }
216
-
217
- let valid = true;
218
- const chosen: CLIAdapter[] = [];
219
-
220
- for (const sel of selections) {
221
- const num = parseInt(sel, 10);
222
- if (isNaN(num) || num < 1 || num > availableAdapters.length + 1) {
223
- console.log(chalk.yellow(`Invalid selection: ${sel}`));
224
- valid = false;
225
- break;
226
- }
227
- if (num === availableAdapters.length + 1) {
228
- chosen.push(...availableAdapters);
229
- } else {
230
- chosen.push(availableAdapters[num - 1]);
231
- }
232
- }
233
-
234
- if (valid) {
235
- selectedAdapters = [...new Set(chosen)];
236
- break;
237
- }
238
- }
239
-
240
- // Source Directory
241
- console.log();
242
- const sourceDirInput = await question('Enter your source directory (e.g., src, lib, .) [default: .]: ');
243
- const sourceDir = sourceDirInput || '.';
244
-
245
- // Lint Check
246
- console.log();
247
- const addLint = await question('Would you like to add a linting check? [y/N]: ');
248
- let lintCmd: string | null = null;
249
- if (addLint.toLowerCase().startsWith('y')) {
250
- lintCmd = await question('Enter lint command (blank to fill later): ');
251
- }
252
-
253
- // Unit Test Check
254
- console.log();
255
- const addTest = await question('Would you like to add a unit test check? [y/N]: ');
256
- let testCmd: string | null = null;
257
- if (addTest.toLowerCase().startsWith('y')) {
258
- testCmd = await question('Enter test command (blank to fill later): ');
259
- }
260
-
261
- rl.close();
262
- return {
263
- sourceDir,
264
- lintCmd,
265
- testCmd,
266
- selectedAdapters
267
- };
268
-
269
- } catch (error) {
270
- rl.close();
271
- throw error;
272
- }
206
+ async function promptForConfig(
207
+ availableAdapters: CLIAdapter[],
208
+ ): Promise<InitConfig> {
209
+ const rl = readline.createInterface({
210
+ input: process.stdin,
211
+ output: process.stdout,
212
+ });
213
+
214
+ const question = (prompt: string): Promise<string> => {
215
+ return new Promise((resolve) => {
216
+ rl.question(prompt, (answer) => {
217
+ resolve(answer?.trim() ?? "");
218
+ });
219
+ });
220
+ };
221
+
222
+ try {
223
+ // CLI Selection
224
+ console.log();
225
+ console.log("Which CLIs would you like to use?");
226
+ availableAdapters.forEach((adapter, i) => {
227
+ console.log(` ${i + 1}) ${adapter.name}`);
228
+ });
229
+ console.log(` ${availableAdapters.length + 1}) All`);
230
+
231
+ let selectedAdapters: CLIAdapter[] = [];
232
+ let attempts = 0;
233
+ while (true) {
234
+ attempts++;
235
+ if (attempts > MAX_PROMPT_ATTEMPTS)
236
+ throw new Error("Too many invalid attempts");
237
+ const answer = await question(`(comma-separated, e.g., 1,2): `);
238
+ const selections = answer
239
+ .split(",")
240
+ .map((s) => s.trim())
241
+ .filter((s) => s);
242
+
243
+ if (selections.length === 0) {
244
+ // Default to all if empty? Or force selection? Plan says "Which CLIs...".
245
+ // Let's assume user must pick or we default to all if they just hit enter?
246
+ // Actually, usually enter means default. Let's make All the default if just Enter.
247
+ selectedAdapters = availableAdapters;
248
+ break;
249
+ }
250
+
251
+ let valid = true;
252
+ const chosen: CLIAdapter[] = [];
253
+
254
+ for (const sel of selections) {
255
+ const num = parseInt(sel, 10);
256
+ if (
257
+ Number.isNaN(num) ||
258
+ num < 1 ||
259
+ num > availableAdapters.length + 1
260
+ ) {
261
+ console.log(chalk.yellow(`Invalid selection: ${sel}`));
262
+ valid = false;
263
+ break;
264
+ }
265
+ if (num === availableAdapters.length + 1) {
266
+ chosen.push(...availableAdapters);
267
+ } else {
268
+ chosen.push(availableAdapters[num - 1]);
269
+ }
270
+ }
271
+
272
+ if (valid) {
273
+ selectedAdapters = [...new Set(chosen)];
274
+ break;
275
+ }
276
+ }
277
+
278
+ // Source Directory
279
+ console.log();
280
+ const sourceDirInput = await question(
281
+ "Enter your source directory (e.g., src, lib, .) [default: .]: ",
282
+ );
283
+ const sourceDir = sourceDirInput || ".";
284
+
285
+ // Lint Check
286
+ console.log();
287
+ const addLint = await question(
288
+ "Would you like to add a linting check? [y/N]: ",
289
+ );
290
+ let lintCmd: string | null = null;
291
+ if (addLint.toLowerCase().startsWith("y")) {
292
+ lintCmd = await question("Enter lint command (blank to fill later): ");
293
+ }
294
+
295
+ // Unit Test Check
296
+ console.log();
297
+ const addTest = await question(
298
+ "Would you like to add a unit test check? [y/N]: ",
299
+ );
300
+ let testCmd: string | null = null;
301
+ if (addTest.toLowerCase().startsWith("y")) {
302
+ testCmd = await question("Enter test command (blank to fill later): ");
303
+ }
304
+
305
+ rl.close();
306
+ return {
307
+ sourceDir,
308
+ lintCmd,
309
+ testCmd,
310
+ selectedAdapters,
311
+ };
312
+ } catch (error) {
313
+ rl.close();
314
+ throw error;
315
+ }
273
316
  }
274
317
 
275
318
  function generateConfigYml(config: InitConfig): string {
276
- const cliList = config.selectedAdapters.map(a => ` - ${a.name}`).join('\n');
277
-
278
- let entryPoints = '';
279
-
280
- // If we have checks, we need a source directory entry point
281
- if (config.lintCmd !== null || config.testCmd !== null) {
282
- entryPoints += ` - path: "${config.sourceDir}"
319
+ const cliList = config.selectedAdapters
320
+ .map((a) => ` - ${a.name}`)
321
+ .join("\n");
322
+
323
+ let entryPoints = "";
324
+
325
+ // If we have checks, we need a source directory entry point
326
+ if (config.lintCmd !== null || config.testCmd !== null) {
327
+ entryPoints += ` - path: "${config.sourceDir}"
283
328
  checks:\n`;
284
- if (config.lintCmd !== null) entryPoints += ` - lint\n`;
285
- if (config.testCmd !== null) entryPoints += ` - unit-tests\n`;
286
- }
329
+ if (config.lintCmd !== null) entryPoints += ` - lint\n`;
330
+ if (config.testCmd !== null) entryPoints += ` - unit-tests\n`;
331
+ }
287
332
 
288
- // Always include root entry point for reviews
289
- entryPoints += ` - path: "."
333
+ // Always include root entry point for reviews
334
+ entryPoints += ` - path: "."
290
335
  reviews:
291
336
  - code-quality`;
292
337
 
293
- return `base_branch: origin/main
338
+ return `base_branch: origin/main
294
339
  log_dir: .gauntlet_logs
295
340
 
296
341
  # Run gates in parallel when possible (default: true)
@@ -307,202 +352,255 @@ ${entryPoints}
307
352
  `;
308
353
  }
309
354
 
310
- async function promptAndInstallCommands(projectRoot: string, canonicalCommandPath: string, availableAdapters: CLIAdapter[]): Promise<void> {
311
- // Only proceed if we have available adapters
312
- if (availableAdapters.length === 0) return;
313
-
314
- const rl = readline.createInterface({
315
- input: process.stdin,
316
- output: process.stdout
317
- });
318
-
319
- const question = (prompt: string): Promise<string> => {
320
- return new Promise((resolve) => {
321
- rl.question(prompt, (answer) => {
322
- resolve(answer?.trim() ?? '');
323
- });
324
- });
325
- };
326
-
327
- try {
328
- console.log();
329
- console.log(chalk.bold('CLI Agent Command Setup'));
330
- console.log(chalk.dim('The gauntlet command can be installed for CLI agents so you can run /gauntlet directly.'));
331
- console.log();
332
-
333
- // Question 1: Install level
334
- console.log('Where would you like to install the /gauntlet command?');
335
- console.log(' 1) Don\'t install commands');
336
- console.log(' 2) Project level (in this repo\'s .claude/commands, .gemini/commands, etc.)');
337
- console.log(' 3) User level (in ~/.claude/commands, ~/.gemini/commands, etc.)');
338
- console.log();
339
-
340
- let installLevel: InstallLevel = 'none';
341
- let answer = await question('Select option [1-3]: ');
342
- let installLevelAttempts = 0;
343
-
344
- while (true) {
345
- installLevelAttempts++;
346
- if (installLevelAttempts > MAX_PROMPT_ATTEMPTS) throw new Error('Too many invalid attempts');
347
-
348
- if (answer === '1') {
349
- installLevel = 'none';
350
- break;
351
- } else if (answer === '2') {
352
- installLevel = 'project';
353
- break;
354
- } else if (answer === '3') {
355
- installLevel = 'user';
356
- break;
357
- } else {
358
- console.log(chalk.yellow('Please enter 1, 2, or 3'));
359
- answer = await question('Select option [1-3]: ');
360
- }
361
- }
362
-
363
- if (installLevel === 'none') {
364
- console.log(chalk.dim('\nSkipping command installation.'));
365
- rl.close();
366
- return;
367
- }
368
-
369
- // Filter available adapters based on install level support
370
- const installableAdapters = installLevel === 'project'
371
- ? availableAdapters.filter(a => a.getProjectCommandDir() !== null)
372
- : availableAdapters.filter(a => a.getUserCommandDir() !== null);
373
-
374
- if (installableAdapters.length === 0) {
375
- console.log(chalk.yellow(`No available agents support ${installLevel}-level commands.`));
376
- rl.close();
377
- return;
378
- }
379
-
380
- console.log();
381
- console.log('Which CLI agents would you like to install the command for?');
382
- installableAdapters.forEach((adapter, i) => {
383
- console.log(` ${i + 1}) ${adapter.name}`);
384
- });
385
- console.log(` ${installableAdapters.length + 1}) All of the above`);
386
- console.log();
387
-
388
- let selectedAgents: string[] = [];
389
- answer = await question(`Select options (comma-separated, e.g., 1,2 or ${installableAdapters.length + 1} for all): `);
390
- let agentSelectionAttempts = 0;
391
-
392
- while (true) {
393
- agentSelectionAttempts++;
394
- if (agentSelectionAttempts > MAX_PROMPT_ATTEMPTS) throw new Error('Too many invalid attempts');
395
-
396
- const selections = answer.split(',').map(s => s.trim()).filter(s => s);
397
-
398
- if (selections.length === 0) {
399
- console.log(chalk.yellow('Please select at least one option'));
400
- answer = await question(`Select options (comma-separated, e.g., 1,2 or ${installableAdapters.length + 1} for all): `);
401
- continue;
402
- }
403
-
404
- let valid = true;
405
- const agents: string[] = [];
406
-
407
- for (const sel of selections) {
408
- const num = parseInt(sel, 10);
409
- if (isNaN(num) || num < 1 || num > installableAdapters.length + 1) {
410
- console.log(chalk.yellow(`Invalid selection: ${sel}`));
411
- valid = false;
412
- break;
413
- }
414
- if (num === installableAdapters.length + 1) {
415
- agents.push(...installableAdapters.map(a => a.name));
416
- } else {
417
- agents.push(installableAdapters[num - 1].name);
418
- }
419
- }
420
-
421
- if (valid) {
422
- selectedAgents = [...new Set(agents)]; // Dedupe
423
- break;
424
- }
425
- answer = await question(`Select options (comma-separated, e.g., 1,2 or ${installableAdapters.length + 1} for all): `);
426
- }
427
-
428
- rl.close();
429
-
430
- // Install commands
431
- await installCommands(installLevel, selectedAgents, projectRoot, canonicalCommandPath);
432
-
433
- } catch (error: any) {
434
- rl.close();
435
- throw error;
436
- }
355
+ async function promptAndInstallCommands(
356
+ projectRoot: string,
357
+ canonicalCommandPath: string,
358
+ availableAdapters: CLIAdapter[],
359
+ ): Promise<void> {
360
+ // Only proceed if we have available adapters
361
+ if (availableAdapters.length === 0) return;
362
+
363
+ const rl = readline.createInterface({
364
+ input: process.stdin,
365
+ output: process.stdout,
366
+ });
367
+
368
+ const question = (prompt: string): Promise<string> => {
369
+ return new Promise((resolve) => {
370
+ rl.question(prompt, (answer) => {
371
+ resolve(answer?.trim() ?? "");
372
+ });
373
+ });
374
+ };
375
+
376
+ try {
377
+ console.log();
378
+ console.log(chalk.bold("CLI Agent Command Setup"));
379
+ console.log(
380
+ chalk.dim(
381
+ "The gauntlet command can be installed for CLI agents so you can run /gauntlet directly.",
382
+ ),
383
+ );
384
+ console.log();
385
+
386
+ // Question 1: Install level
387
+ console.log("Where would you like to install the /gauntlet command?");
388
+ console.log(" 1) Don't install commands");
389
+ console.log(
390
+ " 2) Project level (in this repo's .claude/commands, .gemini/commands, etc.)",
391
+ );
392
+ console.log(
393
+ " 3) User level (in ~/.claude/commands, ~/.gemini/commands, etc.)",
394
+ );
395
+ console.log();
396
+
397
+ let installLevel: InstallLevel = "none";
398
+ let answer = await question("Select option [1-3]: ");
399
+ let installLevelAttempts = 0;
400
+
401
+ while (true) {
402
+ installLevelAttempts++;
403
+ if (installLevelAttempts > MAX_PROMPT_ATTEMPTS)
404
+ throw new Error("Too many invalid attempts");
405
+
406
+ if (answer === "1") {
407
+ installLevel = "none";
408
+ break;
409
+ } else if (answer === "2") {
410
+ installLevel = "project";
411
+ break;
412
+ } else if (answer === "3") {
413
+ installLevel = "user";
414
+ break;
415
+ } else {
416
+ console.log(chalk.yellow("Please enter 1, 2, or 3"));
417
+ answer = await question("Select option [1-3]: ");
418
+ }
419
+ }
420
+
421
+ if (installLevel === "none") {
422
+ console.log(chalk.dim("\nSkipping command installation."));
423
+ rl.close();
424
+ return;
425
+ }
426
+
427
+ // Filter available adapters based on install level support
428
+ const installableAdapters =
429
+ installLevel === "project"
430
+ ? availableAdapters.filter((a) => a.getProjectCommandDir() !== null)
431
+ : availableAdapters.filter((a) => a.getUserCommandDir() !== null);
432
+
433
+ if (installableAdapters.length === 0) {
434
+ console.log(
435
+ chalk.yellow(
436
+ `No available agents support ${installLevel}-level commands.`,
437
+ ),
438
+ );
439
+ rl.close();
440
+ return;
441
+ }
442
+
443
+ console.log();
444
+ console.log("Which CLI agents would you like to install the command for?");
445
+ installableAdapters.forEach((adapter, i) => {
446
+ console.log(` ${i + 1}) ${adapter.name}`);
447
+ });
448
+ console.log(` ${installableAdapters.length + 1}) All of the above`);
449
+ console.log();
450
+
451
+ let selectedAgents: string[] = [];
452
+ answer = await question(
453
+ `Select options (comma-separated, e.g., 1,2 or ${installableAdapters.length + 1} for all): `,
454
+ );
455
+ let agentSelectionAttempts = 0;
456
+
457
+ while (true) {
458
+ agentSelectionAttempts++;
459
+ if (agentSelectionAttempts > MAX_PROMPT_ATTEMPTS)
460
+ throw new Error("Too many invalid attempts");
461
+
462
+ const selections = answer
463
+ .split(",")
464
+ .map((s) => s.trim())
465
+ .filter((s) => s);
466
+
467
+ if (selections.length === 0) {
468
+ console.log(chalk.yellow("Please select at least one option"));
469
+ answer = await question(
470
+ `Select options (comma-separated, e.g., 1,2 or ${installableAdapters.length + 1} for all): `,
471
+ );
472
+ continue;
473
+ }
474
+
475
+ let valid = true;
476
+ const agents: string[] = [];
477
+
478
+ for (const sel of selections) {
479
+ const num = parseInt(sel, 10);
480
+ if (
481
+ Number.isNaN(num) ||
482
+ num < 1 ||
483
+ num > installableAdapters.length + 1
484
+ ) {
485
+ console.log(chalk.yellow(`Invalid selection: ${sel}`));
486
+ valid = false;
487
+ break;
488
+ }
489
+ if (num === installableAdapters.length + 1) {
490
+ agents.push(...installableAdapters.map((a) => a.name));
491
+ } else {
492
+ agents.push(installableAdapters[num - 1].name);
493
+ }
494
+ }
495
+
496
+ if (valid) {
497
+ selectedAgents = [...new Set(agents)]; // Dedupe
498
+ break;
499
+ }
500
+ answer = await question(
501
+ `Select options (comma-separated, e.g., 1,2 or ${installableAdapters.length + 1} for all): `,
502
+ );
503
+ }
504
+
505
+ rl.close();
506
+
507
+ // Install commands
508
+ await installCommands(
509
+ installLevel,
510
+ selectedAgents,
511
+ projectRoot,
512
+ canonicalCommandPath,
513
+ );
514
+ } catch (error: unknown) {
515
+ rl.close();
516
+ throw error;
517
+ }
437
518
  }
438
519
 
439
520
  async function installCommands(
440
- level: InstallLevel,
441
- agentNames: string[],
442
- projectRoot: string,
443
- canonicalCommandPath: string
521
+ level: InstallLevel,
522
+ agentNames: string[],
523
+ projectRoot: string,
524
+ canonicalCommandPath: string,
444
525
  ): Promise<void> {
445
- if (level === 'none' || agentNames.length === 0) {
446
- return;
447
- }
448
-
449
- console.log();
450
- const allAdapters = getAllAdapters();
451
-
452
- for (const agentName of agentNames) {
453
- const adapter = allAdapters.find(a => a.name === agentName);
454
- if (!adapter) continue;
455
-
456
- let commandDir: string | null;
457
- let isUserLevel: boolean;
458
-
459
- if (level === 'project') {
460
- commandDir = adapter.getProjectCommandDir();
461
- isUserLevel = false;
462
- if (commandDir) {
463
- commandDir = path.join(projectRoot, commandDir);
464
- }
465
- } else {
466
- commandDir = adapter.getUserCommandDir();
467
- isUserLevel = true;
468
- }
469
-
470
- if (!commandDir) {
471
- // This shouldn't happen if we filtered correctly, but good safety check
472
- continue;
473
- }
474
-
475
- const commandFileName = 'gauntlet' + adapter.getCommandExtension();
476
- const commandFilePath = path.join(commandDir, commandFileName);
477
-
478
- try {
479
- // Ensure command directory exists
480
- await fs.mkdir(commandDir, { recursive: true });
481
-
482
- // Check if file already exists
483
- if (await exists(commandFilePath)) {
484
- const relPath = isUserLevel ? commandFilePath : path.relative(projectRoot, commandFilePath);
485
- console.log(chalk.dim(` ${adapter.name}: ${relPath} already exists, skipping`));
486
- continue;
487
- }
488
-
489
- // For project-level with symlink support, create symlink
490
- // For user-level or adapters that need transformation, write the file
491
- if (!isUserLevel && adapter.canUseSymlink()) {
492
- // Calculate relative path from command dir to canonical file
493
- const relativePath = path.relative(commandDir, canonicalCommandPath);
494
- await fs.symlink(relativePath, commandFilePath);
495
- const relPath = path.relative(projectRoot, commandFilePath);
496
- console.log(chalk.green(`Created ${relPath} (symlink to .gauntlet/run_gauntlet.md)`));
497
- } else {
498
- // Transform and write the command file
499
- const transformedContent = adapter.transformCommand(GAUNTLET_COMMAND_CONTENT);
500
- await fs.writeFile(commandFilePath, transformedContent);
501
- const relPath = isUserLevel ? commandFilePath : path.relative(projectRoot, commandFilePath);
502
- console.log(chalk.green(`Created ${relPath}`));
503
- }
504
- } catch (error: any) {
505
- console.log(chalk.yellow(` ${adapter.name}: Could not create command - ${error.message}`));
506
- }
507
- }
526
+ if (level === "none" || agentNames.length === 0) {
527
+ return;
528
+ }
529
+
530
+ console.log();
531
+ const allAdapters = getAllAdapters();
532
+
533
+ for (const agentName of agentNames) {
534
+ const adapter = allAdapters.find((a) => a.name === agentName);
535
+ if (!adapter) continue;
536
+
537
+ let commandDir: string | null;
538
+ let isUserLevel: boolean;
539
+
540
+ if (level === "project") {
541
+ commandDir = adapter.getProjectCommandDir();
542
+ isUserLevel = false;
543
+ if (commandDir) {
544
+ commandDir = path.join(projectRoot, commandDir);
545
+ }
546
+ } else {
547
+ commandDir = adapter.getUserCommandDir();
548
+ isUserLevel = true;
549
+ }
550
+
551
+ if (!commandDir) {
552
+ // This shouldn't happen if we filtered correctly, but good safety check
553
+ continue;
554
+ }
555
+
556
+ const commandFileName = `gauntlet${adapter.getCommandExtension()}`;
557
+ const commandFilePath = path.join(commandDir, commandFileName);
558
+
559
+ try {
560
+ // Ensure command directory exists
561
+ await fs.mkdir(commandDir, { recursive: true });
562
+
563
+ // Check if file already exists
564
+ if (await exists(commandFilePath)) {
565
+ const relPath = isUserLevel
566
+ ? commandFilePath
567
+ : path.relative(projectRoot, commandFilePath);
568
+ console.log(
569
+ chalk.dim(` ${adapter.name}: ${relPath} already exists, skipping`),
570
+ );
571
+ continue;
572
+ }
573
+
574
+ // For project-level with symlink support, create symlink
575
+ // For user-level or adapters that need transformation, write the file
576
+ if (!isUserLevel && adapter.canUseSymlink()) {
577
+ // Calculate relative path from command dir to canonical file
578
+ const relativePath = path.relative(commandDir, canonicalCommandPath);
579
+ await fs.symlink(relativePath, commandFilePath);
580
+ const relPath = path.relative(projectRoot, commandFilePath);
581
+ console.log(
582
+ chalk.green(
583
+ `Created ${relPath} (symlink to .gauntlet/run_gauntlet.md)`,
584
+ ),
585
+ );
586
+ } else {
587
+ // Transform and write the command file
588
+ const transformedContent = adapter.transformCommand(
589
+ GAUNTLET_COMMAND_CONTENT,
590
+ );
591
+ await fs.writeFile(commandFilePath, transformedContent);
592
+ const relPath = isUserLevel
593
+ ? commandFilePath
594
+ : path.relative(projectRoot, commandFilePath);
595
+ console.log(chalk.green(`Created ${relPath}`));
596
+ }
597
+ } catch (error: unknown) {
598
+ const err = error as { message?: string };
599
+ console.log(
600
+ chalk.yellow(
601
+ ` ${adapter.name}: Could not create command - ${err.message}`,
602
+ ),
603
+ );
604
+ }
605
+ }
508
606
  }