agentaudit 3.9.48 → 3.10.1

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 +741 -1121
  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, --no-upload
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,75 @@ 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
+ ];
51
+
52
+ // ── Provider-specific model choices (for interactive menu) ──
53
+ const PROVIDER_MODELS = {
54
+ anthropic: [
55
+ { label: 'claude-sonnet-4-20250514', sublabel: 'fast + smart (default)', value: 'claude-sonnet-4-20250514' },
56
+ { label: 'claude-opus-4-20250514', sublabel: 'most capable', value: 'claude-opus-4-20250514' },
57
+ ],
58
+ openai: [
59
+ { label: 'gpt-4o', sublabel: 'fast multimodal (default)', value: 'gpt-4o' },
60
+ { label: 'gpt-4.1', sublabel: 'latest', value: 'gpt-4.1' },
61
+ ],
62
+ google: [
63
+ { label: 'gemini-2.5-flash', sublabel: 'fast + cheap (default)', value: 'gemini-2.5-flash' },
64
+ { label: 'gemini-2.5-pro', sublabel: 'most capable', value: 'gemini-2.5-pro' },
65
+ ],
66
+ deepseek: [
67
+ { label: 'deepseek-chat', sublabel: 'cost-effective (default)', value: 'deepseek-chat' },
68
+ ],
69
+ mistral: [
70
+ { label: 'mistral-large-latest', sublabel: 'EU-hosted (default)', value: 'mistral-large-latest' },
71
+ ],
72
+ groq: [
73
+ { label: 'llama-3.3-70b-versatile', sublabel: 'ultra-fast (default)', value: 'llama-3.3-70b-versatile' },
74
+ ],
75
+ xai: [
76
+ { label: 'grok-3', sublabel: 'real-time knowledge (default)', value: 'grok-3' },
77
+ ],
78
+ together: [
79
+ { label: 'meta-llama/Llama-3.3-70B-Instruct-Turbo', sublabel: 'open source (default)', value: 'meta-llama/Llama-3.3-70B-Instruct-Turbo' },
80
+ ],
81
+ fireworks: [
82
+ { label: 'accounts/fireworks/models/llama-v3p3-70b-instruct', sublabel: 'open source (default)', value: 'accounts/fireworks/models/llama-v3p3-70b-instruct' },
83
+ ],
84
+ cerebras: [
85
+ { label: 'llama-3.3-70b', sublabel: 'fast inference (default)', value: 'llama-3.3-70b' },
86
+ ],
87
+ zhipu: [
88
+ { label: 'glm-4.7', sublabel: 'Chinese language (default)', value: 'glm-4.7' },
89
+ ],
90
+ openrouter: [
91
+ { label: 'anthropic/claude-sonnet-4', sublabel: 'default', value: 'anthropic/claude-sonnet-4' },
92
+ { label: 'qwen/qwen3-coder', sublabel: 'code specialist', value: 'qwen/qwen3-coder' },
93
+ { label: 'meta-llama/Llama-3.3-70B', sublabel: 'open source', value: 'meta-llama/Llama-3.3-70B' },
94
+ ],
95
+ };
79
96
 
80
97
  // ── ANSI Colors (respects NO_COLOR and --no-color) ───────
81
98
 
@@ -137,55 +154,70 @@ function loadCredentials() {
137
154
  return null;
138
155
  }
139
156
 
140
- function saveCredentials(data) {
141
- const json = JSON.stringify(data, null, 2);
157
+ function loadLlmConfig() {
158
+ for (const f of [SKILL_CRED_FILE, USER_CRED_FILE]) {
159
+ if (fs.existsSync(f)) {
160
+ try {
161
+ const data = JSON.parse(fs.readFileSync(f, 'utf8'));
162
+ if (data.llm_model || data.preferred_provider) {
163
+ return { llm_model: data.llm_model || null, preferred_provider: data.preferred_provider || null };
164
+ }
165
+ } catch {}
166
+ }
167
+ }
168
+ return null;
169
+ }
170
+
171
+ function saveLlmConfig(model, provider) {
172
+ // Merge into existing credentials
173
+ let existing = {};
174
+ if (fs.existsSync(USER_CRED_FILE)) {
175
+ try { existing = JSON.parse(fs.readFileSync(USER_CRED_FILE, 'utf8')); } catch {}
176
+ }
177
+ if (model !== undefined) existing.llm_model = model;
178
+ if (provider !== undefined) existing.preferred_provider = provider;
179
+ const json = JSON.stringify(existing, null, 2);
142
180
  fs.mkdirSync(USER_CRED_DIR, { recursive: true });
143
181
  fs.writeFileSync(USER_CRED_FILE, json, { mode: 0o600 });
144
182
  try {
183
+ let skillExisting = {};
184
+ if (fs.existsSync(SKILL_CRED_FILE)) {
185
+ try { skillExisting = JSON.parse(fs.readFileSync(SKILL_CRED_FILE, 'utf8')); } catch {}
186
+ }
187
+ if (model !== undefined) skillExisting.llm_model = model;
188
+ if (provider !== undefined) skillExisting.preferred_provider = provider;
145
189
  fs.mkdirSync(path.dirname(SKILL_CRED_FILE), { recursive: true });
146
- fs.writeFileSync(SKILL_CRED_FILE, json, { mode: 0o600 });
190
+ fs.writeFileSync(SKILL_CRED_FILE, JSON.stringify(skillExisting, null, 2), { mode: 0o600 });
147
191
  } catch {}
148
192
  }
149
193
 
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');
194
+ function resolveProvider() {
195
+ const config = loadLlmConfig();
196
+ const preferred = config?.preferred_provider;
152
197
 
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
- }
198
+ if (preferred) {
199
+ // Find provider by name, check if any of their keys is set
200
+ const match = LLM_PROVIDERS.find(p => p.provider === preferred && process.env[p.key]);
201
+ if (match) return match;
202
+ // Key missing for preferred provider — warn + fallback
203
+ const providerInfo = LLM_PROVIDERS.find(p => p.provider === preferred);
204
+ if (providerInfo && !quietMode) {
205
+ console.log(` ${c.yellow}Preferred provider "${providerInfo.name}" missing key (${providerInfo.key}), falling back...${c.reset}`);
206
+ }
207
+ }
160
208
 
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; }
209
+ // Fallback: first match wins
210
+ return LLM_PROVIDERS.find(p => process.env[p.key]) || null;
173
211
  }
174
212
 
175
- function loadConfig() {
213
+ function saveCredentials(data) {
214
+ const json = JSON.stringify(data, null, 2);
215
+ fs.mkdirSync(USER_CRED_DIR, { recursive: true });
216
+ fs.writeFileSync(USER_CRED_FILE, json, { mode: 0o600 });
176
217
  try {
177
- if (fs.existsSync(USER_CONFIG_FILE)) {
178
- return JSON.parse(fs.readFileSync(USER_CONFIG_FILE, 'utf8'));
179
- }
218
+ fs.mkdirSync(path.dirname(SKILL_CRED_FILE), { recursive: true });
219
+ fs.writeFileSync(SKILL_CRED_FILE, json, { mode: 0o600 });
180
220
  } 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
221
  }
190
222
 
191
223
  function askQuestion(question) {
@@ -298,6 +330,77 @@ function multiSelect(items, { title = 'Select items', hint = 'Space=toggle ↑
298
330
  });
299
331
  }
300
332
 
333
+ /**
334
+ * Interactive single-select in terminal. No dependencies.
335
+ * items: [{ label, sublabel?, value }]
336
+ * Returns: selected value (or null if cancelled)
337
+ */
338
+ function singleSelect(items, { title = 'Select', hint = '↑↓=move Enter=select Esc=cancel' } = {}) {
339
+ return new Promise((resolve) => {
340
+ if (!process.stdin.isTTY) {
341
+ resolve(items[0]?.value || null);
342
+ return;
343
+ }
344
+
345
+ let cursor = 0;
346
+
347
+ const render = () => {
348
+ process.stdout.write(`\x1b[${items.length + 3}A\x1b[J`);
349
+ draw();
350
+ };
351
+
352
+ const draw = () => {
353
+ console.log(` ${c.bold}${title}${c.reset}`);
354
+ console.log(` ${c.dim}${hint}${c.reset}`);
355
+ console.log();
356
+ for (let i = 0; i < items.length; i++) {
357
+ const item = items[i];
358
+ const isCursor = i === cursor;
359
+ const pointer = isCursor ? `${c.cyan}❯${c.reset}` : ' ';
360
+ const label = isCursor ? `${c.bold}${c.cyan}${item.label}${c.reset}` : item.label;
361
+ const sub = item.sublabel ? ` ${c.dim}${item.sublabel}${c.reset}` : '';
362
+ console.log(` ${pointer} ${label}${sub}`);
363
+ }
364
+ };
365
+
366
+ draw();
367
+
368
+ process.stdin.setRawMode(true);
369
+ process.stdin.resume();
370
+ process.stdin.setEncoding('utf8');
371
+
372
+ const onData = (key) => {
373
+ if (key === '\x03' || key === '\x1b') {
374
+ process.stdin.setRawMode(false);
375
+ process.stdin.pause();
376
+ process.stdin.removeListener('data', onData);
377
+ console.log();
378
+ resolve(null);
379
+ return;
380
+ }
381
+ if (key === '\r' || key === '\n') {
382
+ process.stdin.setRawMode(false);
383
+ process.stdin.pause();
384
+ process.stdin.removeListener('data', onData);
385
+ resolve(items[cursor].value);
386
+ return;
387
+ }
388
+ if (key === '\x1b[A' || key === 'k') {
389
+ cursor = (cursor - 1 + items.length) % items.length;
390
+ render();
391
+ return;
392
+ }
393
+ if (key === '\x1b[B' || key === 'j') {
394
+ cursor = (cursor + 1) % items.length;
395
+ render();
396
+ return;
397
+ }
398
+ };
399
+
400
+ process.stdin.on('data', onData);
401
+ });
402
+ }
403
+
301
404
  async function registerAgent(agentName) {
302
405
  const res = await fetch(`${REGISTRY_URL}/api/register`, {
303
406
  method: 'POST',
@@ -354,8 +457,6 @@ async function setupCommand() {
354
457
  console.log(` ${icons.safe} Registered as ${c.bold}${data.agent_name}${c.reset}`);
355
458
  console.log(` ${c.dim}Key: ${data.api_key.slice(0, 12)}...${c.reset}`);
356
459
  console.log(` ${c.dim}Saved to: ${USER_CRED_FILE}${c.reset}`);
357
- // Initialize stats cache
358
- refreshStatsCache(data.agent_name).catch(() => {});
359
460
  } catch (err) {
360
461
  console.log(` ${c.red}failed${c.reset}`);
361
462
  console.log(` ${c.red}${err.message}${c.reset}`);
@@ -364,6 +465,20 @@ async function setupCommand() {
364
465
  }
365
466
 
366
467
  console.log();
468
+
469
+ // ── LLM configuration hint ──
470
+ const llmConfig = loadLlmConfig();
471
+ console.log(` ${c.bold}LLM Configuration${c.reset}`);
472
+ if (llmConfig?.llm_model || llmConfig?.preferred_provider) {
473
+ const parts = [];
474
+ if (llmConfig.preferred_provider) parts.push(llmConfig.preferred_provider);
475
+ if (llmConfig.llm_model) parts.push(llmConfig.llm_model);
476
+ console.log(` ${icons.safe} Current: ${c.bold}${parts.join(' → ')}${c.reset}`);
477
+ }
478
+ console.log(` ${c.dim}Run ${c.cyan}agentaudit model${c.dim} to configure your LLM provider and model.${c.reset}`);
479
+ console.log(` ${c.dim}Deep audits require an LLM API key in your environment.${c.reset}`);
480
+ console.log();
481
+
367
482
  console.log(` ${c.bold}Ready!${c.reset} You can now:`);
368
483
  console.log(` ${c.dim}•${c.reset} Discover servers: ${c.cyan}agentaudit discover${c.reset}`);
369
484
  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 +488,27 @@ async function setupCommand() {
373
488
  console.log();
374
489
  }
375
490
 
376
- // ── Structured error output ─────────────────────────────
491
+ // ── Helpers ──────────────────────────────────────────────
377
492
 
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');
493
+ function validateGitUrl(url) {
494
+ // Reject URLs with shell metacharacters to prevent command injection
495
+ if (/[;&|`$(){}!\n\r]/.test(url)) {
496
+ throw new Error(`Rejected URL with suspicious characters: ${url.slice(0, 80)}`);
497
+ }
498
+ // Must look like a URL (http/https/git/ssh) or a GitHub shorthand
499
+ if (!/^(https?:\/\/|git@|git:\/\/|ssh:\/\/)/.test(url) && !/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/.test(url)) {
500
+ throw new Error(`Invalid repository URL: ${url.slice(0, 80)}`);
381
501
  }
382
- process.exitCode = exitCode;
383
502
  }
384
503
 
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];
504
+ function safeGitClone(url, destPath, timeoutMs = 30_000) {
505
+ validateGitUrl(url);
506
+ execFileSync('git', ['clone', '--depth', '1', url, destPath], {
507
+ timeout: timeoutMs,
508
+ stdio: 'pipe',
509
+ });
396
510
  }
397
511
 
398
- // ── Helpers ──────────────────────────────────────────────
399
-
400
512
  function getVersion() {
401
513
  try {
402
514
  const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8'));
@@ -404,38 +516,17 @@ function getVersion() {
404
516
  } catch { return '0.0.0'; }
405
517
  }
406
518
 
407
- async function banner() {
519
+ function banner() {
408
520
  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
521
  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
- }
522
+ console.log(` ${c.bold}${c.cyan}AgentAudit${c.reset} ${c.dim}v${getVersion()}${c.reset}`);
523
+ console.log(` ${c.dim}Security scanner for AI packages${c.reset}`);
426
524
  console.log();
427
525
  }
428
526
 
429
527
  function slugFromUrl(url) {
430
528
  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
- }
529
+ if (match) return match[2].toLowerCase().replace(/[^a-z0-9-]/g, '-');
439
530
  return url.replace(/[^a-z0-9]/gi, '-').toLowerCase().slice(0, 60);
440
531
  }
441
532
 
@@ -474,6 +565,46 @@ function severityIcon(sev) {
474
565
 
475
566
  // ── File Collection (same logic as MCP server) ──────────
476
567
 
568
+ function formatApiError(error, provider, statusCode) {
569
+ // Extract error message from various API response formats
570
+ const msg = (typeof error === 'string' ? error : error?.message || error?.error?.message || JSON.stringify(error)).toLowerCase();
571
+
572
+ // Authentication errors
573
+ if (statusCode === 401 || statusCode === 403 || msg.includes('invalid api key') || msg.includes('invalid x-api-key') ||
574
+ msg.includes('incorrect api key') || msg.includes('authentication') || msg.includes('unauthorized') ||
575
+ msg.includes('invalid_api_key') || msg.includes('permission denied')) {
576
+ return { text: 'Invalid or expired API key', hint: `Check your ${provider} API key. Run: echo $${provider}_API_KEY` };
577
+ }
578
+
579
+ // Rate limits / quota
580
+ if (statusCode === 429 || msg.includes('rate limit') || msg.includes('rate_limit') || msg.includes('too many requests') ||
581
+ msg.includes('quota') || msg.includes('insufficient_quota') || msg.includes('billing') ||
582
+ msg.includes('exceeded') || msg.includes('no credits') || msg.includes('credit') ||
583
+ msg.includes('overloaded') || msg.includes('capacity')) {
584
+ return { text: 'Rate limit or quota exceeded', hint: 'Wait a moment and retry, or check your billing/credits at your provider dashboard' };
585
+ }
586
+
587
+ // Model not found
588
+ if (statusCode === 404 || msg.includes('not found') || msg.includes('not a valid model') ||
589
+ msg.includes('model_not_found') || msg.includes('does not exist') || msg.includes('invalid model')) {
590
+ return { text: 'Model not found', hint: `"${msg}" — check model name with: agentaudit model` };
591
+ }
592
+
593
+ // Context length / payload too large
594
+ if (statusCode === 413 || msg.includes('context length') || msg.includes('too long') ||
595
+ msg.includes('maximum') || msg.includes('token limit') || msg.includes('content_too_large')) {
596
+ 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' };
597
+ }
598
+
599
+ // Server errors
600
+ if (statusCode >= 500) {
601
+ return { text: `Provider server error (HTTP ${statusCode})`, hint: `${provider} might be experiencing issues. Try again later` };
602
+ }
603
+
604
+ // Fallback
605
+ return null;
606
+ }
607
+
477
608
  function extractJSON(text) {
478
609
  // 1. Try parsing the entire text as JSON directly
479
610
  try { return JSON.parse(text.trim()); } catch {}
@@ -526,60 +657,36 @@ function extractJSON(text) {
526
657
 
527
658
  const MAX_FILE_SIZE = 50_000;
528
659
  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
660
  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',
661
+ 'node_modules', '.git', '__pycache__', '.venv', 'venv', 'dist', 'build',
662
+ '.next', '.nuxt', 'coverage', '.pytest_cache', '.mypy_cache', 'vendor',
663
+ 'test', 'tests', '__tests__', 'spec', 'specs', 'docs', 'doc',
664
+ 'examples', 'example', 'fixtures', '.github', '.vscode', '.idea',
665
+ 'e2e', 'benchmark', 'benchmarks', '.tox', '.eggs', 'htmlcov',
543
666
  ]);
544
667
  const SKIP_EXTENSIONS = new Set([
545
668
  '.lock', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.ico', '.woff',
546
669
  '.woff2', '.ttf', '.eot', '.mp3', '.mp4', '.zip', '.tar', '.gz',
547
670
  '.map', '.min.js', '.min.css', '.d.ts', '.pyc', '.pyo', '.so',
548
671
  '.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
672
  ]);
561
673
 
562
- function collectFiles(dir, basePath = '', collected = [], totalSize = { bytes: 0, truncated: false, skippedPaths: [] }) {
563
- if (totalSize.bytes >= MAX_TOTAL_SIZE) { totalSize.truncated = true; return collected; }
674
+ function collectFiles(dir, basePath = '', collected = [], totalSize = { bytes: 0 }) {
675
+ if (totalSize.bytes >= MAX_TOTAL_SIZE) return collected;
564
676
  let entries;
565
677
  try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
566
678
  catch { return collected; }
567
679
  entries.sort((a, b) => a.name.localeCompare(b.name));
568
680
  for (const entry of entries) {
681
+ if (totalSize.bytes >= MAX_TOTAL_SIZE) break;
569
682
  const relPath = basePath ? `${basePath}/${entry.name}` : entry.name;
570
- if (totalSize.bytes >= MAX_TOTAL_SIZE) { totalSize.truncated = true; totalSize.skippedPaths.push(relPath); continue; }
571
683
  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
684
  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;
685
+ if (SKIP_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
578
686
  collectFiles(fullPath, relPath, collected, totalSize);
579
687
  } else {
580
688
  const ext = path.extname(entry.name).toLowerCase();
581
689
  if (SKIP_EXTENSIONS.has(ext)) continue;
582
- if (SKIP_FILES.has(entry.name.toLowerCase())) continue;
583
690
  try {
584
691
  const stat = fs.statSync(fullPath);
585
692
  if (stat.size > MAX_FILE_SIZE || stat.size === 0) continue;
@@ -924,10 +1031,7 @@ async function scanRepo(url) {
924
1031
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentaudit-'));
925
1032
  const repoPath = path.join(tmpDir, 'repo');
926
1033
  try {
927
- execSync(`git clone --depth 1 "${url}" "${repoPath}"`, {
928
- timeout: 30_000,
929
- stdio: 'pipe',
930
- });
1034
+ safeGitClone(url, repoPath);
931
1035
  } catch (err) {
932
1036
  if (!jsonMode) {
933
1037
  process.stdout.write(` ${c.red}✖ clone failed${c.reset}\n`);
@@ -1437,9 +1541,7 @@ async function auditRepo(url) {
1437
1541
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentaudit-'));
1438
1542
  const repoPath = path.join(tmpDir, 'repo');
1439
1543
  try {
1440
- execSync(`git clone --depth 1 "${url}" "${repoPath}"`, {
1441
- timeout: 30_000, stdio: 'pipe',
1442
- });
1544
+ safeGitClone(url, repoPath);
1443
1545
  console.log(` ${c.green}done${c.reset}`);
1444
1546
  } catch (err) {
1445
1547
  console.log(` ${c.red}failed${c.reset}`);
@@ -1449,165 +1551,48 @@ async function auditRepo(url) {
1449
1551
  return null;
1450
1552
  }
1451
1553
 
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
1554
  // Step 2: Collect files
1464
1555
  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);
1556
+ const files = collectFiles(repoPath);
1467
1557
  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
-
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
1558
 
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
1559
+ // Step 3: Build audit payload
1532
1560
  process.stdout.write(` ${c.dim}[3/4]${c.reset} Preparing audit payload...`);
1533
1561
  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
1562
 
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}`);
1563
+ let codeBlock = '';
1564
+ for (const file of files) {
1565
+ codeBlock += `\n### FILE: ${file.path}\n\`\`\`\n${file.content}\n\`\`\`\n`;
1573
1566
  }
1574
- // For single-pass, use the only chunk as codeBlock
1575
- const codeBlock = chunks[0] || '';
1567
+ console.log(` ${c.green}done${c.reset}`);
1576
1568
 
1577
1569
  // Step 4: LLM Analysis
1578
- const activeProvider = resolvedProvider?.label || null;
1579
-
1580
- if (!resolvedProvider) {
1581
- // No LLM API key clear explanation
1582
- 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.`);
1584
- 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}`);
1587
- 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}`);
1591
- console.log();
1592
- console.log(` ${c.dim}# Linux / macOS:${c.reset}`);
1593
- console.log(` ${c.dim}export ANTHROPIC_API_KEY=sk-ant-...${c.reset}`);
1594
- console.log(` ${c.dim}export OPENAI_API_KEY=sk-...${c.reset}`);
1570
+ // Resolve provider: preferred_provider from config → first match fallback
1571
+ const activeLlm = resolveProvider();
1572
+ const llmApiKey = activeLlm ? process.env[activeLlm.key] : null;
1573
+ const activeProvider = activeLlm ? activeLlm.name : null;
1574
+
1575
+ // Model override: --model flag > AGENTAUDIT_MODEL env > credentials.json > provider default
1576
+ const modelArgIdx = process.argv.indexOf('--model');
1577
+ const modelFlag = modelArgIdx !== -1 ? process.argv[modelArgIdx + 1] : null;
1578
+ const modelEnv = process.env.AGENTAUDIT_MODEL;
1579
+ const modelConfig = loadLlmConfig()?.llm_model;
1580
+ const modelOverride = modelFlag || modelEnv || modelConfig || null;
1581
+ if (activeLlm && modelOverride) {
1582
+ activeLlm.model = modelOverride;
1583
+ }
1584
+
1585
+ if (!activeLlm) {
1586
+ // No LLM API key — compact explanation
1595
1587
  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}`);
1588
+ console.log(` ${c.yellow}No LLM API key found.${c.reset} The ${c.bold}audit${c.reset} command needs an LLM to analyze code.`);
1599
1589
  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}`);
1590
+ console.log(` ${c.bold}Set an API key${c.reset} (e.g. ${c.cyan}export OPENROUTER_API_KEY=sk-or-...${c.reset})`);
1591
+ console.log(` ${c.dim}Run "agentaudit model" to configure provider + model interactively${c.reset}`);
1603
1592
  console.log();
1604
- console.log(` ${c.bold}Option 2: Export for manual review${c.reset}`);
1605
- 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}`);
1607
- console.log();
1608
- console.log(` ${c.bold}Option 3: Use MCP in Claude/Cursor/Windsurf (no API key needed)${c.reset}`);
1609
- console.log(` ${c.dim}Add AgentAudit as MCP server — your editor's agent runs the audit using its own LLM.${c.reset}`);
1610
- console.log(` ${c.dim}Config: { "mcpServers": { "agentaudit": { "command": "npx", "args": ["-y", "agentaudit"] } } }${c.reset}`);
1593
+ console.log(` ${c.bold}Or export for manual review:${c.reset} ${c.cyan}agentaudit audit ${url} --export${c.reset}`);
1594
+ console.log(` ${c.bold}Or use as MCP server${c.reset} in Cursor/Claude ${c.dim}(no extra API key needed)${c.reset}`);
1595
+ console.log(` ${c.dim}{ "agentaudit": { "command": "npx", "args": ["-y", "agentaudit"] } }${c.reset}`);
1611
1596
  console.log();
1612
1597
 
1613
1598
  // Check if --export flag
@@ -1643,319 +1628,181 @@ async function auditRepo(url) {
1643
1628
  return null;
1644
1629
  }
1645
1630
 
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
- }
1631
+ // We have an API key — run LLM audit
1632
+ const modelLabel = modelOverride ? `${activeProvider} → ${activeLlm.model}` : activeProvider;
1633
+ process.stdout.write(` ${c.dim}[4/4]${c.reset} Running LLM analysis ${c.dim}(${modelLabel})${c.reset}...`);
1634
+
1635
+ const systemPrompt = auditPrompt || 'You are a security auditor. Analyze the code and report findings as JSON.';
1636
+ const userMessage = [
1637
+ `Audit this package: **${slug}** (${url})`,
1638
+ ``,
1639
+ `After analysis, respond with ONLY a valid JSON object. No markdown fences, no explanation, no text before or after. Just the raw JSON:`,
1640
+ `{ "skill_slug": "${slug}", "source_url": "${url}", "package_type": "<mcp-server|agent-skill|library|cli-tool>",`,
1641
+ ` "risk_score": <0-100>, "result": "<safe|caution|unsafe>", "max_severity": "<none|low|medium|high|critical>",`,
1642
+ ` "findings_count": <n>, "findings": [{ "id": "...", "title": "...", "severity": "...", "category": "...",`,
1643
+ ` "description": "...", "file": "...", "line": <n>, "remediation": "...", "confidence": "...", "is_by_design": false }] }`,
1644
+ ``,
1645
+ `## Source Code`,
1646
+ codeBlock,
1647
+ ].join('\n');
1721
1648
 
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
1649
  let report = null;
1759
- let providerMeta = {};
1760
1650
  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}`);
1651
+
1652
+ try {
1653
+ let data;
1654
+ if (activeLlm.type === 'anthropic') {
1655
+ // Anthropic Messages API (unique format)
1656
+ const res = await fetch(activeLlm.url, {
1657
+ method: 'POST',
1658
+ headers: {
1659
+ 'x-api-key': llmApiKey,
1660
+ 'anthropic-version': '2023-06-01',
1661
+ 'content-type': 'application/json',
1662
+ },
1663
+ body: JSON.stringify({
1664
+ model: activeLlm.model,
1665
+ max_tokens: 8192,
1666
+ system: systemPrompt,
1667
+ messages: [{ role: 'user', content: userMessage }],
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}`);
1678
+ } else {
1679
+ console.log(` ${c.red}API error: ${data.error.message || JSON.stringify(data.error)}${c.reset}`);
1780
1680
  }
1781
- continue;
1681
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
1682
+ return null;
1782
1683
  }
1783
-
1784
- if (!result.report) {
1785
- console.log(` ${c.yellow}no findings (empty/unparseable)${c.reset}`);
1786
- _lastLlmText = result.rawText || '';
1787
- continue;
1788
- }
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);
1684
+ _lastLlmText = data.content?.[0]?.text || '';
1685
+ report = extractJSON(_lastLlmText);
1686
+ if (report) {
1687
+ report.audit_model = data.model || activeLlm.model;
1688
+ report.audit_provider = activeLlm.provider;
1689
+ if (data.id) report.provider_msg_id = data.id;
1690
+ if (data.usage) {
1691
+ report.input_tokens = data.usage.input_tokens;
1692
+ report.output_tokens = data.usage.output_tokens;
1693
+ }
1815
1694
  }
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 };
1695
+ } else if (activeLlm.type === 'gemini') {
1696
+ // Google Gemini API (unique format)
1697
+ const res = await fetch(`${activeLlm.url}/${activeLlm.model}:generateContent?key=${llmApiKey}`, {
1698
+ method: 'POST',
1699
+ headers: { 'Content-Type': 'application/json' },
1700
+ body: JSON.stringify({
1701
+ systemInstruction: { parts: [{ text: systemPrompt }] },
1702
+ contents: [{ role: 'user', parts: [{ text: userMessage }] }],
1703
+ generationConfig: { maxOutputTokens: 8192 },
1704
+ }),
1705
+ signal: AbortSignal.timeout(120_000),
1706
+ });
1707
+ data = await res.json();
1708
+ if (data.error) {
1709
+ console.log(` ${c.red}failed${c.reset}`);
1710
+ const friendly = formatApiError(data.error, activeLlm.provider, res.status);
1711
+ if (friendly) {
1712
+ console.log(` ${c.red}${friendly.text}${c.reset}`);
1713
+ console.log(` ${c.dim}${friendly.hint}${c.reset}`);
1900
1714
  } else {
1901
- console.log(` ${c.green}clean${c.reset}`);
1715
+ console.log(` ${c.red}API error: ${data.error.message || JSON.stringify(data.error)}${c.reset}`);
1902
1716
  }
1903
- } else {
1904
- console.log(` ${c.dim}skipped (too few files with imports)${c.reset}`);
1717
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
1718
+ return null;
1905
1719
  }
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}`);
1720
+ _lastLlmText = data.candidates?.[0]?.content?.parts?.[0]?.text || '';
1721
+ report = extractJSON(_lastLlmText);
1722
+ if (report) {
1723
+ report.audit_model = data.modelVersion || activeLlm.model;
1724
+ report.audit_provider = activeLlm.provider;
1725
+ if (data.usageMetadata) {
1726
+ report.input_tokens = data.usageMetadata.promptTokenCount;
1727
+ report.output_tokens = data.usageMetadata.candidatesTokenCount;
1728
+ }
1729
+ }
1730
+ } else {
1731
+ // OpenAI-compatible API (OpenAI, Mistral, Groq, OpenRouter, etc.)
1732
+ const headers = {
1733
+ 'Authorization': `Bearer ${llmApiKey}`,
1734
+ 'Content-Type': 'application/json',
1735
+ };
1736
+ // OpenRouter requires additional headers
1737
+ if (activeLlm.provider === 'openrouter') {
1738
+ headers['HTTP-Referer'] = 'https://agentaudit.dev';
1739
+ headers['X-Title'] = 'AgentAudit CLI';
1740
+ }
1741
+ const res = await fetch(activeLlm.url, {
1742
+ method: 'POST',
1743
+ headers,
1744
+ body: JSON.stringify({
1745
+ model: activeLlm.model,
1746
+ max_tokens: 8192,
1747
+ messages: [
1748
+ { role: 'system', content: systemPrompt },
1749
+ { role: 'user', content: userMessage },
1750
+ ],
1751
+ }),
1752
+ signal: AbortSignal.timeout(120_000),
1753
+ });
1754
+ data = await res.json();
1755
+ if (data.error) {
1756
+ console.log(` ${c.red}failed${c.reset}`);
1757
+ const friendly = formatApiError(data.error, activeLlm.provider, res.status);
1758
+ if (friendly) {
1759
+ console.log(` ${c.red}${friendly.text}${c.reset}`);
1760
+ console.log(` ${c.dim}${friendly.hint}${c.reset}`);
1761
+ } else {
1762
+ console.log(` ${c.red}API error: ${data.error.message || JSON.stringify(data.error)}${c.reset}`);
1763
+ }
1764
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
1765
+ return null;
1925
1766
  }
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}`);
1767
+ _lastLlmText = data.choices?.[0]?.message?.content || '';
1768
+ report = extractJSON(_lastLlmText);
1769
+ if (report) {
1770
+ report.audit_model = data.model || activeLlm.model;
1771
+ report.audit_provider = activeLlm.provider;
1772
+ if (data.id) report.provider_msg_id = data.id;
1773
+ if (data.system_fingerprint) report.provider_fingerprint = data.system_fingerprint;
1774
+ if (data.usage) {
1775
+ report.input_tokens = data.usage.prompt_tokens;
1776
+ report.output_tokens = data.usage.completion_tokens;
1777
+ }
1929
1778
  }
1930
- try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
1931
- return null;
1932
1779
  }
1933
1780
 
1934
- report = result.report;
1935
- providerMeta = result.meta;
1936
- _lastLlmText = result.rawText || '';
1937
1781
  console.log(` ${c.green}done${c.reset} ${c.dim}(${elapsed(start)})${c.reset}`);
1782
+ } catch (err) {
1783
+ console.log(` ${c.red}failed${c.reset}`);
1784
+ if (err.name === 'TimeoutError' || err.message?.includes('timeout')) {
1785
+ console.log(` ${c.red}Request timed out (120s)${c.reset}`);
1786
+ console.log(` ${c.dim}The provider took too long to respond. Try again or use a faster model${c.reset}`);
1787
+ } else if (err.code === 'ENOTFOUND' || err.code === 'ECONNREFUSED' || err.message?.includes('fetch failed')) {
1788
+ console.log(` ${c.red}Network error: could not reach ${activeProvider}${c.reset}`);
1789
+ console.log(` ${c.dim}Check your internet connection or provider status${c.reset}`);
1790
+ } else {
1791
+ console.log(` ${c.red}${err.message}${c.reset}`);
1792
+ }
1793
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
1794
+ return null;
1938
1795
  }
1939
1796
 
1940
1797
  // Cleanup repo
1941
1798
  try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
1942
1799
 
1943
1800
  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) {
1801
+ console.log(` ${c.red}Could not parse LLM response as JSON${c.reset}`);
1802
+ console.log(` ${c.dim}Hint: run with --debug to see the raw LLM response${c.reset}`);
1803
+ if (process.argv.includes('--debug')) {
1957
1804
  console.log(` ${c.dim}--- Raw LLM response (first 2000 chars) ---${c.reset}`);
1958
- console.log(_lastLlmText.slice(0, 2000));
1805
+ console.log((typeof _lastLlmText === 'string' ? _lastLlmText : '(empty)').slice(0, 2000));
1959
1806
  console.log(` ${c.dim}--- end ---${c.reset}`);
1960
1807
  }
1961
1808
  return null;
@@ -1963,19 +1810,8 @@ async function auditRepo(url) {
1963
1810
 
1964
1811
  // Display results
1965
1812
  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}`);
1813
+ const riskScore = report.risk_score || 0;
1814
+ console.log(` ${riskBadge(riskScore)} Risk ${riskScore}/100 ${c.bold}${report.result || 'unknown'}${c.reset}`);
1979
1815
  console.log();
1980
1816
 
1981
1817
  if (report.findings && report.findings.length > 0) {
@@ -1993,20 +1829,12 @@ async function auditRepo(url) {
1993
1829
  console.log();
1994
1830
  }
1995
1831
 
1996
- // Upload to registry
1997
- 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
- if (creds) {
1832
+ // Upload to registry (skip with --no-upload)
1833
+ const noUpload = process.argv.includes('--no-upload');
1834
+ let creds = loadCredentials();
1835
+ if (noUpload) {
1836
+ // Skip silently
1837
+ } else if (creds) {
2010
1838
  process.stdout.write(` Uploading report to registry...`);
2011
1839
  try {
2012
1840
  const res = await fetch(`${REGISTRY_URL}/api/reports`, {
@@ -2015,86 +1843,64 @@ async function auditRepo(url) {
2015
1843
  'Authorization': `Bearer ${creds.api_key}`,
2016
1844
  'Content-Type': 'application/json',
2017
1845
  },
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
- }),
1846
+ body: JSON.stringify(report),
2031
1847
  signal: AbortSignal.timeout(15_000),
2032
1848
  });
2033
1849
  if (res.ok) {
2034
1850
  const data = await res.json();
2035
- const reportSlug = data?.skill_slug || data?.slug || slug;
2036
1851
  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 {}
1852
+ console.log(` ${c.dim}Report: ${REGISTRY_URL}/skills/${slug}${c.reset}`);
2087
1853
  } else {
2088
- const errBody = await res.text().catch(() => '');
2089
1854
  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
1855
  }
2092
1856
  } catch (err) {
2093
1857
  console.log(` ${c.yellow}failed${c.reset}`);
2094
- if (process.argv.includes('--debug')) console.log(` ${c.dim}Error: ${err.message}${c.reset}`);
1858
+ }
1859
+ } else if (process.stdin.isTTY) {
1860
+ // No credentials — offer inline registration
1861
+ console.log();
1862
+ console.log(` ${c.bold}Share this audit with the community?${c.reset}`);
1863
+ console.log(` ${c.dim}Uploading helps others assess package security. Account is free.${c.reset}`);
1864
+ console.log();
1865
+ console.log(` ${c.bold}1)${c.reset} Create account + upload ${c.dim}(free)${c.reset}`);
1866
+ console.log(` ${c.bold}2)${c.reset} Skip`);
1867
+ console.log();
1868
+ const uploadChoice = await askQuestion(` Choice ${c.dim}(1/2)${c.reset}: `);
1869
+ if (uploadChoice === '1') {
1870
+ const name = await askQuestion(` Agent name ${c.dim}(e.g. my-scanner, claude-desktop)${c.reset}: `);
1871
+ if (!name || !/^[a-zA-Z0-9._-]{2,64}$/.test(name)) {
1872
+ console.log(` ${c.red}Invalid name. Use 2-64 chars: letters, numbers, dash, underscore, dot.${c.reset}`);
1873
+ } else {
1874
+ try {
1875
+ process.stdout.write(` Registering ${c.bold}${name}${c.reset}...`);
1876
+ const regData = await registerAgent(name);
1877
+ saveCredentials({ api_key: regData.api_key, agent_name: regData.agent_name });
1878
+ console.log(` ${c.green}done!${c.reset}`);
1879
+ creds = { api_key: regData.api_key, agent_name: regData.agent_name };
1880
+ process.stdout.write(` Uploading report...`);
1881
+ const res = await fetch(`${REGISTRY_URL}/api/reports`, {
1882
+ method: 'POST',
1883
+ headers: {
1884
+ 'Authorization': `Bearer ${creds.api_key}`,
1885
+ 'Content-Type': 'application/json',
1886
+ },
1887
+ body: JSON.stringify(report),
1888
+ signal: AbortSignal.timeout(15_000),
1889
+ });
1890
+ if (res.ok) {
1891
+ console.log(` ${c.green}done${c.reset}`);
1892
+ console.log(` ${c.dim}Report: ${REGISTRY_URL}/skills/${slug}${c.reset}`);
1893
+ } else {
1894
+ console.log(` ${c.yellow}failed (HTTP ${res.status})${c.reset}`);
1895
+ }
1896
+ } catch (err) {
1897
+ console.log(` ${c.red}failed${c.reset}`);
1898
+ console.log(` ${c.dim}${err.message}${c.reset}`);
1899
+ }
1900
+ }
2095
1901
  }
2096
1902
  } else {
2097
- console.log(` ${c.dim}Run ${c.cyan}agentaudit setup${c.dim} to upload reports to the registry${c.reset}`);
1903
+ console.log(` ${c.dim}Run ${c.cyan}agentaudit setup${c.dim} to create an account and upload reports${c.reset}`);
2098
1904
  }
2099
1905
 
2100
1906
  console.log();
@@ -2103,89 +1909,28 @@ async function auditRepo(url) {
2103
1909
 
2104
1910
  // ── Check command ───────────────────────────────────────
2105
1911
 
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();
1912
+ async function checkPackage(name) {
2109
1913
  if (!jsonMode) {
2110
1914
  console.log(`${icons.info} Looking up ${c.bold}${name}${c.reset} in registry...`);
2111
1915
  console.log();
2112
1916
  }
2113
1917
 
2114
- const data = await checkRegistry(slug);
1918
+ const data = await checkRegistry(name);
2115
1919
  if (!data) {
2116
1920
  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}`);
1921
+ console.log(` ${c.yellow}Not found${c.reset} package "${name}" hasn't been audited yet.`);
1922
+ console.log(` ${c.dim}Run: agentaudit audit <repo-url> for a deep LLM audit${c.reset}`);
2130
1923
  }
2131
1924
  return null;
2132
1925
  }
2133
1926
 
2134
1927
  if (!jsonMode) {
2135
1928
  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}`);
1929
+ console.log(` ${c.bold}${name}${c.reset} ${riskBadge(riskScore)}`);
1930
+ console.log(` ${c.dim}Risk Score: ${riskScore}/100${c.reset}`);
1931
+ if (data.source_url) console.log(` ${c.dim}Source: ${data.source_url}${c.reset}`);
1932
+ console.log(` ${c.dim}Registry: ${REGISTRY_URL}/skills/${name}${c.reset}`);
2172
1933
  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
1934
  console.log();
2190
1935
  }
2191
1936
  return data;
@@ -2207,40 +1952,12 @@ async function main() {
2207
1952
  quietMode = rawArgs.includes('--quiet') || rawArgs.includes('-q');
2208
1953
  // --no-color already handled at top level for `c` object
2209
1954
 
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
2233
- const globalFlags = new Set(['--json', '--quiet', '-q', '--no-color']);
1955
+ // Strip global flags from args (including --model <value>)
1956
+ const globalFlags = new Set(['--json', '--quiet', '-q', '--no-color', '--no-upload']);
2234
1957
  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
- });
1958
+ // Remove --model <value> pair
1959
+ const modelIdx = args.indexOf('--model');
1960
+ if (modelIdx !== -1) args.splice(modelIdx, 2);
2244
1961
 
2245
1962
  if (args[0] === '-v' || args[0] === '--version') {
2246
1963
  console.log(`agentaudit ${getVersion()}`);
@@ -2248,46 +1965,47 @@ async function main() {
2248
1965
  }
2249
1966
 
2250
1967
  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]`);
1968
+ banner();
1969
+ console.log(` ${c.bold}Commands:${c.reset}`);
1970
+ console.log();
1971
+ console.log(` ${c.cyan}agentaudit${c.reset} Discover MCP servers (same as discover)`);
1972
+ console.log(` ${c.cyan}agentaudit discover${c.reset} Find MCP servers in your AI editors (Cursor, Claude, VS Code, Windsurf)`);
1973
+ console.log(` ${c.cyan}agentaudit discover --quick${c.reset} Discover + auto-scan all servers`);
1974
+ console.log(` ${c.cyan}agentaudit discover --deep${c.reset} Discover + select servers to deep-audit`);
1975
+ console.log(` ${c.cyan}agentaudit scan${c.reset} <url> [url...] Quick static scan (regex, local)`);
1976
+ console.log(` ${c.cyan}agentaudit scan${c.reset} <url> ${c.dim}--deep${c.reset} Deep audit (same as audit)`);
1977
+ console.log(` ${c.cyan}agentaudit audit${c.reset} <url> [url...] Deep LLM-powered security audit`);
1978
+ console.log(` ${c.cyan}agentaudit lookup${c.reset} <name> Look up package in registry`);
1979
+ console.log(` ${c.cyan}agentaudit model${c.reset} Configure LLM provider + model interactively`);
1980
+ console.log(` ${c.cyan}agentaudit model${c.reset} <name> Set model directly (e.g. gpt-4o)`);
1981
+ console.log(` ${c.cyan}agentaudit setup${c.reset} Create account / enter API key (for report uploads)`);
2254
1982
  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`);
1983
+ console.log(` ${c.bold}Global flags:${c.reset}`);
1984
+ console.log(` ${c.dim}--json Output JSON to stdout (machine-readable)${c.reset}`);
1985
+ console.log(` ${c.dim}--quiet Suppress banner and tree visualization${c.reset}`);
1986
+ console.log(` ${c.dim}--no-color Disable ANSI colors (also: NO_COLOR env)${c.reset}`);
1987
+ console.log(` ${c.dim}--model <name> Override LLM model for this run${c.reset}`);
1988
+ console.log(` ${c.dim}--no-upload Skip uploading report to registry${c.reset}`);
2259
1989
  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`);
1990
+ console.log(` ${c.bold}Quick Scan${c.reset} vs ${c.bold}Deep Audit${c.reset}:`);
1991
+ console.log(` ${c.dim}scan = fast regex-based static analysis (~2s)${c.reset}`);
1992
+ console.log(` ${c.dim}audit = deep LLM analysis with 3-pass methodology (~30s)${c.reset}`);
2263
1993
  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`);
1994
+ console.log(` ${c.bold}Exit codes:${c.reset}`);
1995
+ console.log(` ${c.dim}0 = clean / success 1 = findings detected 2 = error${c.reset}`);
2269
1996
  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}`);
1997
+ console.log(` ${c.bold}Examples:${c.reset}`);
1998
+ console.log(` agentaudit`);
1999
+ console.log(` agentaudit discover --quick`);
2000
+ console.log(` agentaudit scan https://github.com/owner/repo`);
2001
+ console.log(` agentaudit audit https://github.com/owner/repo`);
2002
+ console.log(` agentaudit lookup fastmcp --json`);
2278
2003
  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`);
2004
+ console.log(` ${c.bold}For deep audits,${c.reset} set an LLM API key (e.g. ${c.cyan}export OPENROUTER_API_KEY=sk-or-...${c.reset})`);
2005
+ console.log(` ${c.dim}Run "agentaudit model" to configure provider + model interactively${c.reset}`);
2284
2006
  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}`);
2007
+ console.log(` ${c.bold}Or use as MCP server${c.reset} in Cursor/Claude ${c.dim}(no extra API key needed):${c.reset}`);
2008
+ console.log(` ${c.dim}{ "agentaudit": { "command": "npx", "args": ["-y", "agentaudit"] } }${c.reset}`);
2291
2009
  console.log();
2292
2010
  process.exitCode = 0; return;
2293
2011
  }
@@ -2296,312 +2014,224 @@ async function main() {
2296
2014
  const command = args.length === 0 ? 'discover' : args[0];
2297
2015
  const targets = args.slice(1);
2298
2016
 
2299
- await banner();
2017
+ banner();
2300
2018
 
2301
2019
  if (command === 'setup') {
2302
2020
  await setupCommand();
2303
2021
  return;
2304
2022
  }
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
2023
 
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`);
2024
+ if (command === 'model') {
2025
+ const newModel = targets.filter(t => !t.startsWith('--'))[0];
2026
+ const config = loadLlmConfig();
2027
+ const current = config?.llm_model;
2028
+ const currentProvider = config?.preferred_provider;
2029
+
2030
+ // Direct set: agentaudit model reset
2031
+ if (newModel === 'reset' || newModel === 'clear') {
2032
+ for (const f of [USER_CRED_FILE, SKILL_CRED_FILE]) {
2033
+ if (fs.existsSync(f)) {
2034
+ try {
2035
+ const data = JSON.parse(fs.readFileSync(f, 'utf8'));
2036
+ delete data.llm_model;
2037
+ delete data.preferred_provider;
2038
+ fs.writeFileSync(f, JSON.stringify(data, null, 2), { mode: 0o600 });
2039
+ } catch {}
2363
2040
  }
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
2041
  }
2042
+ console.log(` ${icons.safe} Model + provider reset to defaults`);
2043
+ return;
2367
2044
  }
2368
2045
 
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}`);
2046
+ // Direct set: agentaudit model <name> (sets model only, keeps provider)
2047
+ if (newModel) {
2048
+ saveLlmConfig(newModel);
2049
+ console.log(` ${icons.safe} Model set to ${c.bold}${newModel}${c.reset}`);
2050
+ if (current) console.log(` ${c.dim}Was: ${current}${c.reset}`);
2051
+ return;
2381
2052
  }
2382
2053
 
2383
- // AgentAudit registry key
2054
+ // ── No argument — interactive two-step menu ──
2055
+
2056
+ // Build deduplicated provider list
2057
+ const seen = new Set();
2058
+ const providerList = [];
2059
+ for (const p of LLM_PROVIDERS) {
2060
+ if (seen.has(p.provider)) continue;
2061
+ seen.add(p.provider);
2062
+ // Check if ANY key for this provider is set
2063
+ const keys = LLM_PROVIDERS.filter(x => x.provider === p.provider);
2064
+ const hasKey = keys.some(x => process.env[x.key]);
2065
+ const keyName = p.key;
2066
+ providerList.push({
2067
+ label: p.name,
2068
+ sublabel: hasKey
2069
+ ? `${keyName} ${c.green}✔${c.reset}`
2070
+ : `${keyName} ${c.red}✗${c.reset} ${c.dim}set: export ${keyName}=...${c.reset}`,
2071
+ value: p.provider,
2072
+ hasKey,
2073
+ keyName,
2074
+ });
2075
+ }
2076
+
2077
+ // Show status
2078
+ const resolved = resolveProvider();
2079
+ console.log(` ${c.bold}LLM Configuration${c.reset}`);
2384
2080
  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 {}
2081
+ if (currentProvider && current) {
2082
+ const provInfo = LLM_PROVIDERS.find(p => p.provider === currentProvider);
2083
+ console.log(` Active: ${c.bold}${c.cyan}${provInfo?.name || currentProvider} → ${current}${c.reset}`);
2084
+ } else if (current) {
2085
+ console.log(` Active: ${c.bold}${c.cyan}${resolved?.name || 'auto'}${current}${c.reset}`);
2086
+ } else if (resolved) {
2087
+ console.log(` Active: ${c.dim}${resolved.name} → ${resolved.model} (defaults)${c.reset}`);
2412
2088
  } else {
2413
- console.log(` ${c.dim}○${c.reset} AgentAudit ${c.dim}not set${c.reset} ${c.dim}(run: agentaudit setup)${c.reset}`);
2089
+ console.log(` Active: ${c.yellow}no provider configured${c.reset}`);
2414
2090
  }
2415
2091
  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}`);
2092
+
2093
+ // Step A: Provider selection
2094
+ const providerChoices = [
2095
+ ...providerList,
2096
+ { label: '(keep current)', sublabel: '', value: '__keep__', hasKey: true },
2097
+ ];
2098
+
2099
+ const selectedProvider = await singleSelect(providerChoices, {
2100
+ title: 'Choose provider',
2101
+ hint: '↑↓ move Enter select Esc cancel',
2102
+ });
2103
+
2104
+ if (selectedProvider === null) {
2105
+ console.log(` ${c.dim}Cancelled${c.reset}`);
2106
+ return;
2107
+ }
2108
+
2109
+ if (selectedProvider === '__keep__') {
2110
+ console.log(` ${c.dim}Keeping current config${c.reset}`);
2111
+ return;
2112
+ }
2113
+
2114
+ // Check if provider has key set
2115
+ const chosenEntry = providerList.find(p => p.value === selectedProvider);
2116
+ if (chosenEntry && !chosenEntry.hasKey) {
2117
+ console.log();
2118
+ console.log(` ${c.yellow}${chosenEntry.keyName} is not set.${c.reset}`);
2119
+ console.log(` ${c.dim}Run: export ${chosenEntry.keyName}=your-key-here${c.reset}`);
2120
+ console.log(` ${c.dim}Then run "agentaudit model" again.${c.reset}`);
2121
+ return;
2122
+ }
2123
+
2124
+ // Step B: Model selection for chosen provider
2449
2125
  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}`);
2126
+ const providerModels = PROVIDER_MODELS[selectedProvider] || [];
2127
+ const modelChoices = [
2128
+ ...providerModels,
2129
+ { label: '(enter custom model name)', sublabel: '', value: '__custom__' },
2130
+ { label: '(reset to provider default)', sublabel: '', value: '__reset__' },
2131
+ ];
2132
+
2133
+ const providerName = LLM_PROVIDERS.find(p => p.provider === selectedProvider)?.name || selectedProvider;
2134
+ const selectedModel = await singleSelect(modelChoices, {
2135
+ title: `Choose model for ${providerName}`,
2136
+ hint: '↑↓ move Enter select Esc cancel',
2137
+ });
2138
+
2139
+ if (selectedModel === null) {
2140
+ console.log(` ${c.dim}Cancelled${c.reset}`);
2141
+ return;
2142
+ }
2143
+
2144
+ if (selectedModel === '__reset__') {
2145
+ // Save provider, clear model (use provider default)
2146
+ saveLlmConfig(undefined, selectedProvider);
2147
+ // Also clear llm_model from both files
2148
+ for (const f of [USER_CRED_FILE, SKILL_CRED_FILE]) {
2149
+ if (fs.existsSync(f)) {
2150
+ try {
2151
+ const data = JSON.parse(fs.readFileSync(f, 'utf8'));
2152
+ delete data.llm_model;
2153
+ data.preferred_provider = selectedProvider;
2154
+ fs.writeFileSync(f, JSON.stringify(data, null, 2), { mode: 0o600 });
2155
+ } catch {}
2496
2156
  }
2497
- } catch (e) {
2498
- process.stdout.write(`\r ${c.red}Failed to fetch: ${e.message}${c.reset} \n`);
2499
2157
  }
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}`);
2158
+ const defaultModel = LLM_PROVIDERS.find(p => p.provider === selectedProvider)?.model;
2159
+ console.log();
2160
+ console.log(` ${icons.safe} Provider: ${c.bold}${providerName}${c.reset}, model: ${c.dim}${defaultModel} (default)${c.reset}`);
2161
+ return;
2504
2162
  }
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}`);
2163
+
2164
+ if (selectedModel === '__custom__') {
2165
+ console.log();
2166
+ const custom = await askQuestion(` Model name: `);
2167
+ if (!custom) {
2168
+ console.log(` ${c.dim}Cancelled${c.reset}`);
2169
+ return;
2520
2170
  }
2521
- } else {
2522
- console.log(` ${c.dim}Set OLLAMA_MODEL to use local models${c.reset}`);
2171
+ saveLlmConfig(custom, selectedProvider);
2172
+ console.log(` ${icons.safe} Provider: ${c.bold}${providerName}${c.reset}, model: ${c.bold}${custom}${c.reset}`);
2173
+ if (current) console.log(` ${c.dim}Was: ${currentProvider || 'auto'} → ${current}${c.reset}`);
2174
+ return;
2523
2175
  }
2176
+
2177
+ // Normal model selection
2178
+ saveLlmConfig(selectedModel, selectedProvider);
2524
2179
  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}`);
2180
+ console.log(` ${icons.safe} Provider: ${c.bold}${providerName}${c.reset}, model: ${c.bold}${selectedModel}${c.reset}`);
2181
+ if (current) console.log(` ${c.dim}Was: ${currentProvider || 'auto'} → ${current}${c.reset}`);
2182
+ console.log();
2183
+ console.log(` ${c.dim}Tip: agentaudit model <name> to set model directly, or agentaudit model reset${c.reset}`);
2530
2184
  return;
2531
2185
  }
2532
2186
 
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
- }
2187
+ if (command === 'discover') {
2188
+ const scanFlag = targets.includes('--quick') || targets.includes('--scan') || targets.includes('-s');
2189
+ const auditFlag = targets.includes('--deep') || targets.includes('--audit') || targets.includes('-a');
2190
+ await discoverCommand({ scan: scanFlag, audit: auditFlag });
2572
2191
  return;
2573
2192
  }
2574
2193
 
2575
2194
  if (command === 'lookup' || command === 'check') {
2576
2195
  const names = targets.filter(t => !t.startsWith('--'));
2577
2196
  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}`);
2197
+ console.log(` ${c.red}Error: package name required${c.reset}`);
2580
2198
  process.exitCode = 2;
2581
2199
  return;
2582
2200
  }
2583
2201
  const results = [];
2584
- const allowAutoAudit = command === 'check'; // only 'check' auto-audits, 'lookup' never does
2585
2202
  for (const t of names) {
2586
- const data = await checkPackage(t, { autoAudit: allowAutoAudit });
2203
+ const data = await checkPackage(t);
2587
2204
  results.push(data);
2588
2205
  }
2589
2206
  if (jsonMode) {
2590
2207
  console.log(JSON.stringify(results.length === 1 ? (results[0] || { error: 'not_found' }) : results, null, 2));
2591
2208
  }
2592
2209
  process.exitCode = 0; return;
2210
+ return;
2593
2211
  }
2594
2212
 
2595
2213
  if (command === 'scan') {
2214
+ const deepFlag = targets.includes('--deep');
2596
2215
  const urls = targets.filter(t => !t.startsWith('--'));
2597
2216
  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}`);
2217
+ console.log(` ${c.red}Error: at least one repository URL required${c.reset}`);
2218
+ console.log(` ${c.dim}Tip: use ${c.cyan}agentaudit discover${c.dim} to find & check locally installed MCP servers${c.reset}`);
2219
+ console.log(` ${c.dim}Tip: use ${c.cyan}agentaudit audit <url>${c.dim} for a deep LLM-powered audit${c.reset}`);
2601
2220
  process.exitCode = 2;
2602
2221
  return;
2603
2222
  }
2604
2223
 
2224
+ // --deep redirects to audit flow
2225
+ if (deepFlag) {
2226
+ let hasFindings = false;
2227
+ for (const url of urls) {
2228
+ const report = await auditRepo(url);
2229
+ if (report?.findings?.length > 0) hasFindings = true;
2230
+ }
2231
+ process.exitCode = hasFindings ? 1 : 0;
2232
+ return;
2233
+ }
2234
+
2605
2235
  const results = [];
2606
2236
  let hadErrors = false;
2607
2237
  for (const url of urls) {
@@ -2630,40 +2260,30 @@ async function main() {
2630
2260
  }
2631
2261
 
2632
2262
  if (hadErrors && results.length === 0) { process.exitCode = 2; return; }
2633
- process.exitCode = 0;
2263
+ const totalFindings = results.reduce((sum, r) => sum + r.findings.length, 0);
2264
+ process.exitCode = totalFindings > 0 ? 1 : 0;
2634
2265
  return;
2635
2266
  }
2636
2267
 
2637
2268
  if (command === 'audit') {
2638
2269
  const urls = targets.filter(t => !t.startsWith('--'));
2639
2270
  if (urls.length === 0) {
2640
- console.log(` ${c.red}✖ Repository URL required${c.reset}`);
2641
- console.log(` ${c.dim}Usage: agentaudit audit <url>${c.reset}`);
2271
+ console.log(` ${c.red}Error: at least one repository URL required${c.reset}`);
2642
2272
  process.exitCode = 2;
2643
2273
  return;
2644
2274
  }
2645
2275
 
2646
- let hasErrors = false;
2276
+ let hasFindings = false;
2647
2277
  for (const url of urls) {
2648
2278
  const report = await auditRepo(url);
2649
- if (!report) hasErrors = true;
2279
+ if (report?.findings?.length > 0) hasFindings = true;
2650
2280
  }
2651
- process.exitCode = hasErrors ? 2 : 0;
2281
+ process.exitCode = hasFindings ? 1 : 0;
2652
2282
  return;
2653
2283
  }
2654
2284
 
2655
- // Typo correction via Levenshtein distance
2656
- const knownCommands = ['discover', 'scan', 'audit', 'check', 'lookup', 'status', 'setup', 'config', 'models'];
2657
- const suggestion = knownCommands
2658
- .map(cmd => ({ cmd, dist: levenshtein(command, cmd) }))
2659
- .filter(x => x.dist <= 3)
2660
- .sort((a, b) => a.dist - b.dist)[0];
2661
-
2662
- console.log(` ${c.red}✖ Unknown command: ${command}${c.reset}`);
2663
- if (suggestion) {
2664
- console.log(` ${c.dim}Did you mean: ${c.cyan}agentaudit ${suggestion.cmd}${c.reset}${c.dim}?${c.reset}`);
2665
- }
2666
- console.log(` ${c.dim}Run ${c.cyan}agentaudit --help${c.dim} for usage${c.reset}`);
2285
+ console.log(` ${c.red}Unknown command: ${command}${c.reset}`);
2286
+ console.log(` ${c.dim}Run agentaudit --help for usage${c.reset}`);
2667
2287
  process.exitCode = 2;
2668
2288
  }
2669
2289