agent-gauntlet 0.1.4

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 (44) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +106 -0
  3. package/package.json +51 -0
  4. package/src/cli-adapters/claude.ts +114 -0
  5. package/src/cli-adapters/codex.ts +123 -0
  6. package/src/cli-adapters/gemini.ts +149 -0
  7. package/src/cli-adapters/index.ts +79 -0
  8. package/src/commands/check.test.ts +25 -0
  9. package/src/commands/check.ts +67 -0
  10. package/src/commands/detect.test.ts +37 -0
  11. package/src/commands/detect.ts +69 -0
  12. package/src/commands/health.test.ts +79 -0
  13. package/src/commands/health.ts +148 -0
  14. package/src/commands/help.test.ts +44 -0
  15. package/src/commands/help.ts +24 -0
  16. package/src/commands/index.ts +9 -0
  17. package/src/commands/init.test.ts +105 -0
  18. package/src/commands/init.ts +330 -0
  19. package/src/commands/list.test.ts +104 -0
  20. package/src/commands/list.ts +29 -0
  21. package/src/commands/rerun.ts +118 -0
  22. package/src/commands/review.test.ts +25 -0
  23. package/src/commands/review.ts +67 -0
  24. package/src/commands/run.test.ts +25 -0
  25. package/src/commands/run.ts +64 -0
  26. package/src/commands/shared.ts +10 -0
  27. package/src/config/loader.test.ts +129 -0
  28. package/src/config/loader.ts +130 -0
  29. package/src/config/schema.ts +63 -0
  30. package/src/config/types.ts +23 -0
  31. package/src/config/validator.ts +493 -0
  32. package/src/core/change-detector.ts +112 -0
  33. package/src/core/entry-point.test.ts +63 -0
  34. package/src/core/entry-point.ts +80 -0
  35. package/src/core/job.ts +74 -0
  36. package/src/core/runner.ts +226 -0
  37. package/src/gates/check.ts +82 -0
  38. package/src/gates/result.ts +9 -0
  39. package/src/gates/review.ts +501 -0
  40. package/src/index.ts +38 -0
  41. package/src/output/console.ts +201 -0
  42. package/src/output/logger.ts +66 -0
  43. package/src/utils/log-parser.ts +228 -0
  44. package/src/utils/sanitizer.ts +3 -0
@@ -0,0 +1,149 @@
1
+ import { exec } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { type CLIAdapter, isUsageLimit } from './index.js';
4
+ import fs from 'node:fs/promises';
5
+ import path from 'node:path';
6
+ import os from 'node:os';
7
+
8
+ const execAsync = promisify(exec);
9
+ const MAX_BUFFER_BYTES = 10 * 1024 * 1024;
10
+
11
+ export class GeminiAdapter implements CLIAdapter {
12
+ name = 'gemini';
13
+
14
+ async isAvailable(): Promise<boolean> {
15
+ try {
16
+ await execAsync('which gemini');
17
+ return true;
18
+ } catch {
19
+ return false;
20
+ }
21
+ }
22
+
23
+ async checkHealth(options?: { checkUsageLimit?: boolean }): Promise<{ available: boolean; status: 'healthy' | 'missing' | 'unhealthy'; message?: string }> {
24
+ const available = await this.isAvailable();
25
+ if (!available) {
26
+ return { available: false, status: 'missing', message: 'Command not found' };
27
+ }
28
+
29
+ if (options?.checkUsageLimit) {
30
+ try {
31
+ const { stdout, stderr } = await execAsync('echo "hello" | gemini --sandbox --output-format text', { timeout: 10000 });
32
+
33
+ const combined = (stdout || '') + (stderr || '');
34
+ if (isUsageLimit(combined)) {
35
+ return {
36
+ available: true,
37
+ status: 'unhealthy',
38
+ message: 'Usage limit exceeded'
39
+ };
40
+ }
41
+
42
+ return { available: true, status: 'healthy', message: 'Installed' };
43
+ } catch (error: any) {
44
+ const stderr = error.stderr || '';
45
+ const stdout = error.stdout || '';
46
+ const combined = (stderr + stdout);
47
+
48
+ if (isUsageLimit(combined)) {
49
+ return {
50
+ available: true,
51
+ status: 'unhealthy',
52
+ message: 'Usage limit exceeded'
53
+ };
54
+ }
55
+
56
+ // Since we sent a valid prompt ("hello"), any other error implies the tool is broken
57
+ const cleanError = combined.split('\n')[0]?.trim() || error.message || 'Command failed';
58
+ return {
59
+ available: true,
60
+ status: 'unhealthy',
61
+ message: `Error: ${cleanError}`
62
+ };
63
+ }
64
+ }
65
+
66
+ return {
67
+ available,
68
+ status: available ? 'healthy' : 'missing',
69
+ message: available ? 'Installed' : 'Command not found'
70
+ };
71
+ }
72
+
73
+ getProjectCommandDir(): string | null {
74
+ return '.gemini/commands';
75
+ }
76
+
77
+ getUserCommandDir(): string | null {
78
+ // Gemini supports user-level commands at ~/.gemini/commands
79
+ return path.join(os.homedir(), '.gemini', 'commands');
80
+ }
81
+
82
+ getCommandExtension(): string {
83
+ return '.toml';
84
+ }
85
+
86
+ canUseSymlink(): boolean {
87
+ // Gemini uses TOML format, needs transformation
88
+ return false;
89
+ }
90
+
91
+ transformCommand(markdownContent: string): string {
92
+ // Transform Markdown with YAML frontmatter to Gemini's TOML format
93
+ const { frontmatter, body } = this.parseMarkdownWithFrontmatter(markdownContent);
94
+
95
+ const description = frontmatter.description || 'Run the gauntlet verification suite';
96
+ // Escape the body for TOML multi-line string
97
+ const escapedBody = body.trim();
98
+
99
+ return `description = ${JSON.stringify(description)}
100
+ prompt = """
101
+ ${escapedBody}
102
+ """
103
+ `;
104
+ }
105
+
106
+ private parseMarkdownWithFrontmatter(content: string): { frontmatter: Record<string, string>; body: string } {
107
+ const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
108
+ if (!frontmatterMatch) {
109
+ return { frontmatter: {}, body: content };
110
+ }
111
+
112
+ const frontmatterStr = frontmatterMatch[1] ?? '';
113
+ const body = frontmatterMatch[2] ?? '';
114
+
115
+ // Simple YAML parsing for key: value pairs
116
+ const frontmatter: Record<string, string> = {};
117
+ for (const line of frontmatterStr.split('\n')) {
118
+ const kvMatch = line.match(/^([^:]+):\s*(.*)$/);
119
+ if (kvMatch && kvMatch[1] && kvMatch[2] !== undefined) {
120
+ frontmatter[kvMatch[1].trim()] = kvMatch[2].trim();
121
+ }
122
+ }
123
+
124
+ return { frontmatter, body };
125
+ }
126
+
127
+ async execute(opts: { prompt: string; diff: string; model?: string; timeoutMs?: number }): Promise<string> {
128
+ // Construct the full prompt content
129
+ const fullContent = opts.prompt + "\n\n--- DIFF ---\n" + opts.diff;
130
+
131
+ // Write to a temporary file to avoid shell escaping issues
132
+ const tmpDir = os.tmpdir();
133
+ const tmpFile = path.join(tmpDir, `gauntlet-gemini-${Date.now()}.txt`);
134
+ await fs.writeFile(tmpFile, fullContent);
135
+
136
+ try {
137
+ // Use gemini CLI with file input
138
+ // --sandbox: enables the execution sandbox
139
+ // --allowed-tools: whitelists read-only tools for non-interactive execution
140
+ // --output-format text: ensures plain text output
141
+ // Use < for stdin redirection instead of cat pipe (cleaner)
142
+ const cmd = `gemini --sandbox --allowed-tools read_file,list_directory,glob,search_file_content --output-format text < "${tmpFile}"`;
143
+ const { stdout } = await execAsync(cmd, { timeout: opts.timeoutMs, maxBuffer: MAX_BUFFER_BYTES });
144
+ return stdout;
145
+ } finally {
146
+ await fs.unlink(tmpFile).catch(() => {});
147
+ }
148
+ }
149
+ }
@@ -0,0 +1,79 @@
1
+ export interface CLIAdapterHealth {
2
+ available: boolean;
3
+ status: 'healthy' | 'missing' | 'unhealthy';
4
+ message?: string;
5
+ }
6
+
7
+ export function isUsageLimit(output: string): boolean {
8
+ const lower = output.toLowerCase();
9
+ return lower.includes('usage limit') ||
10
+ lower.includes('quota exceeded') ||
11
+ lower.includes('quota will reset') ||
12
+ lower.includes('credit balance is too low') ||
13
+ lower.includes('out of extra usage') ||
14
+ lower.includes('out of usage');
15
+ }
16
+
17
+ export interface CLIAdapter {
18
+ name: string;
19
+ isAvailable(): Promise<boolean>;
20
+ checkHealth(options?: { checkUsageLimit?: boolean }): Promise<CLIAdapterHealth>;
21
+ execute(opts: { prompt: string; diff: string; model?: string; timeoutMs?: number }): Promise<string>;
22
+ /**
23
+ * Returns the project-scoped command directory path (relative to project root).
24
+ * Returns null if the CLI only supports user-level commands.
25
+ */
26
+ getProjectCommandDir(): string | null;
27
+ /**
28
+ * Returns the user-level command directory path (absolute path).
29
+ * Returns null if the CLI doesn't support user-level commands.
30
+ */
31
+ getUserCommandDir(): string | null;
32
+ /**
33
+ * Returns the command file extension used by this CLI.
34
+ */
35
+ getCommandExtension(): string;
36
+ /**
37
+ * Returns true if this adapter can use symlinks (same format as source Markdown).
38
+ */
39
+ canUseSymlink(): boolean;
40
+ /**
41
+ * Transforms gauntlet command content to this CLI's format.
42
+ * The source content is always Markdown with YAML frontmatter.
43
+ */
44
+ transformCommand(markdownContent: string): string;
45
+ }
46
+
47
+ import { GeminiAdapter } from './gemini.js';
48
+ import { CodexAdapter } from './codex.js';
49
+ import { ClaudeAdapter } from './claude.js';
50
+
51
+ export { GeminiAdapter, CodexAdapter, ClaudeAdapter };
52
+
53
+ const adapters: Record<string, CLIAdapter> = {
54
+ gemini: new GeminiAdapter(),
55
+ codex: new CodexAdapter(),
56
+ claude: new ClaudeAdapter(),
57
+ };
58
+
59
+ export function getAdapter(name: string): CLIAdapter | undefined {
60
+ return adapters[name];
61
+ }
62
+
63
+ export function getAllAdapters(): CLIAdapter[] {
64
+ return Object.values(adapters);
65
+ }
66
+
67
+ /**
68
+ * Returns all adapters that support project-scoped commands.
69
+ */
70
+ export function getProjectCommandAdapters(): CLIAdapter[] {
71
+ return Object.values(adapters).filter(a => a.getProjectCommandDir() !== null);
72
+ }
73
+
74
+ /**
75
+ * Returns all adapters that support user-level commands.
76
+ */
77
+ export function getUserCommandAdapters(): CLIAdapter[] {
78
+ return Object.values(adapters).filter(a => a.getUserCommandDir() !== null);
79
+ }
@@ -0,0 +1,25 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
2
+ import { Command } from 'commander';
3
+ import { registerCheckCommand } from './check.js';
4
+
5
+ describe('Check Command', () => {
6
+ let program: Command;
7
+
8
+ beforeEach(() => {
9
+ program = new Command();
10
+ registerCheckCommand(program);
11
+ });
12
+
13
+ it('should register the check command', () => {
14
+ const checkCmd = program.commands.find(cmd => cmd.name() === 'check');
15
+ expect(checkCmd).toBeDefined();
16
+ expect(checkCmd?.description()).toBe('Run only applicable checks for detected changes');
17
+ });
18
+
19
+ it('should have correct options', () => {
20
+ const checkCmd = program.commands.find(cmd => cmd.name() === 'check');
21
+ expect(checkCmd?.options.some(opt => opt.long === '--gate')).toBe(true);
22
+ expect(checkCmd?.options.some(opt => opt.long === '--commit')).toBe(true);
23
+ expect(checkCmd?.options.some(opt => opt.long === '--uncommitted')).toBe(true);
24
+ });
25
+ });
@@ -0,0 +1,67 @@
1
+ import type { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import { loadConfig } from '../config/loader.js';
4
+ import { ChangeDetector } from '../core/change-detector.js';
5
+ import { EntryPointExpander } from '../core/entry-point.js';
6
+ import { JobGenerator } from '../core/job.js';
7
+ import { Runner } from '../core/runner.js';
8
+ import { Logger } from '../output/logger.js';
9
+ import { ConsoleReporter } from '../output/console.js';
10
+
11
+ export function registerCheckCommand(program: Command): void {
12
+ program
13
+ .command('check')
14
+ .description('Run only applicable checks for detected changes')
15
+ .option('-g, --gate <name>', 'Run specific check gate only')
16
+ .option('-c, --commit <sha>', 'Use diff for a specific commit')
17
+ .option('-u, --uncommitted', 'Use diff for current uncommitted changes (staged and unstaged)')
18
+ .action(async (options) => {
19
+ try {
20
+ const config = await loadConfig();
21
+ const changeDetector = new ChangeDetector(config.project.base_branch, {
22
+ commit: options.commit,
23
+ uncommitted: options.uncommitted
24
+ });
25
+ const expander = new EntryPointExpander();
26
+ const jobGen = new JobGenerator(config);
27
+
28
+ console.log(chalk.dim('Detecting changes...'));
29
+ const changes = await changeDetector.getChangedFiles();
30
+
31
+ if (changes.length === 0) {
32
+ console.log(chalk.green('No changes detected.'));
33
+ process.exit(0);
34
+ }
35
+
36
+ console.log(chalk.dim(`Found ${changes.length} changed files.`));
37
+
38
+ const entryPoints = await expander.expand(config.project.entry_points, changes);
39
+ let jobs = jobGen.generateJobs(entryPoints);
40
+
41
+ // Filter to only checks
42
+ jobs = jobs.filter(j => j.type === 'check');
43
+
44
+ if (options.gate) {
45
+ jobs = jobs.filter(j => j.name === options.gate);
46
+ }
47
+
48
+ if (jobs.length === 0) {
49
+ console.log(chalk.yellow('No applicable checks for these changes.'));
50
+ process.exit(0);
51
+ }
52
+
53
+ console.log(chalk.dim(`Running ${jobs.length} check(s)...`));
54
+
55
+ const logger = new Logger(config.project.log_dir);
56
+ const reporter = new ConsoleReporter();
57
+ const runner = new Runner(config, logger, reporter);
58
+
59
+ const success = await runner.run(jobs);
60
+ process.exit(success ? 0 : 1);
61
+
62
+ } catch (error: any) {
63
+ console.error(chalk.red('Error:'), error.message);
64
+ process.exit(1);
65
+ }
66
+ });
67
+ }
@@ -0,0 +1,37 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
2
+ import { Command } from 'commander';
3
+ import { registerDetectCommand } from './detect.js';
4
+
5
+ describe('Detect Command', () => {
6
+ let program: Command;
7
+ const originalConsoleLog = console.log;
8
+ const originalConsoleError = console.error;
9
+ let logs: string[];
10
+ let errors: string[];
11
+
12
+ beforeEach(() => {
13
+ program = new Command();
14
+ registerDetectCommand(program);
15
+ logs = [];
16
+ errors = [];
17
+ console.log = (...args: any[]) => {
18
+ logs.push(args.join(' '));
19
+ };
20
+ console.error = (...args: any[]) => {
21
+ errors.push(args.join(' '));
22
+ };
23
+ });
24
+
25
+ afterEach(() => {
26
+ console.log = originalConsoleLog;
27
+ console.error = originalConsoleError;
28
+ });
29
+
30
+ it('should register the detect command', () => {
31
+ const detectCmd = program.commands.find(cmd => cmd.name() === 'detect');
32
+ expect(detectCmd).toBeDefined();
33
+ expect(detectCmd?.description()).toBe('Show what gates would run for detected changes (without executing them)');
34
+ expect(detectCmd?.options.some(opt => opt.long === '--commit')).toBe(true);
35
+ expect(detectCmd?.options.some(opt => opt.long === '--uncommitted')).toBe(true);
36
+ });
37
+ });
@@ -0,0 +1,69 @@
1
+ import type { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import { loadConfig } from '../config/loader.js';
4
+ import { ChangeDetector } from '../core/change-detector.js';
5
+ import { EntryPointExpander } from '../core/entry-point.js';
6
+ import { JobGenerator, type Job } from '../core/job.js';
7
+
8
+ export function registerDetectCommand(program: Command): void {
9
+ program
10
+ .command('detect')
11
+ .description('Show what gates would run for detected changes (without executing them)')
12
+ .option('-c, --commit <sha>', 'Use diff for a specific commit')
13
+ .option('-u, --uncommitted', 'Use diff for current uncommitted changes (staged and unstaged)')
14
+ .action(async (options) => {
15
+ try {
16
+ const config = await loadConfig();
17
+ const changeDetector = new ChangeDetector(config.project.base_branch, {
18
+ commit: options.commit,
19
+ uncommitted: options.uncommitted
20
+ });
21
+ const expander = new EntryPointExpander();
22
+ const jobGen = new JobGenerator(config);
23
+
24
+ console.log(chalk.dim('Detecting changes...'));
25
+ const changes = await changeDetector.getChangedFiles();
26
+
27
+ if (changes.length === 0) {
28
+ console.log(chalk.green('No changes detected.'));
29
+ return;
30
+ }
31
+
32
+ console.log(chalk.dim(`Found ${changes.length} changed files:`));
33
+ changes.forEach(file => console.log(chalk.dim(` - ${file}`)));
34
+ console.log();
35
+
36
+ const entryPoints = await expander.expand(config.project.entry_points, changes);
37
+ const jobs = jobGen.generateJobs(entryPoints);
38
+
39
+ if (jobs.length === 0) {
40
+ console.log(chalk.yellow('No applicable gates for these changes.'));
41
+ return;
42
+ }
43
+
44
+ console.log(chalk.bold(`Would run ${jobs.length} gate(s):\n`));
45
+
46
+ // Group jobs by entry point for better display
47
+ const jobsByEntryPoint = new Map<string, Job[]>();
48
+ for (const job of jobs) {
49
+ if (!jobsByEntryPoint.has(job.entryPoint)) {
50
+ jobsByEntryPoint.set(job.entryPoint, []);
51
+ }
52
+ jobsByEntryPoint.get(job.entryPoint)!.push(job);
53
+ }
54
+
55
+ for (const [entryPoint, entryJobs] of jobsByEntryPoint.entries()) {
56
+ console.log(chalk.cyan(`Entry point: ${entryPoint}`));
57
+ for (const job of entryJobs) {
58
+ const typeLabel = job.type === 'check' ? chalk.yellow('check') : chalk.blue('review');
59
+ console.log(` ${typeLabel} ${chalk.bold(job.name)}`);
60
+ }
61
+ console.log();
62
+ }
63
+
64
+ } catch (error: any) {
65
+ console.error(chalk.red('Error:'), error.message);
66
+ process.exit(1);
67
+ }
68
+ });
69
+ }
@@ -0,0 +1,79 @@
1
+ import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from 'bun:test';
2
+ import { Command } from 'commander';
3
+ import { registerHealthCommand } from './health.js';
4
+ import fs from 'node:fs/promises';
5
+ import path from 'node:path';
6
+
7
+ const TEST_DIR = path.join(process.cwd(), 'test-health-' + Date.now());
8
+ const GAUNTLET_DIR = path.join(TEST_DIR, '.gauntlet');
9
+ const REVIEWS_DIR = path.join(GAUNTLET_DIR, 'reviews');
10
+
11
+ describe('Health Command', () => {
12
+ let program: Command;
13
+ const originalConsoleLog = console.log;
14
+ const originalCwd = process.cwd();
15
+ let logs: string[];
16
+
17
+ beforeAll(async () => {
18
+ // Setup test directory structure
19
+ await fs.mkdir(TEST_DIR, { recursive: true });
20
+ await fs.mkdir(GAUNTLET_DIR, { recursive: true });
21
+ await fs.mkdir(REVIEWS_DIR, { recursive: true });
22
+
23
+ // Write config.yml
24
+ await fs.writeFile(path.join(GAUNTLET_DIR, 'config.yml'), `
25
+ base_branch: origin/main
26
+ log_dir: .gauntlet_logs
27
+ cli:
28
+ default_preference:
29
+ - gemini
30
+ check_usage_limit: false
31
+ entry_points:
32
+ - path: .
33
+ `);
34
+
35
+ // Write review definition with CLI preference
36
+ await fs.writeFile(path.join(REVIEWS_DIR, 'security.md'), `---
37
+ cli_preference:
38
+ - gemini
39
+ ---
40
+
41
+ # Security Review
42
+ Review for security.
43
+ `);
44
+ });
45
+
46
+ afterAll(async () => {
47
+ await fs.rm(TEST_DIR, { recursive: true, force: true });
48
+ });
49
+
50
+ beforeEach(() => {
51
+ program = new Command();
52
+ registerHealthCommand(program);
53
+ logs = [];
54
+ console.log = (...args: any[]) => {
55
+ logs.push(args.join(' '));
56
+ };
57
+ process.chdir(TEST_DIR);
58
+ });
59
+
60
+ afterEach(() => {
61
+ console.log = originalConsoleLog;
62
+ process.chdir(originalCwd);
63
+ });
64
+
65
+ it('should register the health command', () => {
66
+ const healthCmd = program.commands.find(cmd => cmd.name() === 'health');
67
+ expect(healthCmd).toBeDefined();
68
+ expect(healthCmd?.description()).toBe('Check CLI tool availability');
69
+ });
70
+
71
+ it('should run health check', async () => {
72
+ const healthCmd = program.commands.find(cmd => cmd.name() === 'health');
73
+ await healthCmd?.parseAsync(['health']);
74
+
75
+ const output = logs.join('\n');
76
+ expect(output).toContain('Config validation:');
77
+ expect(output).toContain('CLI Tool Health Check:');
78
+ });
79
+ });
@@ -0,0 +1,148 @@
1
+ import type { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import path from 'node:path';
4
+ import { loadConfig } from '../config/loader.js';
5
+ import { validateConfig } from '../config/validator.js';
6
+ import { getAllAdapters, getAdapter } from '../cli-adapters/index.js';
7
+
8
+ export function registerHealthCommand(program: Command): void {
9
+ program
10
+ .command('health')
11
+ .description('Check CLI tool availability')
12
+ .action(async () => {
13
+ // 1. Config validation
14
+ console.log(chalk.bold('Config validation:'));
15
+ const validationResult = await validateConfig();
16
+
17
+ if (validationResult.filesChecked.length === 0) {
18
+ console.log(chalk.yellow(' No config files found'));
19
+ } else {
20
+ // List all files checked
21
+ for (const file of validationResult.filesChecked) {
22
+ const relativePath = path.relative(process.cwd(), file);
23
+ console.log(chalk.dim(` ${relativePath}`));
24
+ }
25
+
26
+ // Show validation results
27
+ if (validationResult.valid && validationResult.issues.length === 0) {
28
+ console.log(chalk.green(' ✓ All config files are valid'));
29
+ } else {
30
+ // Group issues by file
31
+ const issuesByFile = new Map<string, typeof validationResult.issues>();
32
+ for (const issue of validationResult.issues) {
33
+ const relativeFile = path.relative(process.cwd(), issue.file);
34
+ if (!issuesByFile.has(relativeFile)) {
35
+ issuesByFile.set(relativeFile, []);
36
+ }
37
+ issuesByFile.get(relativeFile)!.push(issue);
38
+ }
39
+
40
+ // Display issues
41
+ for (const [file, issues] of issuesByFile.entries()) {
42
+ for (const issue of issues) {
43
+ const icon = issue.severity === 'error' ? chalk.red('✗') : chalk.yellow('⚠');
44
+ const fieldInfo = issue.field ? chalk.dim(` (${issue.field})`) : '';
45
+ console.log(` ${icon} ${file}${fieldInfo}`);
46
+ console.log(` ${issue.message}`);
47
+ }
48
+ }
49
+ }
50
+ }
51
+
52
+ console.log();
53
+
54
+ // 2. CLI Tool Health Check
55
+ console.log(chalk.bold('CLI Tool Health Check:'));
56
+
57
+ try {
58
+ const config = await loadConfig();
59
+ const checkUsageLimit = config.project.cli.check_usage_limit;
60
+
61
+ // Check for reviews configuration
62
+ const reviewEntries = Object.entries(config.reviews);
63
+
64
+ if (reviewEntries.length === 0) {
65
+ console.log(chalk.yellow(' No CLI tools configured'));
66
+ console.log(chalk.dim(' No review gates found. Add review gates with cli_preference to check tool availability.'));
67
+ return;
68
+ }
69
+
70
+ // Collect all unique agent names from review gate cli_preference settings
71
+ const preferredAgents = new Set<string>();
72
+ const reviewsWithEmptyPreference: string[] = [];
73
+
74
+ reviewEntries.forEach(([reviewName, review]) => {
75
+ if (!review.cli_preference || review.cli_preference.length === 0) {
76
+ reviewsWithEmptyPreference.push(reviewName);
77
+ } else {
78
+ review.cli_preference.forEach(agent => {
79
+ preferredAgents.add(agent);
80
+ });
81
+ }
82
+ });
83
+
84
+ // Report Empty Preferences (Loader should handle this via default merging, but good to check)
85
+ if (reviewsWithEmptyPreference.length > 0) {
86
+ console.log(chalk.yellow(' ⚠️ Misconfiguration detected:'));
87
+ reviewsWithEmptyPreference.forEach(name => {
88
+ console.log(chalk.yellow(` Review gate "${name}" has empty cli_preference`));
89
+ });
90
+ console.log();
91
+ }
92
+
93
+ // If no agents are configured, show message
94
+ if (preferredAgents.size === 0) {
95
+ console.log(chalk.yellow(' No CLI tools configured'));
96
+ console.log(chalk.dim(' All review gates have empty cli_preference. Add tools to cli_preference to check availability.'));
97
+ return;
98
+ }
99
+
100
+ // Check the configured agents
101
+ for (const agentName of Array.from(preferredAgents).sort()) {
102
+ const adapter = getAdapter(agentName);
103
+ if (adapter) {
104
+ const health = await adapter.checkHealth({ checkUsageLimit });
105
+ let statusStr = '';
106
+
107
+ switch (health.status) {
108
+ case 'healthy':
109
+ statusStr = chalk.green('Installed');
110
+ break;
111
+ case 'missing':
112
+ statusStr = chalk.red('Missing');
113
+ break;
114
+ case 'unhealthy':
115
+ statusStr = chalk.red(`${health.message || 'Unhealthy'}`);
116
+ break;
117
+ }
118
+
119
+ console.log(` ${adapter.name.padEnd(10)} : ${statusStr}`);
120
+ } else {
121
+ console.log(` ${agentName.padEnd(10)} : ${chalk.yellow('Unknown')}`);
122
+ }
123
+ }
124
+ } catch (error: any) {
125
+ // If config can't be loaded, fall back to checking all adapters
126
+ const adapters = getAllAdapters();
127
+ console.log(chalk.dim(' (Config not found, checking all supported agents)'));
128
+
129
+ for (const adapter of adapters) {
130
+ const health = await adapter.checkHealth();
131
+ let statusStr = '';
132
+
133
+ switch (health.status) {
134
+ case 'healthy':
135
+ statusStr = chalk.green('Installed');
136
+ break;
137
+ case 'missing':
138
+ statusStr = chalk.red('Missing');
139
+ break;
140
+ case 'unhealthy':
141
+ statusStr = chalk.red(`${health.message || 'Unhealthy'}`);
142
+ break;
143
+ }
144
+ console.log(` ${adapter.name.padEnd(10)} : ${statusStr}`);
145
+ }
146
+ }
147
+ });
148
+ }