@brutalist/mcp 0.1.3 → 0.5.0

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.
@@ -0,0 +1,691 @@
1
+ import { spawn } from 'child_process';
2
+ import { logger } from './logger.js';
3
+ // Configurable timeouts and limits
4
+ const DEFAULT_TIMEOUT = parseInt(process.env.BRUTALIST_TIMEOUT || '300000', 10); // 5 minutes default
5
+ const CLI_CHECK_TIMEOUT = parseInt(process.env.BRUTALIST_CLI_CHECK_TIMEOUT || '5000', 10); // 5 seconds for CLI checks
6
+ const MAX_BUFFER_SIZE = parseInt(process.env.BRUTALIST_MAX_BUFFER || String(10 * 1024 * 1024), 10); // 10MB default
7
+ const MAX_CONCURRENT_CLIS = parseInt(process.env.BRUTALIST_MAX_CONCURRENT || '3', 10); // 3 concurrent CLIs
8
+ // Available models for each CLI
9
+ export const AVAILABLE_MODELS = {
10
+ claude: {
11
+ default: undefined, // Uses user's configured model
12
+ aliases: ['opus', 'sonnet', 'haiku'],
13
+ full: ['claude-opus-4-1-20250805', 'claude-sonnet-4-20250514']
14
+ },
15
+ codex: {
16
+ default: 'gpt-5', // Fast default reasoning
17
+ models: ['gpt-5', 'gpt-5-codex', 'o3', 'o3-mini', 'o3-pro', 'o4-mini']
18
+ },
19
+ gemini: {
20
+ default: 'gemini-2.5-flash', // Best price/performance
21
+ models: ['gemini-2.5-flash', 'gemini-2.5-pro', 'gemini-2.5-flash-lite']
22
+ }
23
+ };
24
+ // Safe command execution helper using spawn instead of exec to prevent command injection
25
+ async function spawnAsync(command, args, options = {}) {
26
+ return new Promise((resolve, reject) => {
27
+ // Use working directory as-is - let CLI tools handle their own sandboxing
28
+ const cwd = options.cwd || process.cwd();
29
+ const child = spawn(command, args, {
30
+ cwd: cwd,
31
+ stdio: ['pipe', 'pipe', 'pipe'],
32
+ shell: false, // CRITICAL: disable shell to prevent injection
33
+ detached: command !== 'gemini', // Disable detached for Gemini CLI to fix macOS sandbox issue
34
+ env: options.env || process.env
35
+ });
36
+ let stdout = '';
37
+ let stderr = '';
38
+ let timedOut = false;
39
+ let killed = false;
40
+ // Set up timeout with SIGKILL escalation
41
+ const timeoutMs = options.timeout || DEFAULT_TIMEOUT;
42
+ const timer = setTimeout(() => {
43
+ timedOut = true;
44
+ // First try SIGTERM
45
+ child.kill('SIGTERM');
46
+ // If still running after 5 seconds, escalate to SIGKILL
47
+ setTimeout(() => {
48
+ if (!killed) {
49
+ try {
50
+ if (command === 'gemini' || process.platform === 'win32') {
51
+ // Gemini runs non-detached, and Windows doesn't support process groups
52
+ child.kill('SIGKILL');
53
+ }
54
+ else {
55
+ // Other CLIs on Unix-like systems: kill process group
56
+ process.kill(-child.pid, 'SIGKILL');
57
+ }
58
+ }
59
+ catch (e) {
60
+ // Process may have already exited
61
+ }
62
+ }
63
+ }, 5000);
64
+ reject(new Error(`Command timed out after ${timeoutMs}ms: ${command} ${args.join(' ')}`));
65
+ }, timeoutMs);
66
+ // Collect output
67
+ // NOTE: maxBuffer (default 10MB) can lead to high memory usage if CLI agents produce large outputs.
68
+ // Consider making this configurable or dynamically adjusting based on expected output size.
69
+ child.stdout?.on('data', (data) => {
70
+ const chunk = data.toString();
71
+ stdout += chunk;
72
+ // Call progress callback if provided
73
+ if (options.onProgress) {
74
+ options.onProgress(chunk, 'stdout');
75
+ }
76
+ if (options.maxBuffer && stdout.length > options.maxBuffer) {
77
+ child.kill('SIGTERM');
78
+ reject(new Error(`stdout exceeded maxBuffer size: ${options.maxBuffer}`));
79
+ }
80
+ });
81
+ child.stderr?.on('data', (data) => {
82
+ const chunk = data.toString();
83
+ stderr += chunk;
84
+ // Call progress callback if provided
85
+ if (options.onProgress) {
86
+ options.onProgress(chunk, 'stderr');
87
+ }
88
+ // Apply same buffer limit to stderr to prevent DoS
89
+ if (options.maxBuffer && stderr.length > options.maxBuffer) {
90
+ child.kill('SIGTERM');
91
+ reject(new Error(`stderr exceeded maxBuffer size: ${options.maxBuffer}`));
92
+ }
93
+ });
94
+ // Handle completion
95
+ child.on('close', (code) => {
96
+ killed = true;
97
+ clearTimeout(timer);
98
+ if (!timedOut) {
99
+ if (code === 0) {
100
+ resolve({ stdout, stderr });
101
+ }
102
+ else {
103
+ const error = new Error(`Command failed with exit code ${code}: ${command} ${args.join(' ')}`);
104
+ error.code = code || undefined;
105
+ error.stdout = stdout;
106
+ error.stderr = stderr;
107
+ reject(error);
108
+ }
109
+ }
110
+ });
111
+ child.on('error', (error) => {
112
+ clearTimeout(timer);
113
+ reject(error);
114
+ });
115
+ // Send input if provided
116
+ if (options.input) {
117
+ child.stdin?.write(options.input);
118
+ child.stdin?.end();
119
+ }
120
+ });
121
+ }
122
+ export class CLIAgentOrchestrator {
123
+ defaultTimeout = 1500000; // 25 minutes - thorough analysis takes time
124
+ defaultWorkingDir = process.cwd();
125
+ cliContext = { availableCLIs: [] };
126
+ cliContextCached = false;
127
+ cliContextCacheTime = 0;
128
+ CLI_CACHE_TTL = 300000; // 5 minutes cache
129
+ runningCLIs = 0; // Track concurrent CLI executions
130
+ MAX_CONCURRENT_CLIS = MAX_CONCURRENT_CLIS; // Configurable concurrency limit
131
+ // Streaming throttle properties
132
+ streamingBuffers = new Map();
133
+ STREAMING_FLUSH_INTERVAL = 200; // 200ms
134
+ MAX_CHUNK_SIZE = 2048; // 2KB per event
135
+ constructor() {
136
+ // Log configuration at startup
137
+ logger.info(`šŸ”§ Brutalist MCP Configuration:`);
138
+ logger.info(` - Default timeout: ${DEFAULT_TIMEOUT}ms`);
139
+ logger.info(` - CLI check timeout: ${CLI_CHECK_TIMEOUT}ms`);
140
+ logger.info(` - Max buffer size: ${MAX_BUFFER_SIZE} bytes`);
141
+ logger.info(` - Max concurrent CLIs: ${MAX_CONCURRENT_CLIS}`);
142
+ // Detect CLI context at startup and cache it
143
+ this.detectCLIContext().catch(error => {
144
+ logger.error("Failed to detect CLI context at startup:", error);
145
+ });
146
+ }
147
+ parseClaudeStreamOutput(chunk, options) {
148
+ // Parse Claude's stream-json output to extract only model content
149
+ try {
150
+ const jsonChunk = JSON.parse(chunk.trim());
151
+ if (jsonChunk.type === 'assistant' && jsonChunk.message?.content) {
152
+ // Extract text content from assistant messages
153
+ const textContent = jsonChunk.message.content
154
+ .filter((c) => c.type === 'text')
155
+ .map((c) => c.text)
156
+ .join('');
157
+ if (textContent.trim()) {
158
+ return textContent;
159
+ }
160
+ }
161
+ // Ignore system messages, init messages, etc.
162
+ return null;
163
+ }
164
+ catch (e) {
165
+ // Not JSON, return as-is for non-streaming mode
166
+ return chunk;
167
+ }
168
+ }
169
+ // Decode Claude's stream-json NDJSON output into plain text
170
+ decodeClaudeStreamJson(ndjsonOutput) {
171
+ if (!ndjsonOutput || !ndjsonOutput.trim()) {
172
+ return '';
173
+ }
174
+ const textParts = [];
175
+ const lines = ndjsonOutput.split('\n');
176
+ for (const line of lines) {
177
+ if (!line.trim())
178
+ continue;
179
+ try {
180
+ const event = JSON.parse(line);
181
+ // Handle different event types from Claude's stream-json format
182
+ if (event.type === 'message' && event.message?.content) {
183
+ // Full message event
184
+ const content = event.message.content;
185
+ if (Array.isArray(content)) {
186
+ for (const item of content) {
187
+ if (item.type === 'text' && item.text) {
188
+ textParts.push(item.text);
189
+ }
190
+ }
191
+ }
192
+ }
193
+ else if (event.type === 'content_block_delta' && event.delta?.text) {
194
+ // Incremental text delta
195
+ textParts.push(event.delta.text);
196
+ }
197
+ else if (event.type === 'assistant' && event.message?.content) {
198
+ // Assistant message format (same as parseClaudeStreamOutput)
199
+ const content = event.message.content;
200
+ if (Array.isArray(content)) {
201
+ for (const item of content) {
202
+ if (item.type === 'text' && item.text) {
203
+ textParts.push(item.text);
204
+ }
205
+ }
206
+ }
207
+ }
208
+ }
209
+ catch {
210
+ // Skip invalid JSON lines
211
+ continue;
212
+ }
213
+ }
214
+ return textParts.join('');
215
+ }
216
+ emitThrottledStreamingEvent(agent, type, content, onStreamingEvent, options) {
217
+ if (!onStreamingEvent)
218
+ return;
219
+ // Filter Claude stream output to only show model content
220
+ let processedContent = content;
221
+ if (agent === 'claude' && options?.progressToken) {
222
+ const filtered = this.parseClaudeStreamOutput(content, options);
223
+ if (!filtered)
224
+ return; // Skip non-content events
225
+ processedContent = filtered;
226
+ }
227
+ const key = `${agent}-${type}`;
228
+ const now = Date.now();
229
+ // Truncate content to prevent huge events
230
+ const truncatedContent = processedContent.length > this.MAX_CHUNK_SIZE
231
+ ? processedContent.substring(0, this.MAX_CHUNK_SIZE) + '...[truncated]'
232
+ : processedContent;
233
+ // Get or create buffer for this agent+type
234
+ if (!this.streamingBuffers.has(key)) {
235
+ this.streamingBuffers.set(key, { chunks: [], lastFlush: now });
236
+ }
237
+ const buffer = this.streamingBuffers.get(key);
238
+ buffer.chunks.push(truncatedContent);
239
+ // For progress notifications, emit immediately and also call onProgress
240
+ if (options?.progressToken && options?.onProgress && type === 'agent_progress') {
241
+ // Estimate progress based on content length (rough approximation)
242
+ const currentProgress = buffer.chunks.length * 10; // rough estimate
243
+ const totalProgress = 100;
244
+ options.onProgress(currentProgress, totalProgress, `${agent.toUpperCase()}: ${truncatedContent.substring(0, 50)}...`);
245
+ }
246
+ // Flush if enough time has passed or buffer is getting large
247
+ if (now - buffer.lastFlush > this.STREAMING_FLUSH_INTERVAL || buffer.chunks.length > 10) {
248
+ const combinedContent = buffer.chunks.join('\n');
249
+ onStreamingEvent({
250
+ type,
251
+ agent,
252
+ content: combinedContent,
253
+ timestamp: now
254
+ });
255
+ // Reset buffer
256
+ buffer.chunks = [];
257
+ buffer.lastFlush = now;
258
+ }
259
+ }
260
+ async detectCLIContext() {
261
+ // Return cached context if still valid
262
+ if (this.cliContextCached && Date.now() - this.cliContextCacheTime < this.CLI_CACHE_TTL) {
263
+ logger.debug('Using cached CLI context');
264
+ return this.cliContext;
265
+ }
266
+ const availableCLIs = [];
267
+ let currentCLI;
268
+ // Check for available CLIs
269
+ const cliChecks = [
270
+ { name: 'claude', command: 'claude --version' },
271
+ { name: 'codex', command: 'codex --version' },
272
+ { name: 'gemini', command: 'gemini --version' }
273
+ ];
274
+ const results = await Promise.allSettled(cliChecks.map(async (check) => {
275
+ try {
276
+ await spawnAsync(check.name, ['--version'], { timeout: CLI_CHECK_TIMEOUT });
277
+ logger.debug(`CLI available: ${check.name}`);
278
+ return check.name;
279
+ }
280
+ catch (error) {
281
+ logger.debug(`CLI not available: ${check.name}`);
282
+ return null;
283
+ }
284
+ }));
285
+ const detectedCLIs = results
286
+ .filter(result => result.status === 'fulfilled' && result.value !== null)
287
+ .map(result => result.value);
288
+ availableCLIs.push(...detectedCLIs);
289
+ // Detect current CLI context from environment or process
290
+ currentCLI = this.detectCurrentCLI();
291
+ this.cliContext = { currentCLI, availableCLIs };
292
+ this.cliContextCached = true;
293
+ this.cliContextCacheTime = Date.now();
294
+ return this.cliContext;
295
+ }
296
+ detectCurrentCLI() {
297
+ // Check environment variables that might indicate current CLI
298
+ if (process.env.CLAUDE_CODE_SESSION || process.env.CLAUDE_CONFIG_DIR) {
299
+ return 'claude';
300
+ }
301
+ if (process.env.CODEX_SESSION || process.env.OPENAI_CODEX_SESSION) {
302
+ return 'codex';
303
+ }
304
+ if (process.env.GEMINI_SESSION || process.env.GEMINI_API_KEY) {
305
+ return 'gemini';
306
+ }
307
+ // Check process parent/ancestry for CLI indicators
308
+ try {
309
+ const processInfo = process.env._;
310
+ if (processInfo?.includes('claude'))
311
+ return 'claude';
312
+ if (processInfo?.includes('codex'))
313
+ return 'codex';
314
+ if (processInfo?.includes('gemini'))
315
+ return 'gemini';
316
+ }
317
+ catch (error) {
318
+ logger.debug('Could not detect current CLI from process info');
319
+ }
320
+ return undefined;
321
+ }
322
+ selectSingleCLI(preferredCLI, analysisType) {
323
+ // 1. Honor explicit preference if available (allow even if current CLI to avoid blocking)
324
+ if (preferredCLI && this.cliContext.availableCLIs.includes(preferredCLI)) {
325
+ logger.info(`āœ… Using preferred CLI: ${preferredCLI}`);
326
+ return preferredCLI;
327
+ }
328
+ // 2. Smart selection based on analysis type
329
+ const selectionRules = {
330
+ 'code': ['claude', 'codex', 'gemini'],
331
+ 'architecture': ['gemini', 'claude', 'codex'],
332
+ 'research': ['claude', 'gemini', 'codex'],
333
+ 'security': ['codex', 'claude', 'gemini'],
334
+ 'data': ['gemini', 'claude', 'codex'],
335
+ 'product': ['claude', 'gemini', 'codex'],
336
+ 'infrastructure': ['gemini', 'codex', 'claude'],
337
+ 'idea': ['claude', 'gemini', 'codex'],
338
+ 'debate': ['claude', 'gemini', 'codex'],
339
+ 'default': ['claude', 'gemini', 'codex']
340
+ };
341
+ const priority = selectionRules[analysisType || 'default'] || selectionRules.default;
342
+ // 3. Filter available CLIs, exclude current CLI only for auto-selection to prevent recursion
343
+ const currentCLI = this.cliContext.currentCLI;
344
+ const candidates = this.cliContext.availableCLIs.filter(cli => cli !== currentCLI);
345
+ // If no candidates after filtering, fall back to available CLIs (allow recursion if necessary)
346
+ const finalCandidates = candidates.length > 0 ? candidates : this.cliContext.availableCLIs;
347
+ if (finalCandidates.length === 0) {
348
+ throw new Error('No CLI agents available');
349
+ }
350
+ // 4. Select by priority
351
+ for (const cli of priority) {
352
+ if (finalCandidates.includes(cli)) {
353
+ const recursionWarning = candidates.length === 0 ? ' (allowing recursion)' : '';
354
+ logger.info(`šŸŽÆ Auto-selected ${cli} for ${analysisType || 'general'} analysis${recursionWarning}`);
355
+ return cli;
356
+ }
357
+ }
358
+ // Fallback to first available
359
+ logger.warn(`āš ļø Using fallback CLI: ${finalCandidates[0]}`);
360
+ return finalCandidates[0];
361
+ }
362
+ async _executeCLI(cliName, userPrompt, systemPromptSpec, options = {}, commandBuilder) {
363
+ const startTime = Date.now();
364
+ const workingDir = options.workingDirectory || this.defaultWorkingDir;
365
+ const timeout = options.timeout || this.defaultTimeout;
366
+ try {
367
+ logger.info(`šŸ¤– Executing ${cliName.toUpperCase()} CLI`);
368
+ logger.debug(`${cliName.toUpperCase()} prompt`, { prompt: userPrompt.substring(0, 100) });
369
+ // Emit agent start event
370
+ if (options.onStreamingEvent) {
371
+ options.onStreamingEvent({
372
+ type: 'agent_start',
373
+ agent: cliName,
374
+ content: `Starting ${cliName.toUpperCase()} analysis...`,
375
+ timestamp: Date.now()
376
+ });
377
+ }
378
+ // WARNING: Claude CLI does not have a native --sandbox flag.
379
+ // If options.sandbox is true, it is assumed that the environment
380
+ // running this Brutalist MCP server provides the sandboxing (e.g., Docker, VM).
381
+ // Running Claude without external sandboxing can be a security risk.
382
+ if (cliName === 'claude' && options.sandbox) {
383
+ logger.warn("āš ļø Claude CLI requested with sandbox: true, but Claude CLI does not support native sandboxing. Ensure external sandboxing is in place.");
384
+ }
385
+ const { command, args, env, input } = commandBuilder(userPrompt, systemPromptSpec, options);
386
+ logger.info(`šŸ“‹ Command: ${command} ${args.join(' ')}`);
387
+ logger.info(`šŸ“ Working directory: ${workingDir}`);
388
+ logger.info(`ā±ļø Timeout: ${timeout}ms`);
389
+ if (input) {
390
+ logger.info(`šŸ“ Using stdin for prompt (${input.length} characters)`);
391
+ }
392
+ const { stdout, stderr } = await spawnAsync(command, args, {
393
+ cwd: workingDir,
394
+ timeout: timeout,
395
+ maxBuffer: MAX_BUFFER_SIZE, // Configurable buffer for model outputs
396
+ env: env,
397
+ input: input,
398
+ onProgress: (chunk, type) => {
399
+ // Stream output in real-time with agent identification
400
+ if (type === 'stdout' && chunk.trim()) {
401
+ logger.info(`šŸ¤– ${cliName.toUpperCase()}: ${chunk.trim()}`);
402
+ // Emit throttled streaming event for real-time updates
403
+ this.emitThrottledStreamingEvent(cliName, 'agent_progress', chunk.trim(), options.onStreamingEvent, options);
404
+ }
405
+ else if (type === 'stderr' && chunk.trim()) {
406
+ logger.warn(`āš ļø ${cliName.toUpperCase()} stderr: ${chunk.trim()}`);
407
+ // Emit throttled error streaming event
408
+ this.emitThrottledStreamingEvent(cliName, 'agent_error', chunk.trim(), options.onStreamingEvent, options);
409
+ }
410
+ }
411
+ });
412
+ logger.info(`āœ… ${cliName.toUpperCase()} completed (${Date.now() - startTime}ms)`);
413
+ // Emit completion event
414
+ if (options.onStreamingEvent) {
415
+ options.onStreamingEvent({
416
+ type: 'agent_complete',
417
+ agent: cliName,
418
+ content: `${cliName.toUpperCase()} analysis completed (${Date.now() - startTime}ms)`,
419
+ timestamp: Date.now()
420
+ });
421
+ }
422
+ // Post-process Claude stream-json output if needed
423
+ let finalOutput = stdout;
424
+ // If Claude was run with stream-json format, decode the NDJSON to extract text
425
+ if (cliName === 'claude' && args.includes('--output-format') && args.includes('stream-json')) {
426
+ const decodedText = this.decodeClaudeStreamJson(stdout);
427
+ if (decodedText) {
428
+ finalOutput = decodedText;
429
+ }
430
+ }
431
+ // Fallback: If stdout is empty but stderr has content and exit was successful,
432
+ // Claude might have written to stderr (common in non-TTY environments)
433
+ if (!finalOutput.trim() && stderr && stderr.trim()) {
434
+ logger.info(`šŸ“ Using stderr as output for ${cliName} (stdout was empty)`);
435
+ finalOutput = stderr;
436
+ }
437
+ return {
438
+ agent: cliName,
439
+ success: true,
440
+ output: finalOutput,
441
+ error: stderr || undefined,
442
+ executionTime: Date.now() - startTime,
443
+ command: `${command} ${args.join(' ')}`,
444
+ workingDirectory: workingDir,
445
+ exitCode: 0
446
+ };
447
+ }
448
+ catch (error) {
449
+ const execError = error;
450
+ const exitCode = execError.code || -1;
451
+ // Detect rate limiting errors for Gemini
452
+ const isRateLimit = cliName === 'gemini' && (execError.stderr?.includes('429') ||
453
+ execError.message?.includes('rateLimitExceeded') ||
454
+ execError.stderr?.includes('rate limit'));
455
+ if (isRateLimit) {
456
+ logger.warn(`ā±ļø ${cliName.toUpperCase()} CLI hit rate limit (${Date.now() - startTime}ms)`);
457
+ }
458
+ else {
459
+ logger.error(`āŒ ${cliName.toUpperCase()} execution failed (${Date.now() - startTime}ms)`, {
460
+ error: "Redacted: See internal logs for full error details.",
461
+ exitCode,
462
+ stderr: "Redacted: See internal logs for full stderr output."
463
+ });
464
+ }
465
+ // Emit error event
466
+ if (options.onStreamingEvent) {
467
+ options.onStreamingEvent({
468
+ type: 'agent_error',
469
+ agent: cliName,
470
+ content: `${cliName.toUpperCase()} failed: ${error instanceof Error ? error.message : String(error)}`,
471
+ timestamp: Date.now()
472
+ });
473
+ }
474
+ return {
475
+ agent: cliName,
476
+ success: false,
477
+ output: '',
478
+ error: error instanceof Error ? error.message : String(error),
479
+ executionTime: Date.now() - startTime,
480
+ command: `(redacted command for ${cliName})`,
481
+ workingDirectory: workingDir,
482
+ exitCode
483
+ };
484
+ }
485
+ }
486
+ async executeClaudeCode(userPrompt, systemPromptSpec, options = {}) {
487
+ return this._executeCLI('claude', userPrompt, systemPromptSpec, options, (userPrompt, systemPromptSpec, options) => {
488
+ const combinedPrompt = `${systemPromptSpec}\n\n${userPrompt}`;
489
+ const args = ['--print'];
490
+ // Enable streaming for real-time progress if progress notifications are enabled
491
+ if (options.progressToken) {
492
+ args.push('--output-format', 'stream-json', '--verbose');
493
+ }
494
+ // Use provided model or let Claude use its default
495
+ const model = options.models?.claude || AVAILABLE_MODELS.claude.default;
496
+ if (model) {
497
+ args.push('--model', model);
498
+ }
499
+ // Pass prompt as argument - Claude CLI works better this way
500
+ args.push(combinedPrompt);
501
+ // Set environment to ensure consistent output behavior
502
+ const env = {
503
+ ...process.env,
504
+ TERM: 'dumb', // Disable fancy terminal output
505
+ NO_COLOR: '1', // Disable colored output
506
+ CI: 'true' // Indicate non-interactive environment
507
+ };
508
+ return { command: 'claude', args, env };
509
+ });
510
+ }
511
+ async executeCodex(userPrompt, systemPromptSpec, options = {}) {
512
+ return this._executeCLI('codex', userPrompt, systemPromptSpec, { ...options, sandbox: true }, // Ensure sandbox is always true for Codex
513
+ (userPrompt, systemPromptSpec, options) => {
514
+ const combinedPrompt = `CONTEXT AND INSTRUCTIONS:\n${systemPromptSpec}\n\nANALYZE:\n${userPrompt}`;
515
+ const args = ['exec'];
516
+ // Use provided model or default to gpt-5
517
+ const model = options.models?.codex || AVAILABLE_MODELS.codex.default;
518
+ args.push('--model', model);
519
+ if (options.sandbox) {
520
+ args.push('--sandbox', 'read-only');
521
+ }
522
+ // Use stdin for the prompt instead of argv to avoid ARG_MAX limits
523
+ return {
524
+ command: 'codex',
525
+ args,
526
+ input: combinedPrompt
527
+ };
528
+ });
529
+ }
530
+ async executeGemini(userPrompt, systemPromptSpec, options = {}) {
531
+ return this._executeCLI('gemini', userPrompt, systemPromptSpec, { ...options, sandbox: true }, // Ensure sandbox is always true for Gemini
532
+ (userPrompt, systemPromptSpec, options) => {
533
+ const args = [];
534
+ // Use provided model or default to gemini-2.5-flash
535
+ const modelName = options.models?.gemini || AVAILABLE_MODELS.gemini.default;
536
+ args.push('--model', modelName);
537
+ if (options.sandbox) {
538
+ args.push('--sandbox');
539
+ }
540
+ const combinedPrompt = `${systemPromptSpec}\n\n${userPrompt}`;
541
+ args.push(combinedPrompt);
542
+ return {
543
+ command: 'gemini',
544
+ args: args,
545
+ env: {
546
+ ...process.env,
547
+ TERM: 'dumb',
548
+ NO_COLOR: '1',
549
+ CI: 'true'
550
+ }
551
+ };
552
+ });
553
+ }
554
+ async executeSingleCLI(cli, userPrompt, systemPromptSpec, options = {}) {
555
+ // Wait for available slot to prevent resource exhaustion
556
+ await this.waitForAvailableSlot();
557
+ this.runningCLIs++;
558
+ logger.info(`šŸŽÆ Executing ${cli} (${this.runningCLIs}/${this.MAX_CONCURRENT_CLIS} slots used)`);
559
+ try {
560
+ switch (cli) {
561
+ case 'claude':
562
+ return await this.executeClaudeCode(userPrompt, systemPromptSpec, options);
563
+ case 'codex':
564
+ return await this.executeCodex(userPrompt, systemPromptSpec, { ...options, sandbox: true });
565
+ case 'gemini':
566
+ return await this.executeGemini(userPrompt, systemPromptSpec, { ...options, sandbox: true });
567
+ default:
568
+ throw new Error(`Unknown CLI: ${cli}`);
569
+ }
570
+ }
571
+ finally {
572
+ this.runningCLIs--;
573
+ logger.info(`āœ… Released CLI slot (${this.runningCLIs}/${this.MAX_CONCURRENT_CLIS} slots used)`);
574
+ }
575
+ }
576
+ async waitForAvailableSlot() {
577
+ let waitTime = 100; // Start with 100ms wait time
578
+ while (this.runningCLIs >= this.MAX_CONCURRENT_CLIS) {
579
+ logger.info(`ā³ Waiting for available CLI slot (${this.runningCLIs}/${this.MAX_CONCURRENT_CLIS} in use). Next check in ${waitTime}ms...`);
580
+ await new Promise(resolve => setTimeout(resolve, waitTime));
581
+ waitTime = Math.min(waitTime * 2, 5000); // Exponential backoff, max 5 seconds
582
+ }
583
+ }
584
+ async executeBrutalistAnalysis(analysisType, targetPath, systemPromptSpec, context, options = {}) {
585
+ const userPrompt = this.constructUserPrompt(analysisType, targetPath, context);
586
+ // If preferred CLI is specified, use single CLI mode
587
+ if (options.preferredCLI) {
588
+ const selectedCLI = this.selectSingleCLI(options.preferredCLI, options.analysisType);
589
+ logger.info(`āœ… Using preferred CLI: ${selectedCLI}`);
590
+ const response = await this.executeSingleCLI(selectedCLI, userPrompt, systemPromptSpec, options);
591
+ return [{
592
+ ...response,
593
+ selectionMethod: 'user-specified',
594
+ analysisType
595
+ }];
596
+ }
597
+ // Multi-CLI execution (default behavior)
598
+ logger.info(`šŸš€ Executing multi-CLI analysis`);
599
+ // Only exclude current CLI if we have other options
600
+ let availableCLIs = [...this.cliContext.availableCLIs];
601
+ if (this.cliContext.currentCLI && this.cliContext.availableCLIs.length > 1) {
602
+ // Exclude current CLI to prevent recursion, but only if we have alternatives
603
+ availableCLIs = availableCLIs.filter(cli => cli !== this.cliContext.currentCLI);
604
+ logger.info(`šŸ”„ Excluding current CLI (${this.cliContext.currentCLI}) to prevent recursion`);
605
+ }
606
+ else if (this.cliContext.currentCLI && this.cliContext.availableCLIs.length === 1) {
607
+ logger.info(`šŸ”„ Using current CLI (${this.cliContext.currentCLI}) - spawning separate process`);
608
+ }
609
+ if (availableCLIs.length === 0) {
610
+ throw new Error('No CLI agents available for analysis');
611
+ }
612
+ logger.info(`šŸ“Š Available CLIs: ${availableCLIs.join(', ')}`);
613
+ // Execute all available CLIs in parallel with allSettled for better error handling
614
+ const promises = availableCLIs.map(async (cli) => {
615
+ try {
616
+ const response = await this.executeSingleCLI(cli, userPrompt, systemPromptSpec, options);
617
+ return {
618
+ ...response,
619
+ selectionMethod: 'multi-cli',
620
+ analysisType
621
+ };
622
+ }
623
+ catch (error) {
624
+ logger.error(`āŒ ${cli} execution failed:`, error);
625
+ return {
626
+ agent: cli,
627
+ success: false,
628
+ output: '',
629
+ error: error instanceof Error ? error.message : String(error),
630
+ executionTime: 0,
631
+ selectionMethod: 'multi-cli',
632
+ analysisType
633
+ };
634
+ }
635
+ });
636
+ // Use allSettled to handle partial failures gracefully
637
+ const results = await Promise.allSettled(promises);
638
+ const responses = results
639
+ .filter(result => result.status === 'fulfilled')
640
+ .map(result => result.value);
641
+ logger.info(`āœ… Multi-CLI analysis complete: ${responses.filter(r => r.success).length}/${responses.length} successful`);
642
+ return responses;
643
+ }
644
+ synthesizeBrutalistFeedback(responses, analysisType) {
645
+ const successfulResponses = responses.filter(r => r.success);
646
+ const failedResponses = responses.filter(r => !r.success);
647
+ if (successfulResponses.length === 0) {
648
+ return `# Brutalist Analysis Failed\n\nāŒ All CLI agents failed to analyze\n${failedResponses.map(r => `- ${r.agent.toUpperCase()}: ${r.error}`).join('\n')}`;
649
+ }
650
+ let synthesis = `${successfulResponses.length} AI critics have systematically demolished your work.\n\n`;
651
+ successfulResponses.forEach((response, index) => {
652
+ synthesis += `## Critic ${index + 1}: ${response.agent.toUpperCase()}\n`;
653
+ synthesis += `*Execution time: ${response.executionTime}ms*\n\n`;
654
+ synthesis += response.output;
655
+ synthesis += '\n\n---\n\n';
656
+ });
657
+ if (failedResponses.length > 0) {
658
+ synthesis += `## Failed Critics\n`;
659
+ synthesis += `${failedResponses.length} critics failed to complete their destruction:\n`;
660
+ failedResponses.forEach(r => {
661
+ synthesis += `- **${r.agent.toUpperCase()}**: ${r.error}\n`;
662
+ });
663
+ synthesis += '\n';
664
+ }
665
+ return synthesis.trim();
666
+ }
667
+ constructUserPrompt(analysisType, targetPath, context) {
668
+ // Trust CLI tools to handle their own security
669
+ const sanitizedTargetPath = targetPath;
670
+ const sanitizedContext = context || 'No additional context provided';
671
+ const prompts = {
672
+ code: `Analyze the codebase at ${sanitizedTargetPath} for issues. Context: ${sanitizedContext}`,
673
+ codebase: `Analyze the codebase directory at ${sanitizedTargetPath} for security vulnerabilities, performance issues, and architectural problems. Context: ${sanitizedContext}`,
674
+ architecture: `Review the architecture: ${sanitizedTargetPath}. Find every scaling failure and cost explosion.`,
675
+ idea: `Analyze this idea: ${sanitizedTargetPath}. Find where imagination fails to become reality.`,
676
+ research: `Review this research: ${sanitizedTargetPath}. Find every methodological flaw and reproducibility issue.`,
677
+ data: `Analyze this data/model: ${sanitizedTargetPath}. Find every overfitting issue, bias, and correlation fallacy.`,
678
+ security: `Security audit of: ${sanitizedTargetPath}. Find every attack vector and vulnerability.`,
679
+ product: `Product review: ${sanitizedTargetPath}. Find every UX disaster and adoption barrier.`,
680
+ infrastructure: `Infrastructure review: ${sanitizedTargetPath}. Find every single point of failure.`,
681
+ debate: `Debate topic: ${sanitizedTargetPath}. Take opposing positions and argue until truth emerges.`,
682
+ fileStructure: `Analyze the directory structure at ${sanitizedTargetPath}. Find organizational disasters and naming failures.`,
683
+ dependencies: `Analyze dependencies at ${sanitizedTargetPath}. Find version conflicts and security vulnerabilities.`,
684
+ gitHistory: `Analyze git history at ${sanitizedTargetPath}. Find commit disasters and workflow failures.`,
685
+ testCoverage: `Analyze test coverage at ${sanitizedTargetPath}. Find testing gaps and quality issues.`
686
+ };
687
+ const specificPrompt = prompts[analysisType] || `Analyze ${sanitizedTargetPath} for ${analysisType} issues.`;
688
+ return `${specificPrompt} ${context ? `Context: ${sanitizedContext}` : ''}`;
689
+ }
690
+ }
691
+ //# sourceMappingURL=cli-agents.js.map