agentaudit 3.9.47 → 3.10.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.
Files changed (4) hide show
  1. package/cli.mjs +586 -1106
  2. package/index.mjs +615 -687
  3. package/package.json +47 -45
  4. package/postinstall.mjs +18 -0
package/cli.mjs CHANGED
@@ -1,18 +1,22 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * AgentAudit CLI — Security scanner for AI tools
3
+ * AgentAudit CLI — Security scanner for AI packages
4
4
  *
5
- * Scan & Audit: scan <url>, audit <url>, discover
6
- * Registry: check <name|url>, lookup <name>
7
- * Setup: status, setup, config
5
+ * Usage:
6
+ * agentaudit Discover local MCP servers
7
+ * agentaudit discover [--quick|--deep] Find MCP servers in AI editors
8
+ * agentaudit scan <repo-url> [--deep] Quick scan (or deep audit with --deep)
9
+ * agentaudit audit <repo-url> Deep LLM-powered security audit
10
+ * agentaudit lookup <name> Look up package in registry
11
+ * agentaudit setup Register + configure API key
8
12
  *
9
- * Global flags: --json, --quiet, --no-color, --provider, --debug, --export
13
+ * Global flags: --json, --quiet, --no-color
10
14
  */
11
15
 
12
16
  import fs from 'fs';
13
17
  import os from 'os';
14
18
  import path from 'path';
15
- import { execSync } from 'child_process';
19
+ import { execSync, execFileSync } from 'child_process';
16
20
  import { createInterface } from 'readline';
17
21
  import { fileURLToPath } from 'url';
18
22
 
@@ -20,62 +24,30 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
20
24
  const SKILL_DIR = path.resolve(__dirname);
21
25
  const REGISTRY_URL = 'https://agentaudit.dev';
22
26
 
23
- // ── Provider resolution ────
24
- function resolveProvider(flagOverride, keys) {
25
- const orModel = process.env.OPENROUTER_MODEL || 'anthropic/claude-sonnet-4';
26
- const ollamaModel = process.env.OLLAMA_MODEL || 'llama3.1';
27
- const ollamaHost = process.env.OLLAMA_HOST || 'http://localhost:11434';
28
- const customUrl = process.env.LLM_API_URL;
29
- const customKey = process.env.LLM_API_KEY;
30
- const customModel = process.env.LLM_MODEL || 'default';
31
-
32
- const providers = {
33
- anthropic: keys.anthropicKey ? { id: 'anthropic', label: 'Anthropic (Claude)', key: keys.anthropicKey } : null,
34
- openai: keys.openaiKey ? { id: 'openai', label: 'OpenAI (GPT-4o)', key: keys.openaiKey } : null,
35
- openrouter: keys.openrouterKey ? { id: 'openrouter', label: `OpenRouter (${orModel})`, key: keys.openrouterKey } : null,
36
- ollama: process.env.OLLAMA_MODEL || process.env.OLLAMA_HOST ? { id: 'ollama', label: `Ollama (${ollamaModel})`, key: null, host: ollamaHost, model: ollamaModel } : null,
37
- custom: customUrl ? { id: 'custom', label: `Custom (${customModel})`, key: customKey, url: customUrl, model: customModel } : null,
38
- };
39
- // Aliases
40
- const aliases = { claude: 'anthropic', gpt: 'openai', 'gpt-4o': 'openai', 'gpt4': 'openai', or: 'openrouter', local: 'ollama' };
41
-
42
- // Priority: --provider flag > AGENTAUDIT_PROVIDER env > config file > model-inferred > auto-detect
43
- const preferred = flagOverride
44
- || process.env.AGENTAUDIT_PROVIDER?.toLowerCase()
45
- || loadConfig()?.preferred_provider
46
- || null;
47
-
48
- if (preferred) {
49
- const resolved = aliases[preferred] || preferred;
50
- const p = providers[resolved];
51
- if (p) return p;
52
- // Preferred provider not available (no API key) — fall through to inference
53
- }
54
-
55
- // Smart inference: if model is set, try to match it to a provider
56
- const activeModel = globalModelOverride || process.env.AGENTAUDIT_MODEL || loadConfig()?.preferred_model;
57
- if (activeModel) {
58
- const lm = activeModel.toLowerCase();
59
- // Direct provider models (no slash = native format)
60
- if (!lm.includes('/')) {
61
- if (lm.startsWith('claude') && providers.anthropic) return providers.anthropic;
62
- if ((lm.startsWith('gpt') || lm.startsWith('o3') || lm.startsWith('o4') || lm.startsWith('o1')) && providers.openai) return providers.openai;
63
- if (providers.ollama && (process.env.OLLAMA_MODEL || process.env.OLLAMA_HOST)) return providers.ollama;
64
- }
65
- // Slash format = OpenRouter convention (provider/model)
66
- if (lm.includes('/') && providers.openrouter) return providers.openrouter;
67
- }
68
-
69
- // Auto-detect priority: Anthropic > OpenAI > OpenRouter > Custom > Ollama (local last — usually weaker)
70
- return providers.anthropic || providers.openai || providers.openrouter || providers.custom || providers.ollama || null;
71
- }
72
-
73
27
  // ── Global flags (set in main before command routing) ────
74
28
  let jsonMode = false;
75
29
  let quietMode = false;
76
- let modelOverride = null; // --model flag or AGENTAUDIT_MODEL env or config
77
- let globalModelOverride = null; // same, but set early for resolveProvider
78
- let llmTimeoutMs = null; // --timeout flag (seconds → ms)
30
+
31
+ // ── LLM Provider Registry ───────────────────────────────
32
+ const LLM_PROVIDERS = [
33
+ // Native APIs (unique formats)
34
+ { key: 'ANTHROPIC_API_KEY', name: 'Anthropic (Claude)', provider: 'anthropic', type: 'anthropic', model: 'claude-sonnet-4-20250514', url: 'https://api.anthropic.com/v1/messages' },
35
+ { key: 'GEMINI_API_KEY', name: 'Google (Gemini)', provider: 'google', type: 'gemini', model: 'gemini-2.5-flash', url: 'https://generativelanguage.googleapis.com/v1beta/models' },
36
+ { key: 'GOOGLE_API_KEY', name: 'Google (Gemini)', provider: 'google', type: 'gemini', model: 'gemini-2.5-flash', url: 'https://generativelanguage.googleapis.com/v1beta/models' },
37
+ // OpenAI-compatible APIs
38
+ { key: 'OPENAI_API_KEY', name: 'OpenAI (GPT-4o)', provider: 'openai', type: 'openai', model: 'gpt-4o', url: 'https://api.openai.com/v1/chat/completions' },
39
+ { key: 'DEEPSEEK_API_KEY', name: 'DeepSeek', provider: 'deepseek', type: 'openai', model: 'deepseek-chat', url: 'https://api.deepseek.com/v1/chat/completions' },
40
+ { key: 'MISTRAL_API_KEY', name: 'Mistral', provider: 'mistral', type: 'openai', model: 'mistral-large-latest', url: 'https://api.mistral.ai/v1/chat/completions' },
41
+ { key: 'GROQ_API_KEY', name: 'Groq', provider: 'groq', type: 'openai', model: 'llama-3.3-70b-versatile', url: 'https://api.groq.com/openai/v1/chat/completions' },
42
+ { key: 'XAI_API_KEY', name: 'xAI (Grok)', provider: 'xai', type: 'openai', model: 'grok-3', url: 'https://api.x.ai/v1/chat/completions' },
43
+ { key: 'TOGETHER_API_KEY', name: 'Together AI', provider: 'together', type: 'openai', model: 'meta-llama/Llama-3.3-70B-Instruct-Turbo', url: 'https://api.together.xyz/v1/chat/completions' },
44
+ { key: 'FIREWORKS_API_KEY', name: 'Fireworks AI', provider: 'fireworks', type: 'openai', model: 'accounts/fireworks/models/llama-v3p3-70b-instruct', url: 'https://api.fireworks.ai/inference/v1/chat/completions' },
45
+ { key: 'CEREBRAS_API_KEY', name: 'Cerebras', provider: 'cerebras', type: 'openai', model: 'llama-3.3-70b', url: 'https://api.cerebras.ai/v1/chat/completions' },
46
+ { key: 'ZAI_API_KEY', name: 'Zhipu AI (GLM)', provider: 'zhipu', type: 'openai', model: 'glm-4.7', url: 'https://api.z.ai/api/paas/v4/chat/completions' },
47
+ { key: 'ZHIPUAI_API_KEY', name: 'Zhipu AI (GLM)', provider: 'zhipu', type: 'openai', model: 'glm-4.7', url: 'https://api.z.ai/api/paas/v4/chat/completions' },
48
+ // Meta-provider (routes to any model)
49
+ { key: 'OPENROUTER_API_KEY', name: 'OpenRouter', provider: 'openrouter', type: 'openai', model: 'anthropic/claude-sonnet-4', url: 'https://openrouter.ai/api/v1/chat/completions' },
50
+ ];
79
51
 
80
52
  // ── ANSI Colors (respects NO_COLOR and --no-color) ───────
81
53
 
@@ -137,55 +109,47 @@ function loadCredentials() {
137
109
  return null;
138
110
  }
139
111
 
140
- function saveCredentials(data) {
141
- const json = JSON.stringify(data, null, 2);
112
+ function loadLlmConfig() {
113
+ for (const f of [SKILL_CRED_FILE, USER_CRED_FILE]) {
114
+ if (fs.existsSync(f)) {
115
+ try {
116
+ const data = JSON.parse(fs.readFileSync(f, 'utf8'));
117
+ if (data.llm_model) return { llm_model: data.llm_model };
118
+ } catch {}
119
+ }
120
+ }
121
+ return null;
122
+ }
123
+
124
+ function saveLlmConfig(model) {
125
+ // Merge into existing credentials
126
+ let existing = {};
127
+ if (fs.existsSync(USER_CRED_FILE)) {
128
+ try { existing = JSON.parse(fs.readFileSync(USER_CRED_FILE, 'utf8')); } catch {}
129
+ }
130
+ existing.llm_model = model;
131
+ const json = JSON.stringify(existing, null, 2);
142
132
  fs.mkdirSync(USER_CRED_DIR, { recursive: true });
143
133
  fs.writeFileSync(USER_CRED_FILE, json, { mode: 0o600 });
144
134
  try {
135
+ let skillExisting = {};
136
+ if (fs.existsSync(SKILL_CRED_FILE)) {
137
+ try { skillExisting = JSON.parse(fs.readFileSync(SKILL_CRED_FILE, 'utf8')); } catch {}
138
+ }
139
+ skillExisting.llm_model = model;
145
140
  fs.mkdirSync(path.dirname(SKILL_CRED_FILE), { recursive: true });
146
- fs.writeFileSync(SKILL_CRED_FILE, json, { mode: 0o600 });
141
+ fs.writeFileSync(SKILL_CRED_FILE, JSON.stringify(skillExisting, null, 2), { mode: 0o600 });
147
142
  } catch {}
148
143
  }
149
144
 
150
- const USER_CONFIG_FILE = path.join(USER_CRED_DIR, 'config.json');
151
- const USER_STATS_FILE = path.join(USER_CRED_DIR, 'stats-cache.json');
152
-
153
- function loadStatsCache() {
154
- try { return fs.existsSync(USER_STATS_FILE) ? JSON.parse(fs.readFileSync(USER_STATS_FILE, 'utf8')) : null; } catch { return null; }
155
- }
156
-
157
- function saveStatsCache(stats) {
158
- try { fs.mkdirSync(USER_CRED_DIR, { recursive: true }); fs.writeFileSync(USER_STATS_FILE, JSON.stringify({ ...stats, _ts: Date.now() })); } catch {}
159
- }
160
-
161
- async function refreshStatsCache(agentName) {
162
- try {
163
- const lbRes = await fetch(`${REGISTRY_URL}/api/leaderboard`, { signal: AbortSignal.timeout(5000) });
164
- if (!lbRes.ok) return null;
165
- const agents = await lbRes.json();
166
- const idx = Array.isArray(agents) ? agents.findIndex(a => (a.agent_name || '').toLowerCase() === agentName.toLowerCase()) : -1;
167
- if (idx < 0) return null;
168
- const me = agents[idx];
169
- const stats = { rank: idx + 1, total: agents.length, pts: me.total_points || 0, reports: me.total_reports || 0, official: !!me.is_official };
170
- saveStatsCache(stats);
171
- return stats;
172
- } catch { return null; }
173
- }
174
-
175
- function loadConfig() {
145
+ function saveCredentials(data) {
146
+ const json = JSON.stringify(data, null, 2);
147
+ fs.mkdirSync(USER_CRED_DIR, { recursive: true });
148
+ fs.writeFileSync(USER_CRED_FILE, json, { mode: 0o600 });
176
149
  try {
177
- if (fs.existsSync(USER_CONFIG_FILE)) {
178
- return JSON.parse(fs.readFileSync(USER_CONFIG_FILE, 'utf8'));
179
- }
150
+ fs.mkdirSync(path.dirname(SKILL_CRED_FILE), { recursive: true });
151
+ fs.writeFileSync(SKILL_CRED_FILE, json, { mode: 0o600 });
180
152
  } catch {}
181
- return {};
182
- }
183
-
184
- function saveConfig(data) {
185
- const existing = loadConfig();
186
- const merged = { ...existing, ...data };
187
- fs.mkdirSync(USER_CRED_DIR, { recursive: true });
188
- fs.writeFileSync(USER_CONFIG_FILE, JSON.stringify(merged, null, 2), { mode: 0o600 });
189
153
  }
190
154
 
191
155
  function askQuestion(question) {
@@ -298,6 +262,77 @@ function multiSelect(items, { title = 'Select items', hint = 'Space=toggle ↑
298
262
  });
299
263
  }
300
264
 
265
+ /**
266
+ * Interactive single-select in terminal. No dependencies.
267
+ * items: [{ label, sublabel?, value }]
268
+ * Returns: selected value (or null if cancelled)
269
+ */
270
+ function singleSelect(items, { title = 'Select', hint = '↑↓=move Enter=select Esc=cancel' } = {}) {
271
+ return new Promise((resolve) => {
272
+ if (!process.stdin.isTTY) {
273
+ resolve(items[0]?.value || null);
274
+ return;
275
+ }
276
+
277
+ let cursor = 0;
278
+
279
+ const render = () => {
280
+ process.stdout.write(`\x1b[${items.length + 3}A\x1b[J`);
281
+ draw();
282
+ };
283
+
284
+ const draw = () => {
285
+ console.log(` ${c.bold}${title}${c.reset}`);
286
+ console.log(` ${c.dim}${hint}${c.reset}`);
287
+ console.log();
288
+ for (let i = 0; i < items.length; i++) {
289
+ const item = items[i];
290
+ const isCursor = i === cursor;
291
+ const pointer = isCursor ? `${c.cyan}❯${c.reset}` : ' ';
292
+ const label = isCursor ? `${c.bold}${c.cyan}${item.label}${c.reset}` : item.label;
293
+ const sub = item.sublabel ? ` ${c.dim}${item.sublabel}${c.reset}` : '';
294
+ console.log(` ${pointer} ${label}${sub}`);
295
+ }
296
+ };
297
+
298
+ draw();
299
+
300
+ process.stdin.setRawMode(true);
301
+ process.stdin.resume();
302
+ process.stdin.setEncoding('utf8');
303
+
304
+ const onData = (key) => {
305
+ if (key === '\x03' || key === '\x1b') {
306
+ process.stdin.setRawMode(false);
307
+ process.stdin.pause();
308
+ process.stdin.removeListener('data', onData);
309
+ console.log();
310
+ resolve(null);
311
+ return;
312
+ }
313
+ if (key === '\r' || key === '\n') {
314
+ process.stdin.setRawMode(false);
315
+ process.stdin.pause();
316
+ process.stdin.removeListener('data', onData);
317
+ resolve(items[cursor].value);
318
+ return;
319
+ }
320
+ if (key === '\x1b[A' || key === 'k') {
321
+ cursor = (cursor - 1 + items.length) % items.length;
322
+ render();
323
+ return;
324
+ }
325
+ if (key === '\x1b[B' || key === 'j') {
326
+ cursor = (cursor + 1) % items.length;
327
+ render();
328
+ return;
329
+ }
330
+ };
331
+
332
+ process.stdin.on('data', onData);
333
+ });
334
+ }
335
+
301
336
  async function registerAgent(agentName) {
302
337
  const res = await fetch(`${REGISTRY_URL}/api/register`, {
303
338
  method: 'POST',
@@ -354,8 +389,6 @@ async function setupCommand() {
354
389
  console.log(` ${icons.safe} Registered as ${c.bold}${data.agent_name}${c.reset}`);
355
390
  console.log(` ${c.dim}Key: ${data.api_key.slice(0, 12)}...${c.reset}`);
356
391
  console.log(` ${c.dim}Saved to: ${USER_CRED_FILE}${c.reset}`);
357
- // Initialize stats cache
358
- refreshStatsCache(data.agent_name).catch(() => {});
359
392
  } catch (err) {
360
393
  console.log(` ${c.red}failed${c.reset}`);
361
394
  console.log(` ${c.red}${err.message}${c.reset}`);
@@ -364,6 +397,34 @@ async function setupCommand() {
364
397
  }
365
398
 
366
399
  console.log();
400
+
401
+ // ── LLM model configuration ──
402
+ const llmConfig = loadLlmConfig();
403
+ console.log(` ${c.bold}LLM Model Configuration${c.reset}`);
404
+ console.log(` ${c.dim}Used for deep audits. Requires an LLM API key in your environment.${c.reset}`);
405
+ console.log();
406
+ if (llmConfig) {
407
+ console.log(` ${icons.safe} Current model: ${c.bold}${llmConfig.llm_model}${c.reset}`);
408
+ console.log();
409
+ }
410
+ console.log(` ${c.dim}Examples:${c.reset}`);
411
+ console.log(` ${c.dim}claude-sonnet-4-20250514${c.reset} ${c.dim}(Anthropic)${c.reset}`);
412
+ console.log(` ${c.dim}gpt-4o${c.reset} ${c.dim}(OpenAI)${c.reset}`);
413
+ console.log(` ${c.dim}gemini-2.5-flash${c.reset} ${c.dim}(Google)${c.reset}`);
414
+ console.log(` ${c.dim}qwen/qwen3.5-coder-32b${c.reset} ${c.dim}(OpenRouter)${c.reset}`);
415
+ console.log(` ${c.dim}deepseek-chat${c.reset} ${c.dim}(DeepSeek)${c.reset}`);
416
+ console.log();
417
+ const modelAnswer = await askQuestion(` Model ${c.dim}(Enter to keep default, or type model name)${c.reset}: `);
418
+ if (modelAnswer) {
419
+ saveLlmConfig(modelAnswer);
420
+ console.log(` ${icons.safe} Model set to ${c.bold}${modelAnswer}${c.reset}`);
421
+ } else if (llmConfig) {
422
+ console.log(` ${c.dim}Keeping: ${llmConfig.llm_model}${c.reset}`);
423
+ } else {
424
+ console.log(` ${c.dim}No custom model — will use provider default${c.reset}`);
425
+ }
426
+ console.log();
427
+
367
428
  console.log(` ${c.bold}Ready!${c.reset} You can now:`);
368
429
  console.log(` ${c.dim}•${c.reset} Discover servers: ${c.cyan}agentaudit discover${c.reset}`);
369
430
  console.log(` ${c.dim}•${c.reset} Audit packages: ${c.cyan}agentaudit audit <repo-url>${c.reset} ${c.dim}(deep LLM analysis)${c.reset}`);
@@ -373,30 +434,27 @@ async function setupCommand() {
373
434
  console.log();
374
435
  }
375
436
 
376
- // ── Structured error output ─────────────────────────────
437
+ // ── Helpers ──────────────────────────────────────────────
377
438
 
378
- function emitError(code, message, hint, exitCode = 2) {
379
- if (jsonMode) {
380
- process.stderr.write(JSON.stringify({ error: true, code, message, hint: hint || undefined, exitCode }) + '\n');
439
+ function validateGitUrl(url) {
440
+ // Reject URLs with shell metacharacters to prevent command injection
441
+ if (/[;&|`$(){}!\n\r]/.test(url)) {
442
+ throw new Error(`Rejected URL with suspicious characters: ${url.slice(0, 80)}`);
443
+ }
444
+ // Must look like a URL (http/https/git/ssh) or a GitHub shorthand
445
+ if (!/^(https?:\/\/|git@|git:\/\/|ssh:\/\/)/.test(url) && !/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/.test(url)) {
446
+ throw new Error(`Invalid repository URL: ${url.slice(0, 80)}`);
381
447
  }
382
- process.exitCode = exitCode;
383
448
  }
384
449
 
385
- // ── Levenshtein distance for typo correction ────────────
386
-
387
- function levenshtein(a, b) {
388
- const m = a.length, n = b.length;
389
- const dp = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
390
- for (let i = 0; i <= m; i++) dp[i][0] = i;
391
- for (let j = 0; j <= n; j++) dp[0][j] = j;
392
- for (let i = 1; i <= m; i++)
393
- for (let j = 1; j <= n; j++)
394
- dp[i][j] = Math.min(dp[i-1][j] + 1, dp[i][j-1] + 1, dp[i-1][j-1] + (a[i-1] !== b[j-1] ? 1 : 0));
395
- return dp[m][n];
450
+ function safeGitClone(url, destPath, timeoutMs = 30_000) {
451
+ validateGitUrl(url);
452
+ execFileSync('git', ['clone', '--depth', '1', url, destPath], {
453
+ timeout: timeoutMs,
454
+ stdio: 'pipe',
455
+ });
396
456
  }
397
457
 
398
- // ── Helpers ──────────────────────────────────────────────
399
-
400
458
  function getVersion() {
401
459
  try {
402
460
  const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8'));
@@ -404,38 +462,17 @@ function getVersion() {
404
462
  } catch { return '0.0.0'; }
405
463
  }
406
464
 
407
- async function banner() {
465
+ function banner() {
408
466
  if (quietMode || jsonMode) return;
409
- const creds = loadCredentials();
410
- const agentInfo = creds?.agent_name ? ` ${c.dim}·${c.reset} ${c.green}${creds.agent_name}${c.reset}` : '';
411
467
  console.log();
412
- console.log(` 🛡 ${c.bold}${c.cyan}AgentAudit${c.reset} ${c.dim}v${getVersion()}${c.reset}${agentInfo}`);
413
- if (creds?.agent_name) {
414
- let cached = loadStatsCache();
415
- // Auto-fetch if no cache or cache older than 1h
416
- if (!cached || !cached._ts || (Date.now() - cached._ts > 3600000)) {
417
- cached = await refreshStatsCache(creds.agent_name).catch(() => null) || cached;
418
- }
419
- if (cached && cached.rank) {
420
- const medal = cached.rank === 1 ? '🥇' : cached.rank === 2 ? '🥈' : cached.rank === 3 ? '🥉' : `#${cached.rank}`;
421
- console.log(` ${c.dim}${medal} of ${cached.total} · ${c.reset}${c.cyan}${cached.pts}${c.reset}${c.dim} pts · ${cached.reports} audits${c.reset}`);
422
- }
423
- } else {
424
- console.log(` ${c.dim}Security scanner for AI tools${c.reset}`);
425
- }
468
+ console.log(` ${c.bold}${c.cyan}AgentAudit${c.reset} ${c.dim}v${getVersion()}${c.reset}`);
469
+ console.log(` ${c.dim}Security scanner for AI packages${c.reset}`);
426
470
  console.log();
427
471
  }
428
472
 
429
473
  function slugFromUrl(url) {
430
474
  const match = url.match(/github\.com\/([^/]+)\/([^/.\s]+)/);
431
- if (match) {
432
- const owner = match[1].toLowerCase().replace(/[^a-z0-9-]/g, '-');
433
- const repo = match[2].toLowerCase().replace(/[^a-z0-9-]/g, '-');
434
- // Generic repo names get owner prefix to avoid collisions
435
- const generic = ['mcp', 'server', 'plugin', 'tool', 'agent', 'sdk', 'api', 'app', 'cli', 'lib', 'core'];
436
- if (generic.includes(repo)) return `${owner}-${repo}`;
437
- return repo;
438
- }
475
+ if (match) return match[2].toLowerCase().replace(/[^a-z0-9-]/g, '-');
439
476
  return url.replace(/[^a-z0-9]/gi, '-').toLowerCase().slice(0, 60);
440
477
  }
441
478
 
@@ -474,6 +511,46 @@ function severityIcon(sev) {
474
511
 
475
512
  // ── File Collection (same logic as MCP server) ──────────
476
513
 
514
+ function formatApiError(error, provider, statusCode) {
515
+ // Extract error message from various API response formats
516
+ const msg = (typeof error === 'string' ? error : error?.message || error?.error?.message || JSON.stringify(error)).toLowerCase();
517
+
518
+ // Authentication errors
519
+ if (statusCode === 401 || statusCode === 403 || msg.includes('invalid api key') || msg.includes('invalid x-api-key') ||
520
+ msg.includes('incorrect api key') || msg.includes('authentication') || msg.includes('unauthorized') ||
521
+ msg.includes('invalid_api_key') || msg.includes('permission denied')) {
522
+ return { text: 'Invalid or expired API key', hint: `Check your ${provider} API key. Run: echo $${provider}_API_KEY` };
523
+ }
524
+
525
+ // Rate limits / quota
526
+ if (statusCode === 429 || msg.includes('rate limit') || msg.includes('rate_limit') || msg.includes('too many requests') ||
527
+ msg.includes('quota') || msg.includes('insufficient_quota') || msg.includes('billing') ||
528
+ msg.includes('exceeded') || msg.includes('no credits') || msg.includes('credit') ||
529
+ msg.includes('overloaded') || msg.includes('capacity')) {
530
+ return { text: 'Rate limit or quota exceeded', hint: 'Wait a moment and retry, or check your billing/credits at your provider dashboard' };
531
+ }
532
+
533
+ // Model not found
534
+ if (statusCode === 404 || msg.includes('not found') || msg.includes('not a valid model') ||
535
+ msg.includes('model_not_found') || msg.includes('does not exist') || msg.includes('invalid model')) {
536
+ return { text: 'Model not found', hint: `"${msg}" — check model name with: agentaudit model` };
537
+ }
538
+
539
+ // Context length / payload too large
540
+ if (statusCode === 413 || msg.includes('context length') || msg.includes('too long') ||
541
+ msg.includes('maximum') || msg.includes('token limit') || msg.includes('content_too_large')) {
542
+ return { text: 'Input too large for model', hint: 'The repository has too many files. Try a smaller repo or a model with larger context window' };
543
+ }
544
+
545
+ // Server errors
546
+ if (statusCode >= 500) {
547
+ return { text: `Provider server error (HTTP ${statusCode})`, hint: `${provider} might be experiencing issues. Try again later` };
548
+ }
549
+
550
+ // Fallback
551
+ return null;
552
+ }
553
+
477
554
  function extractJSON(text) {
478
555
  // 1. Try parsing the entire text as JSON directly
479
556
  try { return JSON.parse(text.trim()); } catch {}
@@ -526,60 +603,36 @@ function extractJSON(text) {
526
603
 
527
604
  const MAX_FILE_SIZE = 50_000;
528
605
  const MAX_TOTAL_SIZE = 300_000;
529
- // Directories safe to skip: dependencies, caches, build artifacts, editor config.
530
- // SECURITY RULE: If a directory can contain source code, workflow files, or
531
- // prose (prompt injection), it MUST be scanned.
532
- // Reviewed 2026-02-17:
533
- // - RESTORED: test/tests/__tests__/spec/specs/e2e (malware in test hooks)
534
- // - RESTORED: .github (workflow injection, supply chain attacks)
535
- // - RESTORED: examples/example (hidden backdoors in "example" code)
536
- // - RESTORED: docs/doc (prompt injection in documentation)
537
- // - RESTORED: fixtures (test data can contain malicious payloads)
538
606
  const SKIP_DIRS = new Set([
539
- 'node_modules', '.git', '__pycache__', '.venv', 'venv', 'vendor',
540
- 'dist', 'build', '.next', '.nuxt',
541
- 'coverage', '.pytest_cache', '.mypy_cache', '.tox', '.eggs', 'htmlcov',
542
- '.vscode', '.idea',
607
+ 'node_modules', '.git', '__pycache__', '.venv', 'venv', 'dist', 'build',
608
+ '.next', '.nuxt', 'coverage', '.pytest_cache', '.mypy_cache', 'vendor',
609
+ 'test', 'tests', '__tests__', 'spec', 'specs', 'docs', 'doc',
610
+ 'examples', 'example', 'fixtures', '.github', '.vscode', '.idea',
611
+ 'e2e', 'benchmark', 'benchmarks', '.tox', '.eggs', 'htmlcov',
543
612
  ]);
544
613
  const SKIP_EXTENSIONS = new Set([
545
614
  '.lock', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.ico', '.woff',
546
615
  '.woff2', '.ttf', '.eot', '.mp3', '.mp4', '.zip', '.tar', '.gz',
547
616
  '.map', '.min.js', '.min.css', '.d.ts', '.pyc', '.pyo', '.so',
548
617
  '.dylib', '.dll', '.exe', '.bin', '.dat', '.db', '.sqlite',
549
- '.snap', '.patch', '.diff', '.log', '.csv', '.tsv', '.parquet',
550
- ]);
551
- // Files safe to skip: ONLY inert line-based config that cannot execute code
552
- // and cannot contain prompt injections (no prose, no markdown, no scripts).
553
- // SECURITY RULE: When in doubt, SCAN IT. False positives > false negatives.
554
- // Reviewed 2026-02-17: All .md files removed from skip list (prompt injection vector).
555
- // All executable configs removed (malware vector). Only line-based dotfiles remain.
556
- const SKIP_FILES = new Set([
557
- '.gitignore', '.gitattributes', '.npmignore', '.dockerignore',
558
- '.editorconfig', '.browserslistrc', '.nvmrc', '.node-version',
559
- '.prettierignore', '.eslintignore',
560
618
  ]);
561
619
 
562
- function collectFiles(dir, basePath = '', collected = [], totalSize = { bytes: 0, truncated: false, skippedPaths: [] }) {
563
- if (totalSize.bytes >= MAX_TOTAL_SIZE) { totalSize.truncated = true; return collected; }
620
+ function collectFiles(dir, basePath = '', collected = [], totalSize = { bytes: 0 }) {
621
+ if (totalSize.bytes >= MAX_TOTAL_SIZE) return collected;
564
622
  let entries;
565
623
  try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
566
624
  catch { return collected; }
567
625
  entries.sort((a, b) => a.name.localeCompare(b.name));
568
626
  for (const entry of entries) {
627
+ if (totalSize.bytes >= MAX_TOTAL_SIZE) break;
569
628
  const relPath = basePath ? `${basePath}/${entry.name}` : entry.name;
570
- if (totalSize.bytes >= MAX_TOTAL_SIZE) { totalSize.truncated = true; totalSize.skippedPaths.push(relPath); continue; }
571
629
  const fullPath = path.join(dir, entry.name);
572
- // SECURITY: Never follow symlinks — attacker could link to /etc/passwd or ~/.ssh/
573
- if (entry.isSymbolicLink()) continue;
574
630
  if (entry.isDirectory()) {
575
- // Allow .github (workflow security), skip other dot-dirs (editor/system config)
576
- if (SKIP_DIRS.has(entry.name)) continue;
577
- if (entry.name.startsWith('.') && entry.name !== '.github') continue;
631
+ if (SKIP_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
578
632
  collectFiles(fullPath, relPath, collected, totalSize);
579
633
  } else {
580
634
  const ext = path.extname(entry.name).toLowerCase();
581
635
  if (SKIP_EXTENSIONS.has(ext)) continue;
582
- if (SKIP_FILES.has(entry.name.toLowerCase())) continue;
583
636
  try {
584
637
  const stat = fs.statSync(fullPath);
585
638
  if (stat.size > MAX_FILE_SIZE || stat.size === 0) continue;
@@ -924,10 +977,7 @@ async function scanRepo(url) {
924
977
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentaudit-'));
925
978
  const repoPath = path.join(tmpDir, 'repo');
926
979
  try {
927
- execSync(`git clone --depth 1 "${url}" "${repoPath}"`, {
928
- timeout: 30_000,
929
- stdio: 'pipe',
930
- });
980
+ safeGitClone(url, repoPath);
931
981
  } catch (err) {
932
982
  if (!jsonMode) {
933
983
  process.stdout.write(` ${c.red}✖ clone failed${c.reset}\n`);
@@ -1437,9 +1487,7 @@ async function auditRepo(url) {
1437
1487
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentaudit-'));
1438
1488
  const repoPath = path.join(tmpDir, 'repo');
1439
1489
  try {
1440
- execSync(`git clone --depth 1 "${url}" "${repoPath}"`, {
1441
- timeout: 30_000, stdio: 'pipe',
1442
- });
1490
+ safeGitClone(url, repoPath);
1443
1491
  console.log(` ${c.green}done${c.reset}`);
1444
1492
  } catch (err) {
1445
1493
  console.log(` ${c.red}failed${c.reset}`);
@@ -1449,161 +1497,62 @@ async function auditRepo(url) {
1449
1497
  return null;
1450
1498
  }
1451
1499
 
1452
- // Capture version + commit from cloned repo
1453
- let repoCommitSha = null;
1454
- let repoPackageVersion = null;
1455
- try { repoCommitSha = execSync('git rev-parse HEAD', { cwd: repoPath, stdio: 'pipe' }).toString().trim(); } catch {}
1456
- try {
1457
- const pkgPath = path.join(repoPath, 'package.json');
1458
- if (fs.existsSync(pkgPath)) {
1459
- repoPackageVersion = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')).version || null;
1460
- }
1461
- } catch {}
1462
-
1463
1500
  // Step 2: Collect files
1464
1501
  process.stdout.write(` ${c.dim}[2/4]${c.reset} Collecting source files...`);
1465
- const _collectMeta = { bytes: 0, truncated: false, skippedPaths: [] };
1466
- const files = collectFiles(repoPath, '', [], _collectMeta);
1502
+ const files = collectFiles(repoPath);
1467
1503
  console.log(` ${c.green}${files.length} files${c.reset}`);
1468
- if (_collectMeta.truncated) {
1469
- console.log(` ${c.yellow}⚠ Size limit reached (${(MAX_TOTAL_SIZE / 1000).toFixed(0)}KB) — ${_collectMeta.skippedPaths.length} files NOT collected:${c.reset}`);
1470
- const shown = _collectMeta.skippedPaths.slice(0, 5);
1471
- for (const p of shown) console.log(` ${c.dim} • ${p}${c.reset}`);
1472
- if (_collectMeta.skippedPaths.length > 5) console.log(` ${c.dim} ... and ${_collectMeta.skippedPaths.length - 5} more${c.reset}`);
1473
- }
1474
-
1475
- // Step 3: Resolve provider + model FIRST (needed for dynamic chunk sizing)
1476
- const anthropicKey = process.env.ANTHROPIC_API_KEY;
1477
- const openaiKey = process.env.OPENAI_API_KEY;
1478
- const openrouterKey = process.env.OPENROUTER_API_KEY;
1479
- const openrouterModel = process.env.OPENROUTER_MODEL || 'anthropic/claude-sonnet-4';
1480
- const providerFlag = process.argv.find(a => a.startsWith('--provider='))?.split('=')[1]?.toLowerCase()
1481
- || (process.argv.includes('--provider') ? process.argv[process.argv.indexOf('--provider') + 1]?.toLowerCase() : null);
1482
- const resolvedProvider = resolveProvider(providerFlag, { anthropicKey, openaiKey, openrouterKey });
1483
- // Determine actual model name
1484
- let actualModel;
1485
- if (!resolvedProvider) {
1486
- actualModel = 'unknown';
1487
- } else if (resolvedProvider.id === 'anthropic') {
1488
- actualModel = modelOverride || 'claude-sonnet-4-20250514';
1489
- } else if (resolvedProvider.id === 'openrouter') {
1490
- actualModel = modelOverride || process.env.OPENROUTER_MODEL || 'anthropic/claude-sonnet-4';
1491
- } else if (resolvedProvider.id === 'openai') {
1492
- actualModel = modelOverride || 'gpt-4o';
1493
- } else if (resolvedProvider.id === 'ollama') {
1494
- actualModel = modelOverride || resolvedProvider.model;
1495
- } else {
1496
- actualModel = modelOverride || resolvedProvider.model || 'unknown';
1497
- }
1498
-
1499
- // Step 3b: Determine model context for dynamic chunk sizing
1500
- let modelContextTokens = 64_000; // conservative default
1501
- let outputTokenBudget = 4096;
1502
1504
 
1503
- if (resolvedProvider) {
1504
- if (resolvedProvider.id === 'openrouter') {
1505
- try {
1506
- const modelInfoRes = await fetch(`https://openrouter.ai/api/v1/models`, {
1507
- signal: AbortSignal.timeout(5000),
1508
- headers: { 'HTTP-Referer': 'https://agentaudit.dev' },
1509
- });
1510
- if (modelInfoRes.ok) {
1511
- const modelData = await modelInfoRes.json();
1512
- const modelInfo = modelData.data?.find(m => m.id === actualModel);
1513
- if (modelInfo?.context_length) {
1514
- modelContextTokens = modelInfo.context_length;
1515
- }
1516
- }
1517
- } catch { /* ignore — use default */ }
1518
- } else if (resolvedProvider.id === 'anthropic') {
1519
- modelContextTokens = 200_000;
1520
- } else if (resolvedProvider.id === 'openai') {
1521
- modelContextTokens = 128_000;
1522
- } else if (resolvedProvider.id === 'ollama') {
1523
- modelContextTokens = 32_000;
1524
- }
1525
- }
1526
-
1527
- outputTokenBudget = modelContextTokens >= 128_000 ? 8192 : modelContextTokens >= 64_000 ? 4096 : modelContextTokens >= 32_000 ? 2048 : 2048;
1528
- const dynamicChunkChars = Math.floor(modelContextTokens * 0.5 * 4);
1529
- const MAX_CHUNK_CHARS = Math.max(40_000, Math.min(dynamicChunkChars, 600_000));
1530
-
1531
- // Step 3c: Build audit payload
1505
+ // Step 3: Build audit payload
1532
1506
  process.stdout.write(` ${c.dim}[3/4]${c.reset} Preparing audit payload...`);
1533
1507
  const auditPrompt = loadAuditPrompt();
1534
- // Sort files by directory to keep related files in the same chunk.
1535
- // This preserves cross-file context (imports, shared modules) within each pass.
1536
- const sortedFiles = [...files].sort((a, b) => {
1537
- const dirA = a.path.includes('/') ? a.path.substring(0, a.path.lastIndexOf('/')) : '';
1538
- const dirB = b.path.includes('/') ? b.path.substring(0, b.path.lastIndexOf('/')) : '';
1539
- return dirA.localeCompare(dirB) || a.path.localeCompare(b.path);
1540
- });
1541
- const chunks = []; // array of code block strings
1542
- const chunkFileNames = []; // track which files are in each chunk for error reporting
1543
- let currentChunk = '';
1544
- let currentChars = 0;
1545
- let currentFiles = [];
1546
- for (const file of sortedFiles) {
1547
- const entry = `\n### FILE: ${file.path}\n\`\`\`\n${file.content}\n\`\`\`\n`;
1548
- if (currentChars + entry.length > MAX_CHUNK_CHARS && currentChars > 0) {
1549
- chunks.push(currentChunk);
1550
- chunkFileNames.push([...currentFiles]);
1551
- currentFiles = [];
1552
- currentChunk = '';
1553
- currentChars = 0;
1554
- }
1555
- // If a single file exceeds chunk limit, truncate it
1556
- if (entry.length > MAX_CHUNK_CHARS) {
1557
- const truncContent = file.content.substring(0, MAX_CHUNK_CHARS - 200);
1558
- currentChunk += `\n### FILE: ${file.path}\n\`\`\`\n${truncContent}\n[... file truncated, ${file.content.length} chars total ...]\n\`\`\`\n`;
1559
- currentChars += MAX_CHUNK_CHARS;
1560
- } else {
1561
- currentChunk += entry;
1562
- currentChars += entry.length;
1563
- }
1564
- currentFiles.push(file.path);
1565
- }
1566
- if (currentChunk) { chunks.push(currentChunk); chunkFileNames.push([...currentFiles]); }
1567
1508
 
1568
- const needsMultiPass = chunks.length > 1;
1569
- if (needsMultiPass) {
1570
- console.log(` ${c.green}done${c.reset} ${c.yellow}(${chunks.length} passes needed — ${files.length} files across ${chunks.length} chunks)${c.reset}`);
1571
- } else {
1572
- console.log(` ${c.green}done${c.reset}`);
1509
+ let codeBlock = '';
1510
+ for (const file of files) {
1511
+ codeBlock += `\n### FILE: ${file.path}\n\`\`\`\n${file.content}\n\`\`\`\n`;
1573
1512
  }
1574
- // For single-pass, use the only chunk as codeBlock
1575
- const codeBlock = chunks[0] || '';
1513
+ console.log(` ${c.green}done${c.reset}`);
1576
1514
 
1577
1515
  // Step 4: LLM Analysis
1578
- const activeProvider = resolvedProvider?.label || null;
1579
-
1580
- if (!resolvedProvider) {
1516
+ // Detect provider from environment variables (first match wins)
1517
+ const activeLlm = LLM_PROVIDERS.find(p => process.env[p.key]);
1518
+ const llmApiKey = activeLlm ? process.env[activeLlm.key] : null;
1519
+ const activeProvider = activeLlm ? activeLlm.name : null;
1520
+
1521
+ // Model override: --model flag > AGENTAUDIT_MODEL env > credentials.json > provider default
1522
+ const modelArgIdx = process.argv.indexOf('--model');
1523
+ const modelFlag = modelArgIdx !== -1 ? process.argv[modelArgIdx + 1] : null;
1524
+ const modelEnv = process.env.AGENTAUDIT_MODEL;
1525
+ const modelConfig = loadLlmConfig()?.llm_model;
1526
+ const modelOverride = modelFlag || modelEnv || modelConfig || null;
1527
+ if (activeLlm && modelOverride) {
1528
+ activeLlm.model = modelOverride;
1529
+ }
1530
+
1531
+ if (!activeLlm) {
1581
1532
  // No LLM API key — clear explanation
1582
1533
  console.log();
1583
- console.log(` ${c.yellow}No LLM provider configured.${c.reset} The ${c.bold}audit${c.reset} command needs an LLM to analyze code.`);
1534
+ console.log(` ${c.yellow}No LLM API key found.${c.reset} The ${c.bold}audit${c.reset} command needs an LLM to analyze code.`);
1584
1535
  console.log();
1585
- console.log(` ${c.bold}Option 1: Set an API key${c.reset} ${c.dim}(any one of these)${c.reset}`);
1586
- console.log(` ${c.cyan}ANTHROPIC_API_KEY${c.reset} Anthropic Claude ${c.dim}(recommended)${c.reset}`);
1536
+ console.log(` ${c.bold}Option 1: Set an API key${c.reset} (any one of these):`);
1537
+ console.log(` ${c.cyan}ANTHROPIC_API_KEY${c.reset} Anthropic Claude`);
1587
1538
  console.log(` ${c.cyan}OPENAI_API_KEY${c.reset} OpenAI GPT-4o`);
1588
- console.log(` ${c.cyan}OPENROUTER_API_KEY${c.reset} OpenRouter ${c.dim}(200+ models)${c.reset}`);
1589
- console.log(` ${c.cyan}OLLAMA_MODEL${c.reset} Ollama ${c.dim}(local, free, set model name)${c.reset}`);
1590
- console.log(` ${c.cyan}LLM_API_URL${c.reset} Any OpenAI-compatible API ${c.dim}(+ LLM_API_KEY, LLM_MODEL)${c.reset}`);
1539
+ console.log(` ${c.cyan}GEMINI_API_KEY${c.reset} Google Gemini`);
1540
+ console.log(` ${c.cyan}DEEPSEEK_API_KEY${c.reset} DeepSeek`);
1541
+ console.log(` ${c.cyan}MISTRAL_API_KEY${c.reset} Mistral`);
1542
+ console.log(` ${c.cyan}XAI_API_KEY${c.reset} xAI Grok`);
1543
+ console.log(` ${c.cyan}GROQ_API_KEY${c.reset} Groq`);
1544
+ console.log(` ${c.cyan}TOGETHER_API_KEY${c.reset} Together AI`);
1545
+ console.log(` ${c.cyan}FIREWORKS_API_KEY${c.reset} Fireworks AI`);
1546
+ console.log(` ${c.cyan}CEREBRAS_API_KEY${c.reset} Cerebras`);
1547
+ console.log(` ${c.cyan}ZAI_API_KEY${c.reset} Zhipu AI (GLM)`);
1548
+ console.log(` ${c.cyan}OPENROUTER_API_KEY${c.reset} OpenRouter (any model)`);
1591
1549
  console.log();
1592
- console.log(` ${c.dim}# Linux / macOS:${c.reset}`);
1593
1550
  console.log(` ${c.dim}export ANTHROPIC_API_KEY=sk-ant-...${c.reset}`);
1594
1551
  console.log(` ${c.dim}export OPENAI_API_KEY=sk-...${c.reset}`);
1595
1552
  console.log();
1596
- console.log(` ${c.dim}# Windows (PowerShell):${c.reset}`);
1597
- console.log(` ${c.dim}$env:ANTHROPIC_API_KEY = "sk-ant-..."${c.reset}`);
1598
- console.log(` ${c.dim}$env:OPENAI_API_KEY = "sk-..."${c.reset}`);
1599
- console.log();
1600
- console.log(` ${c.dim}# Windows (CMD):${c.reset}`);
1601
- console.log(` ${c.dim}set ANTHROPIC_API_KEY=sk-ant-...${c.reset}`);
1602
- console.log(` ${c.dim}set OPENAI_API_KEY=sk-...${c.reset}`);
1603
- console.log();
1604
1553
  console.log(` ${c.bold}Option 2: Export for manual review${c.reset}`);
1605
1554
  console.log(` ${c.cyan}agentaudit audit ${url} --export${c.reset}`);
1606
- console.log(` ${c.dim}Creates a markdown file you can paste into any LLM (Claude, ChatGPT, etc.)${c.reset}`);
1555
+ console.log(` ${c.dim}Creates a markdown file you can paste into any LLM${c.reset}`);
1607
1556
  console.log();
1608
1557
  console.log(` ${c.bold}Option 3: Use MCP in Claude/Cursor/Windsurf (no API key needed)${c.reset}`);
1609
1558
  console.log(` ${c.dim}Add AgentAudit as MCP server — your editor's agent runs the audit using its own LLM.${c.reset}`);
@@ -1643,319 +1592,181 @@ async function auditRepo(url) {
1643
1592
  return null;
1644
1593
  }
1645
1594
 
1646
- // actualModel already resolved in Step 3
1647
-
1648
- // ── LLM call helper (reused for multi-pass) ──
1649
- async function callLLM(codeContent, passLabel) {
1650
- const systemPrompt = auditPrompt || 'You are a security auditor. Analyze the code and report findings as JSON.';
1651
- const userMessage = [
1652
- `Audit this package: **${slug}** (${url})`,
1653
- ``,
1654
- `After analysis, respond with ONLY a valid JSON object. No markdown fences, no explanation, no text before or after. Just the raw JSON:`,
1655
- `{ "skill_slug": "${slug}", "source_url": "${url}", "package_type": "<mcp-server|agent-skill|library|cli-tool>",`,
1656
- ` "risk_score": <0-100>, "result": "<safe|caution|unsafe>", "max_severity": "<none|low|medium|high|critical>",`,
1657
- ` "findings_count": <n>, "findings": [{ "id": "...", "title": "...", "severity": "...", "category": "...",`,
1658
- ` "description": "...", "file": "...", "line": <n>, "remediation": "...", "confidence": "...", "is_by_design": false }] }`,
1659
- ``,
1660
- `## Source Code`,
1661
- codeContent,
1662
- ].join('\n');
1663
-
1664
- let _lastLlmText = '';
1665
- let result = null;
1666
- let meta = {};
1667
-
1668
- try {
1669
- if (resolvedProvider.id === 'anthropic') {
1670
- const res = await fetch('https://api.anthropic.com/v1/messages', {
1671
- method: 'POST',
1672
- headers: {
1673
- 'x-api-key': resolvedProvider.key,
1674
- 'anthropic-version': '2023-06-01',
1675
- 'content-type': 'application/json',
1676
- },
1677
- body: JSON.stringify({
1678
- model: modelOverride || 'claude-sonnet-4-20250514',
1679
- max_tokens: outputTokenBudget,
1680
- system: systemPrompt,
1681
- messages: [{ role: 'user', content: userMessage }],
1682
- }),
1683
- signal: AbortSignal.timeout(llmTimeoutMs || 180_000),
1684
- });
1685
- const data = await res.json();
1686
- if (data.error) {
1687
- return { error: data.error.message || JSON.stringify(data.error) };
1688
- }
1689
- const text = data.content?.[0]?.text || '';
1690
- _lastLlmText = text;
1691
- result = extractJSON(text);
1692
- meta = {
1693
- provider_msg_id: data.id || null,
1694
- input_tokens: data.usage?.input_tokens || null,
1695
- output_tokens: data.usage?.output_tokens || null,
1696
- reported_model: data.model || null,
1697
- };
1698
- } else {
1699
- let apiUrl, modelName, authHeaders;
1700
- switch (resolvedProvider.id) {
1701
- case 'openrouter':
1702
- apiUrl = 'https://openrouter.ai/api/v1/chat/completions';
1703
- modelName = modelOverride || process.env.OPENROUTER_MODEL || 'anthropic/claude-sonnet-4';
1704
- authHeaders = { 'Authorization': `Bearer ${resolvedProvider.key}`, 'HTTP-Referer': 'https://agentaudit.dev', 'X-Title': 'AgentAudit' };
1705
- break;
1706
- case 'ollama':
1707
- apiUrl = `${resolvedProvider.host}/v1/chat/completions`;
1708
- modelName = modelOverride || resolvedProvider.model;
1709
- authHeaders = {};
1710
- break;
1711
- case 'custom':
1712
- apiUrl = resolvedProvider.url.endsWith('/chat/completions') ? resolvedProvider.url : `${resolvedProvider.url.replace(/\/$/, '')}/chat/completions`;
1713
- modelName = modelOverride || resolvedProvider.model;
1714
- authHeaders = resolvedProvider.key ? { 'Authorization': `Bearer ${resolvedProvider.key}` } : {};
1715
- break;
1716
- default:
1717
- apiUrl = 'https://api.openai.com/v1/chat/completions';
1718
- modelName = modelOverride || 'gpt-4o';
1719
- authHeaders = { 'Authorization': `Bearer ${resolvedProvider.key}` };
1720
- }
1595
+ // We have an API key — run LLM audit
1596
+ const modelLabel = modelOverride ? `${activeProvider} → ${activeLlm.model}` : activeProvider;
1597
+ process.stdout.write(` ${c.dim}[4/4]${c.reset} Running LLM analysis ${c.dim}(${modelLabel})${c.reset}...`);
1598
+
1599
+ const systemPrompt = auditPrompt || 'You are a security auditor. Analyze the code and report findings as JSON.';
1600
+ const userMessage = [
1601
+ `Audit this package: **${slug}** (${url})`,
1602
+ ``,
1603
+ `After analysis, respond with ONLY a valid JSON object. No markdown fences, no explanation, no text before or after. Just the raw JSON:`,
1604
+ `{ "skill_slug": "${slug}", "source_url": "${url}", "package_type": "<mcp-server|agent-skill|library|cli-tool>",`,
1605
+ ` "risk_score": <0-100>, "result": "<safe|caution|unsafe>", "max_severity": "<none|low|medium|high|critical>",`,
1606
+ ` "findings_count": <n>, "findings": [{ "id": "...", "title": "...", "severity": "...", "category": "...",`,
1607
+ ` "description": "...", "file": "...", "line": <n>, "remediation": "...", "confidence": "...", "is_by_design": false }] }`,
1608
+ ``,
1609
+ `## Source Code`,
1610
+ codeBlock,
1611
+ ].join('\n');
1721
1612
 
1722
- const res = await fetch(apiUrl, {
1723
- method: 'POST',
1724
- headers: { 'Content-Type': 'application/json', ...authHeaders },
1725
- body: JSON.stringify({
1726
- model: modelName,
1727
- max_tokens: outputTokenBudget,
1728
- messages: [
1729
- { role: 'system', content: systemPrompt },
1730
- { role: 'user', content: userMessage },
1731
- ],
1732
- }),
1733
- signal: AbortSignal.timeout(llmTimeoutMs || (resolvedProvider.id === 'ollama' ? 300_000 : 180_000)),
1734
- });
1735
- const data = await res.json();
1736
- if (data.error) {
1737
- return { error: data.error.message || JSON.stringify(data.error) };
1738
- }
1739
- const text = data.choices?.[0]?.message?.content || '';
1740
- _lastLlmText = text;
1741
- result = extractJSON(text);
1742
- meta = {
1743
- provider_msg_id: data.id || null,
1744
- provider_fingerprint: data.system_fingerprint || null,
1745
- input_tokens: data.usage?.prompt_tokens || null,
1746
- output_tokens: data.usage?.completion_tokens || null,
1747
- reported_model: data.model || null,
1748
- };
1749
- }
1750
- } catch (err) {
1751
- return { error: err.message };
1752
- }
1753
-
1754
- return { report: result, meta, rawText: _lastLlmText };
1755
- }
1756
-
1757
- // ── Run LLM analysis (single or multi-pass) ──
1758
1613
  let report = null;
1759
- let providerMeta = {};
1760
1614
  let _lastLlmText = '';
1761
-
1762
- if (needsMultiPass) {
1763
- // Multi-pass: analyze each chunk, merge findings
1764
- console.log(` ${c.dim}[4/4]${c.reset} Running LLM analysis ${c.dim}(${resolvedProvider.id}: ${actualModel})${c.reset} — ${c.yellow}${chunks.length} passes${c.reset}`);
1765
- const allFindings = [];
1766
- let totalInput = 0, totalOutput = 0;
1767
- let lastMeta = {};
1768
- let baseReport = null;
1769
-
1770
- for (let i = 0; i < chunks.length; i++) {
1771
- process.stdout.write(` ${c.dim} Pass ${i + 1}/${chunks.length}...${c.reset}`);
1772
- const passStart = Date.now();
1773
- const result = await callLLM(chunks[i], `pass ${i + 1}`);
1774
-
1775
- if (result.error) {
1776
- const failedFiles = chunkFileNames[i] || [];
1777
- console.log(` ${c.red}failed${c.reset} ${c.dim}(${result.error.slice(0, 80)})${c.reset}`);
1778
- if (failedFiles.length > 0) {
1779
- console.log(` ${c.yellow}⚠ ${failedFiles.length} files NOT analyzed:${c.reset} ${c.dim}${failedFiles.slice(0, 5).join(', ')}${failedFiles.length > 5 ? ` (+${failedFiles.length - 5} more)` : ''}${c.reset}`);
1615
+
1616
+ try {
1617
+ let data;
1618
+ if (activeLlm.type === 'anthropic') {
1619
+ // Anthropic Messages API (unique format)
1620
+ const res = await fetch(activeLlm.url, {
1621
+ method: 'POST',
1622
+ headers: {
1623
+ 'x-api-key': llmApiKey,
1624
+ 'anthropic-version': '2023-06-01',
1625
+ 'content-type': 'application/json',
1626
+ },
1627
+ body: JSON.stringify({
1628
+ model: activeLlm.model,
1629
+ max_tokens: 8192,
1630
+ system: systemPrompt,
1631
+ messages: [{ role: 'user', content: userMessage }],
1632
+ }),
1633
+ signal: AbortSignal.timeout(120_000),
1634
+ });
1635
+ data = await res.json();
1636
+ if (data.error) {
1637
+ console.log(` ${c.red}failed${c.reset}`);
1638
+ const friendly = formatApiError(data.error, activeLlm.provider, res.status);
1639
+ if (friendly) {
1640
+ console.log(` ${c.red}${friendly.text}${c.reset}`);
1641
+ console.log(` ${c.dim}${friendly.hint}${c.reset}`);
1642
+ } else {
1643
+ console.log(` ${c.red}API error: ${data.error.message || JSON.stringify(data.error)}${c.reset}`);
1780
1644
  }
1781
- continue;
1782
- }
1783
-
1784
- if (!result.report) {
1785
- console.log(` ${c.yellow}no findings (empty/unparseable)${c.reset}`);
1786
- _lastLlmText = result.rawText || '';
1787
- continue;
1645
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
1646
+ return null;
1788
1647
  }
1789
-
1790
- const passElapsed = ((Date.now() - passStart) / 1000).toFixed(1);
1791
- const passFindings = result.report.findings?.length || 0;
1792
- console.log(` ${c.green}done${c.reset} ${c.dim}(${passElapsed}s, ${passFindings} findings)${c.reset}`);
1793
-
1794
- if (!baseReport) baseReport = result.report;
1795
- if (result.report.findings) allFindings.push(...result.report.findings);
1796
- lastMeta = result.meta;
1797
- totalInput += result.meta.input_tokens || 0;
1798
- totalOutput += result.meta.output_tokens || 0;
1799
- }
1800
-
1801
- if (!baseReport) {
1802
- console.log(` ${c.red}✖ All passes failed to produce a report${c.reset}`);
1803
- try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
1804
- return null;
1805
- }
1806
-
1807
- // Merge: deduplicate findings by title+file, recalculate risk score
1808
- const seen = new Set();
1809
- const mergedFindings = [];
1810
- for (const f of allFindings) {
1811
- const key = `${f.title}::${f.file || ''}`;
1812
- if (!seen.has(key)) {
1813
- seen.add(key);
1814
- mergedFindings.push(f);
1648
+ _lastLlmText = data.content?.[0]?.text || '';
1649
+ report = extractJSON(_lastLlmText);
1650
+ if (report) {
1651
+ report.audit_model = data.model || activeLlm.model;
1652
+ report.audit_provider = activeLlm.provider;
1653
+ if (data.id) report.provider_msg_id = data.id;
1654
+ if (data.usage) {
1655
+ report.input_tokens = data.usage.input_tokens;
1656
+ report.output_tokens = data.usage.output_tokens;
1657
+ }
1815
1658
  }
1816
- }
1817
-
1818
- // Recalculate severity-based risk
1819
- const sevWeights = { critical: 25, high: 15, medium: 5, low: 1 };
1820
- const mergedRisk = Math.min(100, mergedFindings.reduce((s, f) => s + (sevWeights[f.severity] || 0), 0));
1821
- const maxSev = mergedFindings.length === 0 ? 'none' :
1822
- mergedFindings.some(f => f.severity === 'critical') ? 'critical' :
1823
- mergedFindings.some(f => f.severity === 'high') ? 'high' :
1824
- mergedFindings.some(f => f.severity === 'medium') ? 'medium' : 'low';
1825
-
1826
- report = {
1827
- ...baseReport,
1828
- findings: mergedFindings,
1829
- findings_count: mergedFindings.length,
1830
- risk_score: mergedRisk,
1831
- result: mergedRisk === 0 ? 'safe' : mergedRisk <= 20 ? 'caution' : 'unsafe',
1832
- max_severity: maxSev,
1833
- };
1834
- providerMeta = { ...lastMeta, input_tokens: totalInput || null, output_tokens: totalOutput || null };
1835
-
1836
- console.log(` ${c.dim} Merged: ${mergedFindings.length} unique findings from ${chunks.length} passes${c.reset}`);
1837
-
1838
- // ── Cross-file correlation pass ──
1839
- // Build lightweight import/export map and ask LLM to check for multi-file attack patterns
1840
- // that individual chunk passes couldn't detect (e.g., credential read in file A + exfil in file B)
1841
- process.stdout.write(` ${c.dim} Cross-file correlation...${c.reset}`);
1842
- try {
1843
- const importMap = sortedFiles.map(f => {
1844
- const imports = [];
1845
- const exports = [];
1846
- // JS/TS imports
1847
- for (const m of f.content.matchAll(/(?:import|require)\s*\(?['"]([^'"]+)['"]\)?/g)) imports.push(m[1]);
1848
- for (const m of f.content.matchAll(/(?:from)\s+['"]([^'"]+)['"]/g)) imports.push(m[1]);
1849
- // Python imports
1850
- for (const m of f.content.matchAll(/(?:from|import)\s+([\w.]+)/g)) imports.push(m[1]);
1851
- // Exports
1852
- for (const m of f.content.matchAll(/(?:module\.exports|export\s+(?:default\s+)?(?:function|class|const|let|var)\s+)(\w+)/g)) exports.push(m[1]);
1853
- // Dangerous function calls (brief)
1854
- const dangerousCalls = [];
1855
- if (/\b(?:exec|spawn|execSync|system|eval|Function)\s*\(/.test(f.content)) dangerousCalls.push('exec/eval');
1856
- if (/\b(?:fetch|https?\.request|axios|got)\s*\(/.test(f.content)) dangerousCalls.push('network');
1857
- if (/\b(?:readFile|writeFile|createReadStream|open)\s*\(/.test(f.content)) dangerousCalls.push('fs');
1858
- if (/process\.env|os\.environ|getenv/.test(f.content)) dangerousCalls.push('env-read');
1859
- return { path: f.path, imports: [...new Set(imports)].slice(0, 10), exports: exports.slice(0, 10), calls: dangerousCalls };
1860
- }).filter(f => f.imports.length > 0 || f.exports.length > 0 || f.calls.length > 0);
1861
-
1862
- if (importMap.length > 2) {
1863
- const correlationPrompt = [
1864
- `You previously analyzed ${chunks.length} code chunks from package "${slug}" (${url}).`,
1865
- `Here is a cross-file map showing imports, exports, and dangerous function calls.`,
1866
- `Check for MULTI-FILE ATTACK PATTERNS that individual chunk analysis could miss:`,
1867
- `- File A reads credentials/env → File B sends them to network (credential exfiltration pipeline)`,
1868
- `- File A defines a function with exec/eval → File B calls it with user input (indirect RCE)`,
1869
- `- Config file grants broad permissions → Code file exploits them`,
1870
- `- Install hook in scripts/ triggers code in src/ that exfiltrates data`,
1871
- ``,
1872
- `Respond with ONLY a JSON object: { "cross_file_findings": [...] } where each finding has:`,
1873
- `{ "title": "...", "severity": "...", "description": "...", "file": "...", "confidence": "...", "pattern_id": "CORR_001", "remediation": "..." }`,
1874
- `If no cross-file issues found, respond: { "cross_file_findings": [] }`,
1875
- ``,
1876
- `## File Map`,
1877
- JSON.stringify(importMap, null, 2),
1878
- ].join('\n');
1879
-
1880
- const corrResult = await callLLM(correlationPrompt, 'correlation');
1881
- if (corrResult.report?.cross_file_findings?.length > 0) {
1882
- const corrFindings = corrResult.report.cross_file_findings;
1883
- for (const f of corrFindings) {
1884
- const key = `${f.title}::${f.file || ''}`;
1885
- if (!seen.has(key)) {
1886
- seen.add(key);
1887
- mergedFindings.push(f);
1888
- }
1889
- }
1890
- console.log(` ${c.yellow}${corrFindings.length} cross-file issues found${c.reset}`);
1891
- // Re-merge into report
1892
- const newRisk = Math.min(100, mergedFindings.reduce((s, f) => s + (sevWeights[f.severity] || 0), 0));
1893
- report.findings = mergedFindings;
1894
- report.findings_count = mergedFindings.length;
1895
- report.risk_score = newRisk;
1896
- report.result = newRisk === 0 ? 'safe' : newRisk <= 20 ? 'caution' : 'unsafe';
1897
- totalInput += corrResult.meta?.input_tokens || 0;
1898
- totalOutput += corrResult.meta?.output_tokens || 0;
1899
- providerMeta = { ...providerMeta, input_tokens: totalInput || null, output_tokens: totalOutput || null };
1659
+ } else if (activeLlm.type === 'gemini') {
1660
+ // Google Gemini API (unique format)
1661
+ const res = await fetch(`${activeLlm.url}/${activeLlm.model}:generateContent?key=${llmApiKey}`, {
1662
+ method: 'POST',
1663
+ headers: { 'Content-Type': 'application/json' },
1664
+ body: JSON.stringify({
1665
+ systemInstruction: { parts: [{ text: systemPrompt }] },
1666
+ contents: [{ role: 'user', parts: [{ text: userMessage }] }],
1667
+ generationConfig: { maxOutputTokens: 8192 },
1668
+ }),
1669
+ signal: AbortSignal.timeout(120_000),
1670
+ });
1671
+ data = await res.json();
1672
+ if (data.error) {
1673
+ console.log(` ${c.red}failed${c.reset}`);
1674
+ const friendly = formatApiError(data.error, activeLlm.provider, res.status);
1675
+ if (friendly) {
1676
+ console.log(` ${c.red}${friendly.text}${c.reset}`);
1677
+ console.log(` ${c.dim}${friendly.hint}${c.reset}`);
1900
1678
  } else {
1901
- console.log(` ${c.green}clean${c.reset}`);
1679
+ console.log(` ${c.red}API error: ${data.error.message || JSON.stringify(data.error)}${c.reset}`);
1902
1680
  }
1903
- } else {
1904
- console.log(` ${c.dim}skipped (too few files with imports)${c.reset}`);
1681
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
1682
+ return null;
1905
1683
  }
1906
- } catch (corrErr) {
1907
- console.log(` ${c.dim}skipped (${corrErr.message?.slice(0, 40)})${c.reset}`);
1908
- }
1909
-
1910
- console.log(` ${c.green}done${c.reset} ${c.dim}(${elapsed(start)})${c.reset}`);
1911
- } else {
1912
- // Single-pass (original flow)
1913
- process.stdout.write(` ${c.dim}[4/4]${c.reset} Running LLM analysis ${c.dim}(${resolvedProvider.id}: ${actualModel})${c.reset}...`);
1914
- const result = await callLLM(codeBlock);
1915
-
1916
- if (result.error) {
1917
- console.log(` ${c.red}failed${c.reset}`);
1918
- const errMsg = result.error;
1919
- console.log(` ${c.red}API error: ${errMsg}${c.reset}`);
1920
- if (/abort|timeout/i.test(errMsg)) {
1921
- const currentTimeout = llmTimeoutMs ? (llmTimeoutMs / 1000) : 180;
1922
- console.log(` ${c.dim}The model took longer than ${currentTimeout}s to respond.${c.reset}`);
1923
- console.log(` ${c.dim}Try increasing the timeout: --timeout 300 (or --timeout 600 for reasoning models)${c.reset}`);
1924
- console.log(` ${c.dim}You can also set AGENTAUDIT_TIMEOUT=300 as environment variable.${c.reset}`);
1684
+ _lastLlmText = data.candidates?.[0]?.content?.parts?.[0]?.text || '';
1685
+ report = extractJSON(_lastLlmText);
1686
+ if (report) {
1687
+ report.audit_model = data.modelVersion || activeLlm.model;
1688
+ report.audit_provider = activeLlm.provider;
1689
+ if (data.usageMetadata) {
1690
+ report.input_tokens = data.usageMetadata.promptTokenCount;
1691
+ report.output_tokens = data.usageMetadata.candidatesTokenCount;
1692
+ }
1693
+ }
1694
+ } else {
1695
+ // OpenAI-compatible API (OpenAI, Mistral, Groq, OpenRouter, etc.)
1696
+ const headers = {
1697
+ 'Authorization': `Bearer ${llmApiKey}`,
1698
+ 'Content-Type': 'application/json',
1699
+ };
1700
+ // OpenRouter requires additional headers
1701
+ if (activeLlm.provider === 'openrouter') {
1702
+ headers['HTTP-Referer'] = 'https://agentaudit.dev';
1703
+ headers['X-Title'] = 'AgentAudit CLI';
1704
+ }
1705
+ const res = await fetch(activeLlm.url, {
1706
+ method: 'POST',
1707
+ headers,
1708
+ body: JSON.stringify({
1709
+ model: activeLlm.model,
1710
+ max_tokens: 8192,
1711
+ messages: [
1712
+ { role: 'system', content: systemPrompt },
1713
+ { role: 'user', content: userMessage },
1714
+ ],
1715
+ }),
1716
+ signal: AbortSignal.timeout(120_000),
1717
+ });
1718
+ data = await res.json();
1719
+ if (data.error) {
1720
+ console.log(` ${c.red}failed${c.reset}`);
1721
+ const friendly = formatApiError(data.error, activeLlm.provider, res.status);
1722
+ if (friendly) {
1723
+ console.log(` ${c.red}${friendly.text}${c.reset}`);
1724
+ console.log(` ${c.dim}${friendly.hint}${c.reset}`);
1725
+ } else {
1726
+ console.log(` ${c.red}API error: ${data.error.message || JSON.stringify(data.error)}${c.reset}`);
1727
+ }
1728
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
1729
+ return null;
1925
1730
  }
1926
- if (/context.length|maximum.*tokens|too.many.tokens/i.test(errMsg)) {
1927
- console.log(` ${c.dim}This model's context window is too small for this repository.${c.reset}`);
1928
- console.log(` ${c.dim}Try a model with a larger context: --model anthropic/claude-sonnet-4 (200k) or --model openai/gpt-4o (128k)${c.reset}`);
1731
+ _lastLlmText = data.choices?.[0]?.message?.content || '';
1732
+ report = extractJSON(_lastLlmText);
1733
+ if (report) {
1734
+ report.audit_model = data.model || activeLlm.model;
1735
+ report.audit_provider = activeLlm.provider;
1736
+ if (data.id) report.provider_msg_id = data.id;
1737
+ if (data.system_fingerprint) report.provider_fingerprint = data.system_fingerprint;
1738
+ if (data.usage) {
1739
+ report.input_tokens = data.usage.prompt_tokens;
1740
+ report.output_tokens = data.usage.completion_tokens;
1741
+ }
1929
1742
  }
1930
- try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
1931
- return null;
1932
1743
  }
1933
1744
 
1934
- report = result.report;
1935
- providerMeta = result.meta;
1936
- _lastLlmText = result.rawText || '';
1937
1745
  console.log(` ${c.green}done${c.reset} ${c.dim}(${elapsed(start)})${c.reset}`);
1746
+ } catch (err) {
1747
+ console.log(` ${c.red}failed${c.reset}`);
1748
+ if (err.name === 'TimeoutError' || err.message?.includes('timeout')) {
1749
+ console.log(` ${c.red}Request timed out (120s)${c.reset}`);
1750
+ console.log(` ${c.dim}The provider took too long to respond. Try again or use a faster model${c.reset}`);
1751
+ } else if (err.code === 'ENOTFOUND' || err.code === 'ECONNREFUSED' || err.message?.includes('fetch failed')) {
1752
+ console.log(` ${c.red}Network error: could not reach ${activeProvider}${c.reset}`);
1753
+ console.log(` ${c.dim}Check your internet connection or provider status${c.reset}`);
1754
+ } else {
1755
+ console.log(` ${c.red}${err.message}${c.reset}`);
1756
+ }
1757
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
1758
+ return null;
1938
1759
  }
1939
1760
 
1940
1761
  // Cleanup repo
1941
1762
  try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
1942
1763
 
1943
1764
  if (!report) {
1944
- const rawLen = typeof _lastLlmText === 'string' ? _lastLlmText.length : 0;
1945
- if (rawLen === 0) {
1946
- console.log(` ${c.red}✖ Model returned an empty response${c.reset}`);
1947
- console.log(` ${c.dim}This model may not support structured JSON output or the prompt was too large.${c.reset}`);
1948
- console.log(` ${c.dim}Try a different model: --model anthropic/claude-sonnet-4 or --model openai/gpt-4o${c.reset}`);
1949
- } else {
1950
- console.log(` ${c.red}✖ Could not parse LLM response as JSON${c.reset}`);
1951
- console.log(` ${c.dim}The model returned ${rawLen} chars but not valid JSON. Try a stronger model.${c.reset}`);
1952
- if (!process.argv.includes('--debug')) {
1953
- console.log(` ${c.dim}Hint: run with --debug to see the raw LLM response${c.reset}`);
1954
- }
1955
- }
1956
- if (process.argv.includes('--debug') && rawLen > 0) {
1765
+ console.log(` ${c.red}Could not parse LLM response as JSON${c.reset}`);
1766
+ console.log(` ${c.dim}Hint: run with --debug to see the raw LLM response${c.reset}`);
1767
+ if (process.argv.includes('--debug')) {
1957
1768
  console.log(` ${c.dim}--- Raw LLM response (first 2000 chars) ---${c.reset}`);
1958
- console.log(_lastLlmText.slice(0, 2000));
1769
+ console.log((typeof _lastLlmText === 'string' ? _lastLlmText : '(empty)').slice(0, 2000));
1959
1770
  console.log(` ${c.dim}--- end ---${c.reset}`);
1960
1771
  }
1961
1772
  return null;
@@ -1963,19 +1774,8 @@ async function auditRepo(url) {
1963
1774
 
1964
1775
  // Display results
1965
1776
  console.log();
1966
- // Always recalculate risk_score from findings severities (never trust LLM's score)
1967
- const _sevW = { critical: 25, high: 15, medium: 5, low: 1 };
1968
- const recalcRisk = report.findings && report.findings.length > 0
1969
- ? Math.min(100, report.findings.reduce((s, f) => s + (_sevW[f.severity] || 0), 0))
1970
- : 0;
1971
- report.risk_score = recalcRisk;
1972
- report.result = recalcRisk === 0 ? 'safe' : recalcRisk <= 20 ? 'caution' : 'unsafe';
1973
- const riskScore = recalcRisk;
1974
- const trustScore = 100 - riskScore;
1975
- const trustColor = trustScore >= 70 ? c.green : trustScore >= 40 ? c.yellow : c.red;
1976
- const trustLabel = trustScore >= 70 ? 'SAFE' : trustScore >= 40 ? 'CAUTION' : 'UNSAFE';
1977
- console.log(` ${trustColor}${c.bold}${trustLabel}${c.reset} ${trustColor}Trust Score: ${trustScore}/100${c.reset} ${c.dim}(Risk: ${riskScore}/100)${c.reset}`);
1978
- console.log(` ${c.dim}Model: ${resolvedProvider.id}/${actualModel} Duration: ${elapsed(start)}${c.reset}`);
1777
+ const riskScore = report.risk_score || 0;
1778
+ console.log(` ${riskBadge(riskScore)} Risk ${riskScore}/100 ${c.bold}${report.result || 'unknown'}${c.reset}`);
1979
1779
  console.log();
1980
1780
 
1981
1781
  if (report.findings && report.findings.length > 0) {
@@ -1995,17 +1795,6 @@ async function auditRepo(url) {
1995
1795
 
1996
1796
  // Upload to registry
1997
1797
  const creds = loadCredentials();
1998
- const finalModel = providerMeta.reported_model || actualModel;
1999
- const finalProvider = resolvedProvider.id;
2000
- if (!finalModel || finalModel === 'unknown') {
2001
- console.log(` ${c.yellow}⚠ Model not detected — report will not include model attestation.${c.reset}`);
2002
- console.log(` ${c.dim} This usually means the LLM API did not return model info.${c.reset}`);
2003
- console.log(` ${c.dim} Try: agentaudit config set model <model-name>${c.reset}`);
2004
- if (process.argv.includes('--debug')) {
2005
- console.log(` ${c.dim} providerMeta: ${JSON.stringify(providerMeta)}${c.reset}`);
2006
- console.log(` ${c.dim} actualModel: ${actualModel}, resolvedProvider: ${resolvedProvider.id}${c.reset}`);
2007
- }
2008
- }
2009
1798
  if (creds) {
2010
1799
  process.stdout.write(` Uploading report to registry...`);
2011
1800
  try {
@@ -2015,83 +1804,18 @@ async function auditRepo(url) {
2015
1804
  'Authorization': `Bearer ${creds.api_key}`,
2016
1805
  'Content-Type': 'application/json',
2017
1806
  },
2018
- body: JSON.stringify({
2019
- ...report,
2020
- commit_sha: report.commit_sha || repoCommitSha || undefined,
2021
- package_version: report.package_version || repoPackageVersion || undefined,
2022
- audit_model: finalModel !== 'unknown' ? finalModel : undefined,
2023
- audit_provider: finalProvider,
2024
- provider_msg_id: providerMeta.provider_msg_id || undefined,
2025
- provider_fingerprint: providerMeta.provider_fingerprint || undefined,
2026
- input_tokens: providerMeta.input_tokens || undefined,
2027
- output_tokens: providerMeta.output_tokens || undefined,
2028
- audit_duration_ms: Date.now() - start,
2029
- files_scanned: files.length,
2030
- }),
1807
+ body: JSON.stringify(report),
2031
1808
  signal: AbortSignal.timeout(15_000),
2032
1809
  });
2033
1810
  if (res.ok) {
2034
1811
  const data = await res.json();
2035
- const reportSlug = data?.skill_slug || data?.slug || slug;
2036
1812
  console.log(` ${c.green}done${c.reset}`);
2037
- console.log(` ${c.dim}Report: ${REGISTRY_URL}/skills/${reportSlug}${c.reset}`);
2038
- // Show API warnings
2039
- if (data?.warnings?.length) {
2040
- for (const w of data.warnings) {
2041
- console.log(` ${c.yellow}⚠ ${w}${c.reset}`);
2042
- }
2043
- }
2044
- // Refresh stats cache in background
2045
- if (creds.agent_name) refreshStatsCache(creds.agent_name).catch(() => {});
2046
- // Fetch registry consensus after upload
2047
- try {
2048
- const regData = await checkRegistry(reportSlug);
2049
- if (regData) {
2050
- const regRisk = regData.latest_risk_score ?? regData.risk_score ?? 0;
2051
- const regTrust = regData.trust_score ?? (100 - regRisk);
2052
- const totalReports = regData.total_reports ?? 1;
2053
- const uniqueAgents = regData.unique_agents ?? 1;
2054
- const confidence = regData.confidence ?? 'unverified';
2055
- const totalFindings = regData.total_findings ?? 0;
2056
- const hasOfficial = regData.has_official_audit;
2057
-
2058
- // Show registry consensus if different from own report or multiple reports exist
2059
- if (totalReports > 1 || regRisk !== report.risk_score) {
2060
- console.log();
2061
- console.log(` ${c.bold}Registry Consensus${c.reset}`);
2062
- const trustColor = regTrust >= 70 ? c.green : regTrust >= 40 ? c.yellow : c.red;
2063
- const trustLabel = regTrust >= 70 ? 'SAFE' : regTrust >= 40 ? 'CAUTION' : 'UNSAFE';
2064
- console.log(` ${trustColor}${c.bold}${trustLabel}${c.reset} Trust Score: ${trustColor}${regTrust}/100${c.reset} ${c.dim}(across ${totalReports} reports from ${uniqueAgents} auditor${uniqueAgents !== 1 ? 's' : ''})${c.reset}`);
2065
- if (totalFindings > 0) {
2066
- console.log(` ${c.dim}Total findings across all reports: ${totalFindings}${c.reset}`);
2067
- }
2068
- const confDisplay = {
2069
- consensus: { icon: '🟢', label: 'Consensus Certified', color: c.green },
2070
- verified: { icon: '🟢', label: 'Verified', color: c.green },
2071
- low: { icon: '🟡', label: 'Low Confidence', color: c.yellow },
2072
- unverified: { icon: '🔴', label: 'Unverified', color: c.yellow },
2073
- }[confidence] || { icon: '⚪', label: confidence, color: c.dim };
2074
- console.log(` ${confDisplay.icon} ${confDisplay.color}${confDisplay.label}${c.reset}`);
2075
- if (hasOfficial) console.log(` ${c.green}✔ Officially audited${c.reset}`);
2076
-
2077
- // Warn if own report disagrees with consensus
2078
- const ownRisk = report.risk_score || 0;
2079
- if (Math.abs(ownRisk - regRisk) > 10) {
2080
- console.log();
2081
- console.log(` ${c.yellow}⚠ Your audit (risk ${ownRisk}) differs from registry consensus (risk ${regRisk}).${c.reset}`);
2082
- console.log(` ${c.dim} This may indicate model-specific blind spots or a changing codebase.${c.reset}`);
2083
- }
2084
- }
2085
- }
2086
- } catch {}
1813
+ console.log(` ${c.dim}Report: ${REGISTRY_URL}/skills/${slug}${c.reset}`);
2087
1814
  } else {
2088
- const errBody = await res.text().catch(() => '');
2089
1815
  console.log(` ${c.yellow}failed (HTTP ${res.status})${c.reset}`);
2090
- if (process.argv.includes('--debug')) console.log(` ${c.dim}Response: ${errBody.slice(0, 500)}${c.reset}`);
2091
1816
  }
2092
1817
  } catch (err) {
2093
1818
  console.log(` ${c.yellow}failed${c.reset}`);
2094
- if (process.argv.includes('--debug')) console.log(` ${c.dim}Error: ${err.message}${c.reset}`);
2095
1819
  }
2096
1820
  } else {
2097
1821
  console.log(` ${c.dim}Run ${c.cyan}agentaudit setup${c.dim} to upload reports to the registry${c.reset}`);
@@ -2103,89 +1827,28 @@ async function auditRepo(url) {
2103
1827
 
2104
1828
  // ── Check command ───────────────────────────────────────
2105
1829
 
2106
- async function checkPackage(name, { autoAudit = false } = {}) {
2107
- // Derive slug from URL for registry lookup (URLs won't match as-is)
2108
- const slug = (name.includes('github.com') || name.includes('://')) ? slugFromUrl(name) : name.toLowerCase();
1830
+ async function checkPackage(name) {
2109
1831
  if (!jsonMode) {
2110
1832
  console.log(`${icons.info} Looking up ${c.bold}${name}${c.reset} in registry...`);
2111
1833
  console.log();
2112
1834
  }
2113
1835
 
2114
- const data = await checkRegistry(slug);
1836
+ const data = await checkRegistry(name);
2115
1837
  if (!data) {
2116
1838
  if (!jsonMode) {
2117
- // Auto-audit: only when called from 'check' command AND input looks like a URL
2118
- if (autoAudit && (name.includes('github.com') || name.includes('://'))) {
2119
- console.log(` ${c.yellow}Not found in registry.${c.reset}`);
2120
- console.log(` ${c.dim}Starting audit for ${name}...${c.reset}`);
2121
- console.log();
2122
- return await auditRepo(name);
2123
- }
2124
- console.log(` ${c.yellow}✖ Not found${c.reset} — "${name}" hasn't been audited yet.`);
2125
- console.log();
2126
- console.log(` ${c.dim}Next steps:${c.reset}`);
2127
- console.log(` ${c.cyan}agentaudit check <repo-url>${c.reset} ${c.dim}Auto-lookup + audit if not found${c.reset}`);
2128
- console.log(` ${c.cyan}agentaudit audit <repo-url>${c.reset} ${c.dim}Deep LLM audit${c.reset}`);
2129
- console.log(` ${c.cyan}agentaudit scan <repo-url>${c.reset} ${c.dim}Quick static check (no API key)${c.reset}`);
1839
+ console.log(` ${c.yellow}Not found${c.reset} package "${name}" hasn't been audited yet.`);
1840
+ console.log(` ${c.dim}Run: agentaudit audit <repo-url> for a deep LLM audit${c.reset}`);
2130
1841
  }
2131
1842
  return null;
2132
1843
  }
2133
1844
 
2134
1845
  if (!jsonMode) {
2135
1846
  const riskScore = data.risk_score ?? data.latest_risk_score ?? 0;
2136
- const trustScore = data.trust_score ?? (100 - riskScore);
2137
- const totalFindings = data.total_findings ?? 0;
2138
- const totalReports = data.total_reports ?? 0;
2139
-
2140
- // Package name + verdict
2141
- console.log(` ${c.bold}${data.display_name || name}${c.reset} ${riskBadge(riskScore)}`);
2142
- if (data.description) console.log(` ${c.dim}${data.description}${c.reset}`);
2143
- console.log();
2144
-
2145
- // Trust Score (the main metric)
2146
- const trustColor = trustScore >= 70 ? c.green : trustScore >= 40 ? c.yellow : c.red;
2147
- const trustLabel = trustScore >= 70 ? 'SAFE' : trustScore >= 40 ? 'CAUTION' : 'UNSAFE';
2148
- console.log(` ${trustColor}${c.bold}${trustLabel}${c.reset} ${trustColor}Trust Score: ${trustScore}/100${c.reset} ${c.dim}(Risk: ${riskScore}/100)${c.reset}`);
2149
-
2150
- // Findings summary
2151
- if (totalFindings > 0) {
2152
- const maxSev = data.latest_max_severity;
2153
- const sevStr = maxSev ? `max severity: ${severityColor(maxSev)}${maxSev}${c.reset}` : '';
2154
- console.log(` ${c.dim}Findings: ${totalFindings}${sevStr ? ` (${sevStr}${c.dim})` : ''}${c.reset}`);
2155
- } else {
2156
- console.log(` ${c.dim}Findings: 0 (clean)${c.reset}`);
2157
- }
2158
-
2159
- // Consensus / Confidence
2160
- const uniqueAgents = data.unique_agents ?? 0;
2161
- const confidence = data.confidence ?? 'unverified';
2162
- const confidenceDisplay = {
2163
- consensus: { icon: '🟢', label: 'Consensus Certified', color: c.green, desc: `${totalReports} reports from ${uniqueAgents} independent auditors agree` },
2164
- verified: { icon: '🟢', label: 'Verified', color: c.green, desc: `${totalReports} reports from ${uniqueAgents} auditors` },
2165
- low: { icon: '🟡', label: 'Low Confidence', color: c.yellow, desc: `${totalReports} reports but ${uniqueAgents <= 1 ? 'only 1 auditor' : `only ${uniqueAgents} auditors`}` },
2166
- unverified: { icon: '🔴', label: 'Unverified', color: c.yellow, desc: 'Single audit, no independent confirmation' },
2167
- }[confidence] || { icon: '⚪', label: confidence, color: c.dim, desc: '' };
2168
- console.log(` ${confidenceDisplay.icon} ${confidenceDisplay.color}${confidenceDisplay.label}${c.reset} ${c.dim}${confidenceDisplay.desc}${c.reset}`);
2169
-
2170
- // Audit info
2171
- console.log(` ${c.dim}Reports: ${totalReports} | Auditors: ${uniqueAgents} | Last: ${data.last_audited_at ? new Date(data.last_audited_at).toLocaleDateString() : 'unknown'}${c.reset}`);
1847
+ console.log(` ${c.bold}${name}${c.reset} ${riskBadge(riskScore)}`);
1848
+ console.log(` ${c.dim}Risk Score: ${riskScore}/100${c.reset}`);
1849
+ if (data.source_url) console.log(` ${c.dim}Source: ${data.source_url}${c.reset}`);
1850
+ console.log(` ${c.dim}Registry: ${REGISTRY_URL}/skills/${name}${c.reset}`);
2172
1851
  if (data.has_official_audit) console.log(` ${c.green}✔ Officially audited${c.reset}`);
2173
-
2174
- // Recommendation
2175
- if (confidence === 'unverified' && trustScore >= 70) {
2176
- console.log();
2177
- console.log(` ${c.yellow}⚠ Score looks good but only 1 audit exists.${c.reset}`);
2178
- console.log(` ${c.dim} Consider running your own audit: agentaudit audit ${data.source_url || name}${c.reset}`);
2179
- } else if (confidence === 'low') {
2180
- console.log();
2181
- console.log(` ${c.yellow}⚠ Limited independent verification.${c.reset}`);
2182
- console.log(` ${c.dim} More auditors needed for consensus. Run: agentaudit audit ${data.source_url || name}${c.reset}`);
2183
- }
2184
-
2185
- // Links
2186
- console.log();
2187
- if (data.source_url) console.log(` ${c.dim}Source: ${data.source_url}${c.reset}`);
2188
- console.log(` ${c.dim}Registry: ${REGISTRY_URL}/skills/${encodeURIComponent(name)}${c.reset}`);
2189
1852
  console.log();
2190
1853
  }
2191
1854
  return data;
@@ -2207,40 +1870,12 @@ async function main() {
2207
1870
  quietMode = rawArgs.includes('--quiet') || rawArgs.includes('-q');
2208
1871
  // --no-color already handled at top level for `c` object
2209
1872
 
2210
- // --model flag: --model=<name> or --model <name>
2211
- const modelFlagIdx = rawArgs.findIndex(a => a === '--model');
2212
- const modelFlagEq = rawArgs.find(a => a.startsWith('--model='));
2213
- modelOverride = modelFlagEq?.split('=')[1]
2214
- || (modelFlagIdx >= 0 ? rawArgs[modelFlagIdx + 1] : null)
2215
- || process.env.AGENTAUDIT_MODEL
2216
- || loadConfig()?.preferred_model
2217
- || null;
2218
- globalModelOverride = modelOverride;
2219
-
2220
- // --timeout flag: --timeout=<seconds> or --timeout <seconds>
2221
- const timeoutFlagIdx = rawArgs.findIndex(a => a === '--timeout');
2222
- const timeoutFlagEq = rawArgs.find(a => a.startsWith('--timeout='));
2223
- const timeoutVal = timeoutFlagEq?.split('=')[1]
2224
- || (timeoutFlagIdx >= 0 ? rawArgs[timeoutFlagIdx + 1] : null)
2225
- || process.env.AGENTAUDIT_TIMEOUT
2226
- || null;
2227
- if (timeoutVal) {
2228
- const secs = parseInt(timeoutVal, 10);
2229
- if (secs > 0) llmTimeoutMs = secs * 1000;
2230
- }
2231
-
2232
- // Strip global flags from args
1873
+ // Strip global flags from args (including --model <value>)
2233
1874
  const globalFlags = new Set(['--json', '--quiet', '-q', '--no-color']);
2234
1875
  let args = rawArgs.filter(a => !globalFlags.has(a));
2235
- // Strip --model and its value
2236
- args = args.filter((a, i, arr) => {
2237
- if (a.startsWith('--model=')) return false;
2238
- if (a === '--model') { arr[i + 1] = '__skip__'; return false; }
2239
- if (a.startsWith('--timeout=')) return false;
2240
- if (a === '--timeout') { arr[i + 1] = '__skip__'; return false; }
2241
- if (a === '__skip__') return false;
2242
- return true;
2243
- });
1876
+ // Remove --model <value> pair
1877
+ const modelIdx = args.indexOf('--model');
1878
+ if (modelIdx !== -1) args.splice(modelIdx, 2);
2244
1879
 
2245
1880
  if (args[0] === '-v' || args[0] === '--version') {
2246
1881
  console.log(`agentaudit ${getVersion()}`);
@@ -2248,46 +1883,48 @@ async function main() {
2248
1883
  }
2249
1884
 
2250
1885
  if (args[0] === '--help' || args[0] === '-h') {
2251
- await banner();
2252
- console.log(` ${c.bold}USAGE${c.reset}`);
2253
- console.log(` ${c.cyan}agentaudit${c.reset} <command> [options]`);
1886
+ banner();
1887
+ console.log(` ${c.bold}Commands:${c.reset}`);
1888
+ console.log();
1889
+ console.log(` ${c.cyan}agentaudit${c.reset} Discover MCP servers (same as discover)`);
1890
+ console.log(` ${c.cyan}agentaudit discover${c.reset} Find MCP servers in your AI editors (Cursor, Claude, VS Code, Windsurf)`);
1891
+ console.log(` ${c.cyan}agentaudit discover --quick${c.reset} Discover + auto-scan all servers`);
1892
+ console.log(` ${c.cyan}agentaudit discover --deep${c.reset} Discover + select servers to deep-audit`);
1893
+ console.log(` ${c.cyan}agentaudit scan${c.reset} <url> [url...] Quick static scan (regex, local)`);
1894
+ console.log(` ${c.cyan}agentaudit scan${c.reset} <url> ${c.dim}--deep${c.reset} Deep audit (same as audit)`);
1895
+ console.log(` ${c.cyan}agentaudit audit${c.reset} <url> [url...] Deep LLM-powered security audit`);
1896
+ console.log(` ${c.cyan}agentaudit lookup${c.reset} <name> Look up package in registry`);
1897
+ console.log(` ${c.cyan}agentaudit model${c.reset} [name|reset] View or set default LLM model`);
1898
+ console.log(` ${c.cyan}agentaudit setup${c.reset} Register + configure API key`);
2254
1899
  console.log();
2255
- console.log(` ${c.bold}SCAN & AUDIT${c.reset}`);
2256
- console.log(` ${c.cyan}scan${c.reset} <url> [url...] Quick static analysis ${c.dim}(~2s, no API key)${c.reset}`);
2257
- console.log(` ${c.cyan}audit${c.reset} <url> [url...] Deep LLM security audit ${c.dim}(~30s)${c.reset}`);
2258
- console.log(` ${c.cyan}discover${c.reset} Find MCP servers in your editors`);
1900
+ console.log(` ${c.bold}Global flags:${c.reset}`);
1901
+ console.log(` ${c.dim}--json Output JSON to stdout (machine-readable)${c.reset}`);
1902
+ console.log(` ${c.dim}--quiet Suppress banner and tree visualization${c.reset}`);
1903
+ console.log(` ${c.dim}--no-color Disable ANSI colors (also: NO_COLOR env)${c.reset}`);
1904
+ console.log(` ${c.dim}--model <name> Override LLM model (e.g. qwen/qwen3.5-coder-32b)${c.reset}`);
2259
1905
  console.log();
2260
- console.log(` ${c.bold}REGISTRY${c.reset}`);
2261
- console.log(` ${c.cyan}check${c.reset} <name|url> Look up or auto-audit package`);
2262
- console.log(` ${c.cyan}lookup${c.reset} <name> Look up package in registry`);
1906
+ console.log(` ${c.bold}Quick Scan${c.reset} vs ${c.bold}Deep Audit${c.reset}:`);
1907
+ console.log(` ${c.dim}scan = fast regex-based static analysis (~2s)${c.reset}`);
1908
+ console.log(` ${c.dim}audit = deep LLM analysis with 3-pass methodology (~30s)${c.reset}`);
2263
1909
  console.log();
2264
- console.log(` ${c.bold}SETUP${c.reset}`);
2265
- console.log(` ${c.cyan}status${c.reset} Check providers & API keys`);
2266
- console.log(` ${c.cyan}setup${c.reset} Register & configure`);
2267
- console.log(` ${c.cyan}models${c.reset} List available LLM models`);
2268
- console.log(` ${c.cyan}config set${c.reset} <key> <value> Set default provider/options`);
1910
+ console.log(` ${c.bold}Exit codes:${c.reset}`);
1911
+ console.log(` ${c.dim}0 = clean / success 1 = findings detected 2 = error${c.reset}`);
2269
1912
  console.log();
2270
- console.log(` ${c.bold}OPTIONS${c.reset}`);
2271
- console.log(` ${c.dim}--json Machine-readable JSON output${c.reset}`);
2272
- console.log(` ${c.dim}--quiet Suppress banner${c.reset}`);
2273
- console.log(` ${c.dim}--no-color Disable colors ${c.reset}${c.dim}(also: NO_COLOR=1)${c.reset}`);
2274
- console.log(` ${c.dim}--provider <p> Force provider ${c.reset}${c.dim}(anthropic|openai|openrouter|ollama|custom)${c.reset}`);
2275
- console.log(` ${c.dim}--model <m> Override model ${c.reset}${c.dim}(e.g. gpt-4o-mini, claude-3.5-sonnet)${c.reset}`);
2276
- console.log(` ${c.dim}--export Export audit payload to markdown${c.reset}`);
2277
- console.log(` ${c.dim}--debug Show raw LLM response on errors${c.reset}`);
1913
+ console.log(` ${c.bold}Examples:${c.reset}`);
1914
+ console.log(` agentaudit`);
1915
+ console.log(` agentaudit discover --quick`);
1916
+ console.log(` agentaudit scan https://github.com/owner/repo`);
1917
+ console.log(` agentaudit audit https://github.com/owner/repo`);
1918
+ console.log(` agentaudit lookup fastmcp --json`);
2278
1919
  console.log();
2279
- console.log(` ${c.bold}EXAMPLES${c.reset}`);
2280
- console.log(` ${c.dim}$${c.reset} agentaudit scan https://github.com/owner/repo`);
2281
- console.log(` ${c.dim}$${c.reset} agentaudit audit https://github.com/owner/repo`);
2282
- console.log(` ${c.dim}$${c.reset} agentaudit check fastmcp`);
2283
- console.log(` ${c.dim}$${c.reset} agentaudit status`);
1920
+ console.log(` ${c.bold}For deep audits,${c.reset} set any LLM API key:`);
1921
+ console.log(` ${c.dim}ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, DEEPSEEK_API_KEY,${c.reset}`);
1922
+ console.log(` ${c.dim}MISTRAL_API_KEY, XAI_API_KEY, GROQ_API_KEY, TOGETHER_API_KEY,${c.reset}`);
1923
+ console.log(` ${c.dim}FIREWORKS_API_KEY, CEREBRAS_API_KEY, ZAI_API_KEY, or OPENROUTER_API_KEY${c.reset}`);
2284
1924
  console.log();
2285
- console.log(` ${c.bold}PROVIDERS${c.reset} ${c.dim}(set any one for deep audits)${c.reset}`);
2286
- console.log(` ${c.dim}ANTHROPIC_API_KEY · OPENAI_API_KEY · OPENROUTER_API_KEY · OLLAMA_MODEL · LLM_API_URL${c.reset}`);
2287
- console.log(` ${c.dim}Set default: AGENTAUDIT_PROVIDER=openai AGENTAUDIT_MODEL=gpt-4o-mini${c.reset}`);
2288
- console.log(` ${c.dim}Or persist: agentaudit config set provider openai${c.reset}`);
2289
- console.log(` ${c.dim} agentaudit config set model gpt-4o-mini${c.reset}`);
2290
- console.log(` ${c.dim}Run ${c.cyan}agentaudit status${c.dim} to check configuration.${c.reset}`);
1925
+ console.log(` ${c.bold}Or use as MCP server${c.reset} in Cursor/Claude ${c.dim}(no extra API key needed):${c.reset}`);
1926
+ console.log(` ${c.dim}Add to your MCP config:${c.reset}`);
1927
+ console.log(` ${c.dim}{ "agentaudit": { "command": "npx", "args": ["-y", "agentaudit"] } }${c.reset}`);
2291
1928
  console.log();
2292
1929
  process.exitCode = 0; return;
2293
1930
  }
@@ -2296,312 +1933,166 @@ async function main() {
2296
1933
  const command = args.length === 0 ? 'discover' : args[0];
2297
1934
  const targets = args.slice(1);
2298
1935
 
2299
- await banner();
1936
+ banner();
2300
1937
 
2301
1938
  if (command === 'setup') {
2302
1939
  await setupCommand();
2303
1940
  return;
2304
1941
  }
2305
-
2306
- if (command === 'status') {
2307
- console.log(` ${c.bold}LLM Providers:${c.reset}`);
2308
- console.log();
2309
- const keys = {
2310
- anthropicKey: process.env.ANTHROPIC_API_KEY,
2311
- openaiKey: process.env.OPENAI_API_KEY,
2312
- openrouterKey: process.env.OPENROUTER_API_KEY,
2313
- };
2314
- const ollamaHost = process.env.OLLAMA_HOST || 'http://localhost:11434';
2315
- const ollamaModel = process.env.OLLAMA_MODEL;
2316
- const customUrl = process.env.LLM_API_URL;
2317
-
2318
- const checks = [
2319
- { name: 'Anthropic', env: 'ANTHROPIC_API_KEY', key: keys.anthropicKey, testUrl: 'https://api.anthropic.com/v1/messages', testHeaders: (k) => ({ 'x-api-key': k, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' }), testBody: JSON.stringify({ model: 'claude-sonnet-4-20250514', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }) },
2320
- { name: 'OpenAI', env: 'OPENAI_API_KEY', key: keys.openaiKey, testUrl: 'https://api.openai.com/v1/chat/completions', testHeaders: (k) => ({ 'Authorization': `Bearer ${k}`, 'Content-Type': 'application/json' }), testBody: JSON.stringify({ model: 'gpt-4o-mini', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }) },
2321
- { name: 'OpenRouter', env: 'OPENROUTER_API_KEY', key: keys.openrouterKey, testUrl: 'https://openrouter.ai/api/v1/chat/completions', testHeaders: (k) => ({ 'Authorization': `Bearer ${k}`, 'Content-Type': 'application/json', 'HTTP-Referer': 'https://agentaudit.dev', 'X-Title': 'AgentAudit' }), testBody: JSON.stringify({ model: 'openai/gpt-4o-mini', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }) },
2322
- { name: 'Ollama', env: 'OLLAMA_MODEL', key: ollamaModel, testUrl: `${ollamaHost}/api/tags`, testHeaders: () => ({}), testBody: null },
2323
- { name: 'Custom', env: 'LLM_API_URL', key: customUrl, testUrl: customUrl ? `${customUrl.replace(/\/$/, '')}/models` : null, testHeaders: (k) => process.env.LLM_API_KEY ? ({ 'Authorization': `Bearer ${process.env.LLM_API_KEY}` }) : {}, testBody: null },
2324
- ];
2325
1942
 
2326
- for (const p of checks) {
2327
- if (!p.key) {
2328
- console.log(` ${c.dim}○${c.reset} ${p.name.padEnd(12)} ${c.dim}not set${c.reset} ${c.dim}(${p.env})${c.reset}`);
2329
- continue;
2330
- }
2331
- const masked = p.key.substring(0, 8) + '...' + p.key.substring(p.key.length - 4);
2332
- process.stdout.write(` ${c.yellow}●${c.reset} ${p.name.padEnd(12)} ${c.dim}${masked}${c.reset} checking...`);
2333
- try {
2334
- const res = await fetch(p.testUrl, {
2335
- method: p.testBody ? 'POST' : 'GET',
2336
- headers: p.testHeaders(p.key),
2337
- ...(p.testBody ? { body: p.testBody } : {}),
2338
- signal: AbortSignal.timeout(10_000),
2339
- });
2340
- if (res.ok || res.status === 200 || res.status === 201) {
2341
- process.stdout.write(`\r ${c.green}●${c.reset} ${p.name.padEnd(12)} ${c.dim}${masked}${c.reset} ${c.green}valid ✓${c.reset} \n`);
2342
- } else {
2343
- const body = await res.json().catch(() => ({}));
2344
- const rawMsg = body?.error?.message || body?.message || `HTTP ${res.status}`;
2345
- // Detect specific error types for clearer messages
2346
- const lcMsg = rawMsg.toLowerCase();
2347
- let errMsg = rawMsg;
2348
- let hint = '';
2349
- if (lcMsg.includes('credit') || lcMsg.includes('balance') || lcMsg.includes('quota') || lcMsg.includes('billing') || lcMsg.includes('exceeded') || lcMsg.includes('insufficient')) {
2350
- errMsg = 'no credits';
2351
- if (p.name === 'Anthropic') hint = `\n ${c.dim}└─ Add credits: console.anthropic.com/settings/plans${c.reset}`;
2352
- else if (p.name === 'OpenAI') hint = `\n ${c.dim}└─ Check usage: platform.openai.com/usage${c.reset}`;
2353
- else if (p.name === 'OpenRouter') hint = `\n ${c.dim}└─ Check balance: openrouter.ai/credits${c.reset}`;
2354
- } else if (res.status === 401 || lcMsg.includes('invalid') || lcMsg.includes('unauthorized') || lcMsg.includes('authentication')) {
2355
- errMsg = 'invalid key';
2356
- if (p.name === 'Anthropic') hint = `\n ${c.dim}└─ Check key: console.anthropic.com/settings/keys${c.reset}`;
2357
- else if (p.name === 'OpenAI') hint = `\n ${c.dim}└─ Check key: platform.openai.com/api-keys${c.reset}`;
2358
- } else if (res.status === 429) {
2359
- errMsg = 'rate limited';
2360
- hint = `\n ${c.dim}└─ Try again in a moment${c.reset}`;
2361
- }
2362
- process.stdout.write(`\r ${c.red}●${c.reset} ${p.name.padEnd(12)} ${c.dim}${masked}${c.reset} ${c.red}✖ ${errMsg}${c.reset}${hint} \n`);
1943
+ if (command === 'model') {
1944
+ const newModel = targets.filter(t => !t.startsWith('--'))[0];
1945
+ const current = loadLlmConfig()?.llm_model;
1946
+
1947
+ // Direct set: agentaudit model <name>
1948
+ if (newModel === 'reset' || newModel === 'clear') {
1949
+ for (const f of [USER_CRED_FILE, SKILL_CRED_FILE]) {
1950
+ if (fs.existsSync(f)) {
1951
+ try {
1952
+ const data = JSON.parse(fs.readFileSync(f, 'utf8'));
1953
+ delete data.llm_model;
1954
+ fs.writeFileSync(f, JSON.stringify(data, null, 2), { mode: 0o600 });
1955
+ } catch {}
2363
1956
  }
2364
- } catch (e) {
2365
- process.stdout.write(`\r ${c.red}●${c.reset} ${p.name.padEnd(12)} ${c.dim}${masked}${c.reset} ${c.red}error ✗${c.reset} ${c.dim}(${e.message})${c.reset} \n`);
2366
1957
  }
1958
+ console.log(` ${icons.safe} Model reset to provider default`);
1959
+ return;
2367
1960
  }
2368
1961
 
2369
- const resolved = resolveProvider(null, keys);
2370
- console.log();
2371
- if (resolved) {
2372
- const activeModel = modelOverride || process.env.AGENTAUDIT_MODEL || loadConfig()?.preferred_model;
2373
- console.log(` ${c.bold}Active:${c.reset} ${c.green}${resolved.label}${c.reset}${activeModel ? ` ${c.dim}model: ${activeModel}${c.reset}` : ''}`);
2374
- console.log(` ${c.dim}Override: --provider=<name> --model=<name>${c.reset}`);
2375
- console.log(` ${c.dim}Set default: agentaudit config set provider <name>${c.reset}`);
2376
- console.log(` ${c.dim} agentaudit config set model <name>${c.reset}`);
2377
- } else {
2378
- console.log(` ${c.yellow}⚠ No working LLM provider.${c.reset} Deep audits require one.`);
2379
- console.log(` ${c.dim}Set a key: export ANTHROPIC_API_KEY=sk-ant-...${c.reset}`);
2380
- console.log(` ${c.dim}Or scan without LLM: agentaudit scan <url>${c.reset}`);
1962
+ if (newModel) {
1963
+ saveLlmConfig(newModel);
1964
+ console.log(` ${icons.safe} Model set to ${c.bold}${newModel}${c.reset}`);
1965
+ if (current) console.log(` ${c.dim}Was: ${current}${c.reset}`);
1966
+ return;
2381
1967
  }
2382
1968
 
2383
- // AgentAudit registry key
1969
+ // No argument — interactive menu
1970
+ const activeLlm = LLM_PROVIDERS.find(p => process.env[p.key]);
1971
+ console.log(` ${c.bold}LLM Model Configuration${c.reset}`);
2384
1972
  console.log();
2385
- console.log(` ${c.bold}Registry:${c.reset}`);
2386
- const creds = loadCredentials();
2387
- if (creds?.api_key) {
2388
- const masked = creds.api_key.substring(0, 8) + '...' + creds.api_key.substring(creds.api_key.length - 4);
2389
- console.log(` ${c.green}●${c.reset} AgentAudit ${c.dim}${masked}${c.reset} ${c.dim}(${creds.agent_name || 'unknown'})${c.reset}`);
2390
- // Fetch agent stats from leaderboard
2391
- try {
2392
- const lbRes = await fetch(`${REGISTRY_URL}/api/leaderboard`, { signal: AbortSignal.timeout(5000) });
2393
- if (lbRes.ok) {
2394
- const agents = await lbRes.json();
2395
- const myName = creds.agent_name?.toLowerCase();
2396
- const idx = Array.isArray(agents) ? agents.findIndex((a) => (a.agent_name || '').toLowerCase() === myName) : -1;
2397
- if (idx >= 0) {
2398
- const me = agents[idx];
2399
- const pts = me.total_points || 0;
2400
- const reports = me.total_reports || 0;
2401
- const rank = idx + 1;
2402
- const medal = rank === 1 ? '🥇' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : ' ';
2403
- console.log();
2404
- console.log(` ${c.bold}Your Stats:${c.reset}`);
2405
- console.log(` ${medal} Rank #${rank} of ${agents.length} ${c.dim}│${c.reset} ${c.cyan}${pts}${c.reset} points ${c.dim}│${c.reset} ${reports} reports`);
2406
- if (me.is_official) console.log(` ${c.green}✔ Official Auditor${c.reset}`);
2407
- // Update stats cache
2408
- saveStatsCache({ rank, total: agents.length, pts, reports, official: !!me.is_official });
2409
- }
2410
- }
2411
- } catch {}
1973
+ if (current) {
1974
+ console.log(` Current: ${c.bold}${c.cyan}${current}${c.reset}`);
2412
1975
  } else {
2413
- console.log(` ${c.dim}○${c.reset} AgentAudit ${c.dim}not set${c.reset} ${c.dim}(run: agentaudit setup)${c.reset}`);
1976
+ console.log(` Current: ${c.dim}(provider default${activeLlm ? `: ${activeLlm.model}` : ''})${c.reset}`);
1977
+ }
1978
+ if (activeLlm) {
1979
+ console.log(` Provider: ${c.dim}${activeLlm.name} (${activeLlm.key})${c.reset}`);
2414
1980
  }
2415
1981
  console.log();
2416
- return;
2417
- }
2418
-
2419
- if (command === 'discover') {
2420
- const scanFlag = targets.includes('--quick') || targets.includes('--scan') || targets.includes('-s');
2421
- const auditFlag = targets.includes('--deep') || targets.includes('--audit') || targets.includes('-a');
2422
- await discoverCommand({ scan: scanFlag, audit: auditFlag });
2423
- return;
2424
- }
2425
-
2426
- if (command === 'models') {
2427
- const anthropicKey = process.env.ANTHROPIC_API_KEY;
2428
- const openaiKey = process.env.OPENAI_API_KEY;
2429
- const openrouterKey = process.env.OPENROUTER_API_KEY;
2430
-
2431
- console.log(` ${c.bold}Available models by provider:${c.reset}`);
2432
- console.log();
2433
-
2434
- // Static lists for Anthropic (no list API)
2435
- console.log(` ${c.bold}Anthropic${c.reset}${anthropicKey ? ` ${c.green}(configured)${c.reset}` : ` ${c.dim}(not configured)${c.reset}`}`);
2436
- console.log(` ${c.dim}claude-sonnet-4-20250514${c.reset} ${c.dim}(default)${c.reset}`);
2437
- console.log(` ${c.dim}claude-opus-4-20250514${c.reset}`);
2438
- console.log(` ${c.dim}claude-haiku-3-20250514${c.reset}`);
2439
- console.log();
2440
-
2441
- // Static list for OpenAI
2442
- console.log(` ${c.bold}OpenAI${c.reset}${openaiKey ? ` ${c.green}(configured)${c.reset}` : ` ${c.dim}(not configured)${c.reset}`}`);
2443
- console.log(` ${c.dim}gpt-4o${c.reset} ${c.dim}(default)${c.reset}`);
2444
- console.log(` ${c.dim}gpt-4o-mini${c.reset}`);
2445
- console.log(` ${c.dim}gpt-4.1${c.reset}`);
2446
- console.log(` ${c.dim}gpt-4.1-mini${c.reset}`);
2447
- console.log(` ${c.dim}o3${c.reset}`);
2448
- console.log(` ${c.dim}o4-mini${c.reset}`);
2449
- console.log();
2450
-
2451
- // OpenRouter fetch from API
2452
- console.log(` ${c.bold}OpenRouter${c.reset}${openrouterKey ? ` ${c.green}(configured)${c.reset}` : ` ${c.dim}(not configured)${c.reset}`}`);
2453
- if (openrouterKey || targets.includes('--all')) {
2454
- process.stdout.write(` ${c.dim}Fetching models...${c.reset}`);
2455
- try {
2456
- const res = await fetch('https://openrouter.ai/api/v1/models', {
2457
- headers: openrouterKey ? { 'Authorization': `Bearer ${openrouterKey}` } : {},
2458
- signal: AbortSignal.timeout(10_000),
2459
- });
2460
- const data = await res.json();
2461
- const models = (data.data || [])
2462
- .filter(m => m.id && !m.id.includes(':free') && !m.id.includes('/extended'))
2463
- .sort((a, b) => (a.id || '').localeCompare(b.id || ''));
2464
-
2465
- // Group by provider prefix
2466
- const groups = {};
2467
- for (const m of models) {
2468
- const [prefix] = m.id.split('/');
2469
- if (!groups[prefix]) groups[prefix] = [];
2470
- groups[prefix].push(m);
2471
- }
2472
-
2473
- // Show popular ones first
2474
- const popular = ['anthropic', 'openai', 'google', 'meta-llama', 'mistralai', 'deepseek'];
2475
- const shown = new Set();
2476
- process.stdout.write(`\r ${c.green}${models.length} models available${c.reset} \n`);
2477
- console.log();
2478
-
2479
- for (const prefix of popular) {
2480
- if (!groups[prefix]) continue;
2481
- shown.add(prefix);
2482
- console.log(` ${c.bold}${prefix}${c.reset}`);
2483
- for (const m of groups[prefix].slice(0, 5)) {
2484
- console.log(` ${c.dim}${m.id}${c.reset}`);
2485
- }
2486
- if (groups[prefix].length > 5) {
2487
- console.log(` ${c.dim}... and ${groups[prefix].length - 5} more${c.reset}`);
2488
- }
2489
- }
2490
-
2491
- const otherCount = Object.keys(groups).filter(k => !shown.has(k)).length;
2492
- if (otherCount > 0) {
2493
- console.log();
2494
- console.log(` ${c.dim}+ ${otherCount} more providers. Use --model=<provider/model>${c.reset}`);
2495
- console.log(` ${c.dim}Full list: https://openrouter.ai/models${c.reset}`);
1982
+
1983
+ // Build menu items — deduplicate providers, show popular models
1984
+ const modelChoices = [
1985
+ { label: 'claude-sonnet-4-20250514', sublabel: 'Anthropic — fast + smart', value: 'claude-sonnet-4-20250514' },
1986
+ { label: 'claude-opus-4-20250514', sublabel: 'Anthropic most capable', value: 'claude-opus-4-20250514' },
1987
+ { label: 'gpt-4o', sublabel: 'OpenAI fast multimodal', value: 'gpt-4o' },
1988
+ { label: 'gpt-4.1', sublabel: 'OpenAI — latest', value: 'gpt-4.1' },
1989
+ { label: 'gemini-2.5-flash', sublabel: 'Google — fast + cheap', value: 'gemini-2.5-flash' },
1990
+ { label: 'gemini-2.5-pro', sublabel: 'Google — most capable', value: 'gemini-2.5-pro' },
1991
+ { label: 'deepseek-chat', sublabel: 'DeepSeek — cost-effective', value: 'deepseek-chat' },
1992
+ { label: 'qwen/qwen3-coder', sublabel: 'OpenRouter — code specialist', value: 'qwen/qwen3-coder' },
1993
+ { label: 'mistral-large-latest', sublabel: 'Mistral — EU-hosted', value: 'mistral-large-latest' },
1994
+ { label: 'grok-3', sublabel: 'xAI — real-time knowledge', value: 'grok-3' },
1995
+ { label: 'meta-llama/Llama-3.3-70B-Instruct-Turbo', sublabel: 'Together AI — open source', value: 'meta-llama/Llama-3.3-70B-Instruct-Turbo' },
1996
+ { label: 'llama-3.3-70b-versatile', sublabel: 'Groq — ultra-fast inference', value: 'llama-3.3-70b-versatile' },
1997
+ { label: '(reset to provider default)', sublabel: '', value: '__reset__' },
1998
+ { label: '(enter custom model name)', sublabel: '', value: '__custom__' },
1999
+ ];
2000
+
2001
+ const selected = await singleSelect(modelChoices, {
2002
+ title: 'Choose default model',
2003
+ hint: '↑↓=move Enter=select Esc=cancel',
2004
+ });
2005
+
2006
+ if (selected === null) {
2007
+ console.log(` ${c.dim}Cancelled${c.reset}`);
2008
+ return;
2009
+ }
2010
+
2011
+ if (selected === '__reset__') {
2012
+ for (const f of [USER_CRED_FILE, SKILL_CRED_FILE]) {
2013
+ if (fs.existsSync(f)) {
2014
+ try {
2015
+ const data = JSON.parse(fs.readFileSync(f, 'utf8'));
2016
+ delete data.llm_model;
2017
+ fs.writeFileSync(f, JSON.stringify(data, null, 2), { mode: 0o600 });
2018
+ } catch {}
2496
2019
  }
2497
- } catch (e) {
2498
- process.stdout.write(`\r ${c.red}Failed to fetch: ${e.message}${c.reset} \n`);
2499
2020
  }
2500
- } else {
2501
- console.log(` ${c.dim}anthropic/claude-sonnet-4${c.reset} ${c.dim}(default)${c.reset}`);
2502
- console.log(` ${c.dim}Set OPENROUTER_API_KEY to see all ${c.bold}200+${c.reset}${c.dim} models${c.reset}`);
2503
- console.log(` ${c.dim}Or browse: https://openrouter.ai/models${c.reset}`);
2021
+ console.log();
2022
+ console.log(` ${icons.safe} Model reset to provider default`);
2023
+ return;
2504
2024
  }
2505
- console.log();
2506
-
2507
- // Ollama
2508
- const ollamaModel = process.env.OLLAMA_MODEL;
2509
- const ollamaHost = process.env.OLLAMA_HOST || 'http://localhost:11434';
2510
- console.log(` ${c.bold}Ollama${c.reset}${ollamaModel ? ` ${c.green}(configured: ${ollamaModel})${c.reset}` : ` ${c.dim}(not configured)${c.reset}`}`);
2511
- if (ollamaModel || process.env.OLLAMA_HOST) {
2512
- try {
2513
- const res = await fetch(`${ollamaHost}/api/tags`, { signal: AbortSignal.timeout(5_000) });
2514
- const data = await res.json();
2515
- for (const m of (data.models || []).slice(0, 10)) {
2516
- console.log(` ${c.dim}${m.name}${c.reset}`);
2517
- }
2518
- } catch {
2519
- console.log(` ${c.dim}(Ollama not running at ${ollamaHost})${c.reset}`);
2025
+
2026
+ if (selected === '__custom__') {
2027
+ console.log();
2028
+ const custom = await askQuestion(` Model name: `);
2029
+ if (!custom) {
2030
+ console.log(` ${c.dim}Cancelled${c.reset}`);
2031
+ return;
2520
2032
  }
2521
- } else {
2522
- console.log(` ${c.dim}Set OLLAMA_MODEL to use local models${c.reset}`);
2033
+ saveLlmConfig(custom);
2034
+ console.log(` ${icons.safe} Model set to ${c.bold}${custom}${c.reset}`);
2035
+ if (current) console.log(` ${c.dim}Was: ${current}${c.reset}`);
2036
+ return;
2523
2037
  }
2038
+
2039
+ saveLlmConfig(selected);
2524
2040
  console.log();
2525
-
2526
- console.log(` ${c.bold}Set model:${c.reset}`);
2527
- console.log(` ${c.cyan}agentaudit config set model <name>${c.reset}`);
2528
- console.log(` ${c.cyan}agentaudit audit <url> --model <name>${c.reset}`);
2529
- console.log(` ${c.dim}Or env: AGENTAUDIT_MODEL=<name>${c.reset}`);
2041
+ console.log(` ${icons.safe} Model set to ${c.bold}${selected}${c.reset}`);
2042
+ if (current) console.log(` ${c.dim}Was: ${current}${c.reset}`);
2043
+ console.log();
2044
+ console.log(` ${c.dim}Tip: agentaudit model <name> to set directly, or agentaudit model reset${c.reset}`);
2530
2045
  return;
2531
2046
  }
2532
2047
 
2533
- if (command === 'config') {
2534
- const subCmd = targets[0];
2535
- if (subCmd === 'set' && targets[1] === 'provider' && targets[2]) {
2536
- const validProviders = ['anthropic', 'openai', 'openrouter', 'ollama', 'custom', 'claude', 'gpt'];
2537
- const val = targets[2].toLowerCase();
2538
- if (!validProviders.includes(val)) {
2539
- console.log(` ${c.red}✖ Unknown provider: ${val}${c.reset}`);
2540
- console.log(` ${c.dim}Valid: anthropic, openai, openrouter, ollama, custom${c.reset}`);
2541
- process.exitCode = 2; return;
2542
- }
2543
- saveConfig({ preferred_provider: val });
2544
- console.log(` ${c.green}✔${c.reset} Default provider set to: ${c.bold}${val}${c.reset}`);
2545
- console.log(` ${c.dim}Override per-command: --provider=<name>${c.reset}`);
2546
- console.log(` ${c.dim}Or env: AGENTAUDIT_PROVIDER=<name>${c.reset}`);
2547
- } else if (subCmd === 'set' && targets[1] === 'model' && targets[2]) {
2548
- const val = targets[2];
2549
- saveConfig({ preferred_model: val });
2550
- console.log(` ${c.green}✔${c.reset} Default model set to: ${c.bold}${val}${c.reset}`);
2551
- console.log(` ${c.dim}Override per-command: --model=<name>${c.reset}`);
2552
- console.log(` ${c.dim}Or env: AGENTAUDIT_MODEL=<name>${c.reset}`);
2553
- } else if (subCmd === 'get' || !subCmd) {
2554
- const cfg = loadConfig();
2555
- console.log(` ${c.bold}Config:${c.reset} ${USER_CONFIG_FILE}`);
2556
- if (Object.keys(cfg).length === 0) {
2557
- console.log(` ${c.dim}(empty — using defaults)${c.reset}`);
2558
- } else {
2559
- for (const [k, v] of Object.entries(cfg)) {
2560
- console.log(` ${c.dim}${k}:${c.reset} ${v}`);
2561
- }
2562
- }
2563
- } else if (subCmd === 'reset') {
2564
- try { fs.unlinkSync(USER_CONFIG_FILE); } catch {}
2565
- console.log(` ${c.green}✔${c.reset} Config reset to defaults.`);
2566
- } else {
2567
- console.log(` ${c.red}✖ Unknown config command${c.reset}`);
2568
- console.log(` ${c.dim}Usage: agentaudit config set provider <name>${c.reset}`);
2569
- console.log(` ${c.dim} agentaudit config get${c.reset}`);
2570
- console.log(` ${c.dim} agentaudit config reset${c.reset}`);
2571
- }
2048
+ if (command === 'discover') {
2049
+ const scanFlag = targets.includes('--quick') || targets.includes('--scan') || targets.includes('-s');
2050
+ const auditFlag = targets.includes('--deep') || targets.includes('--audit') || targets.includes('-a');
2051
+ await discoverCommand({ scan: scanFlag, audit: auditFlag });
2572
2052
  return;
2573
2053
  }
2574
2054
 
2575
2055
  if (command === 'lookup' || command === 'check') {
2576
2056
  const names = targets.filter(t => !t.startsWith('--'));
2577
2057
  if (names.length === 0) {
2578
- console.log(` ${c.red}✖ Package name or URL required${c.reset}`);
2579
- console.log(` ${c.dim}Usage: agentaudit check <name|url>${c.reset}`);
2058
+ console.log(` ${c.red}Error: package name required${c.reset}`);
2580
2059
  process.exitCode = 2;
2581
2060
  return;
2582
2061
  }
2583
2062
  const results = [];
2584
- const allowAutoAudit = command === 'check'; // only 'check' auto-audits, 'lookup' never does
2585
2063
  for (const t of names) {
2586
- const data = await checkPackage(t, { autoAudit: allowAutoAudit });
2064
+ const data = await checkPackage(t);
2587
2065
  results.push(data);
2588
2066
  }
2589
2067
  if (jsonMode) {
2590
2068
  console.log(JSON.stringify(results.length === 1 ? (results[0] || { error: 'not_found' }) : results, null, 2));
2591
2069
  }
2592
2070
  process.exitCode = 0; return;
2071
+ return;
2593
2072
  }
2594
2073
 
2595
2074
  if (command === 'scan') {
2075
+ const deepFlag = targets.includes('--deep');
2596
2076
  const urls = targets.filter(t => !t.startsWith('--'));
2597
2077
  if (urls.length === 0) {
2598
- console.log(` ${c.red}✖ Repository URL required${c.reset}`);
2599
- console.log(` ${c.dim}Usage: agentaudit scan <url>${c.reset}`);
2600
- console.log(` ${c.dim}Or discover local servers: ${c.cyan}agentaudit discover${c.reset}`);
2078
+ console.log(` ${c.red}Error: at least one repository URL required${c.reset}`);
2079
+ console.log(` ${c.dim}Tip: use ${c.cyan}agentaudit discover${c.dim} to find & check locally installed MCP servers${c.reset}`);
2080
+ console.log(` ${c.dim}Tip: use ${c.cyan}agentaudit audit <url>${c.dim} for a deep LLM-powered audit${c.reset}`);
2601
2081
  process.exitCode = 2;
2602
2082
  return;
2603
2083
  }
2604
2084
 
2085
+ // --deep redirects to audit flow
2086
+ if (deepFlag) {
2087
+ let hasFindings = false;
2088
+ for (const url of urls) {
2089
+ const report = await auditRepo(url);
2090
+ if (report?.findings?.length > 0) hasFindings = true;
2091
+ }
2092
+ process.exitCode = hasFindings ? 1 : 0;
2093
+ return;
2094
+ }
2095
+
2605
2096
  const results = [];
2606
2097
  let hadErrors = false;
2607
2098
  for (const url of urls) {
@@ -2638,8 +2129,7 @@ async function main() {
2638
2129
  if (command === 'audit') {
2639
2130
  const urls = targets.filter(t => !t.startsWith('--'));
2640
2131
  if (urls.length === 0) {
2641
- console.log(` ${c.red}✖ Repository URL required${c.reset}`);
2642
- console.log(` ${c.dim}Usage: agentaudit audit <url>${c.reset}`);
2132
+ console.log(` ${c.red}Error: at least one repository URL required${c.reset}`);
2643
2133
  process.exitCode = 2;
2644
2134
  return;
2645
2135
  }
@@ -2653,18 +2143,8 @@ async function main() {
2653
2143
  return;
2654
2144
  }
2655
2145
 
2656
- // Typo correction via Levenshtein distance
2657
- const knownCommands = ['discover', 'scan', 'audit', 'check', 'lookup', 'status', 'setup', 'config', 'models'];
2658
- const suggestion = knownCommands
2659
- .map(cmd => ({ cmd, dist: levenshtein(command, cmd) }))
2660
- .filter(x => x.dist <= 3)
2661
- .sort((a, b) => a.dist - b.dist)[0];
2662
-
2663
- console.log(` ${c.red}✖ Unknown command: ${command}${c.reset}`);
2664
- if (suggestion) {
2665
- console.log(` ${c.dim}Did you mean: ${c.cyan}agentaudit ${suggestion.cmd}${c.reset}${c.dim}?${c.reset}`);
2666
- }
2667
- console.log(` ${c.dim}Run ${c.cyan}agentaudit --help${c.dim} for usage${c.reset}`);
2146
+ console.log(` ${c.red}Unknown command: ${command}${c.reset}`);
2147
+ console.log(` ${c.dim}Run agentaudit --help for usage${c.reset}`);
2668
2148
  process.exitCode = 2;
2669
2149
  }
2670
2150