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