agentaudit 3.10.10 → 3.12.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.
package/cli.mjs CHANGED
@@ -5,7 +5,7 @@
5
5
  * Usage: agentaudit <command> [options]
6
6
  *
7
7
  * Commands:
8
- * discover Find MCP servers in AI editors
8
+ * discover Find MCP servers across all AI tools
9
9
  * scan <url> [url...] Quick static scan (regex)
10
10
  * audit <url> [url...] Deep LLM-powered security audit
11
11
  * lookup <name> Look up package in registry
@@ -17,6 +17,7 @@
17
17
  * model [name|reset] Configure LLM provider + model
18
18
  * setup Log in to agentaudit.dev (for report uploads)
19
19
  * status Show current config + auth status
20
+ * profile Your profile — rank, points, audit stats
20
21
  * help [command] Show help
21
22
  *
22
23
  * Flags: --json, --quiet, --no-color, --no-upload, --model, --export, --debug
@@ -554,7 +555,7 @@ function banner() {
554
555
  const ptsStr = `${fmtNum(cache.total_points)}pts`;
555
556
  const auditsStr = `${fmtNum(cache.total_reports)} audits`;
556
557
  const profile = [cache.agent_name, rankStr, ptsStr, auditsStr].filter(Boolean).join(' \u00b7 ');
557
- console.log(` ${c.bold}${c.cyan}\u26e8 AgentAudit${c.reset} ${c.dim}v${getVersion()}${c.reset} ${c.dim}\u2502${c.reset} ${profile}`);
558
+ console.log(` ${c.bold}${c.cyan}\u25c6 AgentAudit${c.reset} ${c.dim}v${getVersion()}${c.reset} ${c.dim}\u2502${c.reset} ${profile}`);
558
559
  } else {
559
560
  console.log(` ${c.bold}${c.cyan}AgentAudit${c.reset} ${c.dim}v${getVersion()}${c.reset}`);
560
561
  console.log(` ${c.dim}Security scanner for AI packages${c.reset}`);
@@ -575,10 +576,14 @@ function elapsed(startMs) {
575
576
  }
576
577
 
577
578
  function riskBadge(score) {
578
- if (score === 0) return `${c.bgGreen}${c.bold}${c.white} SAFE ${c.reset}`;
579
- if (score <= 10) return `${c.bgGreen}${c.white} LOW ${c.reset}`;
580
- if (score <= 30) return `${c.bgYellow}${c.bold} CAUTION ${c.reset}`;
581
- return `${c.bgRed}${c.bold}${c.white} UNSAFE ${c.reset}`;
579
+ const badge = score === 0 ? `${c.bgGreen}${c.bold}${c.white} SAFE ${c.reset}`
580
+ : score <= 10 ? `${c.bgGreen}${c.white} LOW ${c.reset}`
581
+ : score <= 30 ? `${c.bgYellow}${c.bold} CAUTION ${c.reset}`
582
+ : `${c.bgRed}${c.bold}${c.white} UNSAFE ${c.reset}`;
583
+ const filled = Math.min(Math.round(score / 20), 5);
584
+ const gaugeColor = score <= 10 ? c.green : score <= 30 ? c.yellow : c.red;
585
+ const gauge = `${gaugeColor}${'▰'.repeat(filled)}${c.dim}${'▱'.repeat(5 - filled)}${c.reset}`;
586
+ return `${badge} ${gauge} ${score}/100`;
582
587
  }
583
588
 
584
589
  function severityColor(sev) {
@@ -638,15 +643,17 @@ function padLeft(str, len) {
638
643
 
639
644
  function drawBox(title, contentLines, width) {
640
645
  const inner = width - 4; // 2 for "│ " + 2 for " │"
646
+ const totalDash = inner + 2; // total horizontal line chars between corners
641
647
  const lines = [];
642
648
  const titleStr = title ? ` ${title} ` : '';
643
649
  const titleLen = visLen(titleStr);
644
- const topDash = BOX.h.repeat(Math.max(1, inner + 2 - titleLen));
645
- lines.push(` ${BOX.tl}${c.dim}─${c.reset}${c.bold}${titleStr}${c.reset}${c.dim}${topDash}${c.reset}${BOX.tr}`);
650
+ // Top: ╭─ Title ────────────╮ (1 dash before title + title + remaining dashes)
651
+ const topDash = BOX.h.repeat(Math.max(1, totalDash - 1 - titleLen));
652
+ lines.push(` ${BOX.tl}${c.dim}${BOX.h}${c.reset}${c.bold}${titleStr}${c.reset}${c.dim}${topDash}${c.reset}${BOX.tr}`);
646
653
  for (const line of contentLines) {
647
654
  lines.push(` ${BOX.v} ${padRight(line, inner + 1)}${BOX.v}`);
648
655
  }
649
- lines.push(` ${BOX.bl}${c.dim}${BOX.h.repeat(inner + 2)}${c.reset}${BOX.br}`);
656
+ lines.push(` ${BOX.bl}${c.dim}${BOX.h.repeat(totalDash)}${c.reset}${BOX.br}`);
650
657
  return lines;
651
658
  }
652
659
 
@@ -695,6 +702,47 @@ function sparkline(values) {
695
702
  }).join('');
696
703
  }
697
704
 
705
+ // ─── Section Header ─────────── labeled divider
706
+ function sectionHeader(title, width = 60) {
707
+ const dashAfter = Math.max(3, width - 5 - title.length);
708
+ return ` ${c.dim}───${c.reset} ${c.bold}${title}${c.reset} ${c.dim}${'─'.repeat(dashAfter)}${c.reset}`;
709
+ }
710
+
711
+ // █████░░░░░░░░░░░░░░ coverage bar
712
+ function coverageBar(filled, total, width = 20) {
713
+ if (total === 0) return `${c.dim}${'░'.repeat(width)}${c.reset} 0/0`;
714
+ const barFilled = Math.max(filled > 0 ? 1 : 0, Math.round((filled / total) * width));
715
+ const barEmpty = width - barFilled;
716
+ const pct = Math.round((filled / total) * 100);
717
+ const color = pct >= 80 ? c.green : pct >= 50 ? c.yellow : c.red;
718
+ return `${color}${'█'.repeat(barFilled)}${c.dim}${'░'.repeat(barEmpty)}${c.reset} ${filled}/${total} (${pct}%)`;
719
+ }
720
+
721
+ // Severity histogram for findings
722
+ function severityHistogram(findings) {
723
+ const counts = { critical: 0, high: 0, medium: 0, low: 0 };
724
+ for (const f of findings) {
725
+ const sev = (f.severity || '').toLowerCase();
726
+ if (counts[sev] !== undefined) counts[sev]++;
727
+ }
728
+ const max = Math.max(...Object.values(counts), 1);
729
+ const lines = [];
730
+ for (const sev of ['critical', 'high', 'medium', 'low']) {
731
+ const count = counts[sev];
732
+ if (count === 0) continue;
733
+ const barLen = Math.max(1, Math.round((count / max) * 24));
734
+ const sc = severityColor(sev);
735
+ const label = sev.toUpperCase().padEnd(10);
736
+ lines.push(` ${sc}${label}${c.reset} ${sc}${'█'.repeat(barLen)}${c.reset}${' '.repeat(24 - barLen)} ${count}`);
737
+ }
738
+ return lines;
739
+ }
740
+
741
+ // ▰▰▰▱ step progress indicator
742
+ function stepProgress(current, total) {
743
+ return `${c.cyan}${'▰'.repeat(current)}${c.dim}${'▱'.repeat(total - current)}${c.reset}`;
744
+ }
745
+
698
746
  function fmtNum(n) {
699
747
  if (n == null) return '0';
700
748
  return n.toLocaleString('en-US');
@@ -709,7 +757,7 @@ function dashboardBanner() {
709
757
  const ver = getVersion();
710
758
  return [
711
759
  ` ${BOX.tl}${c.dim}${BOX.h.repeat(35)}${c.reset}${BOX.tr}`,
712
- ` ${BOX.v} ${c.bold}${c.cyan} AgentAudit${c.reset} ${c.dim}v${ver}${c.reset}${' '.repeat(Math.max(0, 19 - ver.length))}${BOX.v}`,
760
+ ` ${BOX.v} ${c.bold}${c.cyan} AgentAudit${c.reset} ${c.dim}v${ver}${c.reset}${' '.repeat(Math.max(0, 19 - ver.length))}${BOX.v}`,
713
761
  ` ${BOX.v} ${c.dim}Security Registry for AI Agents${c.reset} ${BOX.v}`,
714
762
  ` ${BOX.bl}${c.dim}${BOX.h.repeat(35)}${c.reset}${BOX.br}`,
715
763
  ];
@@ -883,24 +931,38 @@ function detectPackageInfo(repoPath, files) {
883
931
  info.type = 'library';
884
932
  }
885
933
 
886
- // Extract MCP tools (look for tool definitions)
934
+ // Extract MCP tools only from files that reference MCP SDK
935
+ const mcpKeywords = ['modelcontextprotocol', 'FastMCP', 'mcp.server', 'mcp_server', '@mcp.tool', '@server.tool', '.tool(', 'ListTools', 'CallTool'];
936
+ const mcpFiles = files.filter(f => mcpKeywords.some(kw => f.content.includes(kw)));
937
+ // Fallback: if no MCP-specific files found, try entrypoint files
938
+ if (mcpFiles.length === 0) {
939
+ const entryNames = ['index.js', 'index.ts', 'index.mjs', 'main.py', 'server.py', 'app.py', 'src/index.ts', 'src/main.ts', 'src/index.js'];
940
+ for (const f of files) {
941
+ if (entryNames.includes(f.path)) mcpFiles.push(f);
942
+ }
943
+ }
944
+
887
945
  const toolPatterns = [
888
- // JS/TS: name: 'tool_name' or "tool_name" in tool definitions
889
- /(?:name|tool_name)['":\s]+['"]([a-z_][a-z0-9_]*)['"]/gi,
890
- // Python: @mcp.tool() def func_name or Tool(name="...")
891
- /(?:@(?:mcp|server)\.tool\(\)[\s\S]*?def\s+([a-z_][a-z0-9_]*))|(?:Tool\s*\(\s*name\s*=\s*['"]([a-z_][a-z0-9_]*)['"])/gi,
892
- // Direct: tool names in ListTools handlers
893
- /['"]name['"]\s*:\s*['"]([a-z_][a-z0-9_]*)['"]/gi,
946
+ // JS/TS MCP SDK: server.tool('name', ...) or .setTool('name', ...)
947
+ /\.tool\s*\(\s*['"]([a-z_][a-z0-9_]*)['"]/gi,
948
+ // Python: @mcp.tool() / @server.tool() followed by def name
949
+ /@(?:mcp|server)\.tool\s*\(.*?\)[\s\S]*?def\s+([a-z_][a-z0-9_]*)/gi,
950
+ // Python: Tool(name="xxx")
951
+ /Tool\s*\(\s*name\s*=\s*['"]([a-z_][a-z0-9_]*)['"]/gi,
952
+ // ListTools handler: { name: "tool_name", description: ... }
953
+ /{\s*(?:['"]?)name(?:['"]?)\s*:\s*['"]([a-z_][a-z0-9_]*)['"]\s*,\s*(?:['"]?)description(?:['"]?)\s*:/gi,
894
954
  ];
895
-
955
+
956
+ const toolBlacklist = new Set(['type', 'name', 'string', 'object', 'number', 'boolean', 'array', 'required', 'description', 'default', 'null', 'true', 'false', 'none', 'test', 'self', 'args', 'kwargs', 'input', 'output', 'result', 'data', 'error', 'value', 'index', 'item', 'list', 'dict', 'set', 'map', 'key', 'url', 'env', 'config', 'options']);
957
+
896
958
  const toolSet = new Set();
897
- for (const file of files) {
959
+ for (const file of mcpFiles) {
898
960
  for (const pattern of toolPatterns) {
899
961
  pattern.lastIndex = 0;
900
962
  let m;
901
963
  while ((m = pattern.exec(file.content)) !== null) {
902
964
  const name = m[1] || m[2];
903
- if (name && name.length > 2 && name.length < 50 && !['type', 'name', 'string', 'object', 'number', 'boolean', 'array', 'required', 'description', 'default', 'null', 'true', 'false', 'none'].includes(name)) {
965
+ if (name && name.length > 2 && name.length < 50 && !toolBlacklist.has(name)) {
904
966
  toolSet.add(name);
905
967
  }
906
968
  }
@@ -1120,31 +1182,38 @@ function printScanResult(url, info, files, findings, registryData, duration) {
1120
1182
  console.log(`${icons.pipe} ${c.dim}(no tools or prompts detected)${c.reset}`);
1121
1183
  }
1122
1184
 
1123
- // Findings
1185
+ // Findings with severity stripe
1124
1186
  if (findings.length > 0) {
1125
- console.log(`${icons.pipe}`);
1126
- console.log(`${icons.pipe} ${c.bold}Findings (${findings.length})${c.reset} ${c.dim}static analysis — may include false positives${c.reset}`);
1127
- for (let i = 0; i < findings.length; i++) {
1128
- const f = findings[i];
1129
- const isLast = i === findings.length - 1;
1130
- const branch = isLast ? icons.treeLast : icons.tree;
1131
- const pipeOrSpace = isLast ? ' ' : `${icons.pipe} `;
1187
+ console.log();
1188
+ console.log(sectionHeader(`Findings (${findings.length})`));
1189
+ console.log(` ${c.dim}static analysis may include false positives${c.reset}`);
1190
+ console.log();
1191
+ for (const f of findings) {
1132
1192
  const sc = severityColor(f.severity);
1133
- console.log(`${branch} ${severityIcon(f.severity)} ${sc}${f.severity.toUpperCase().padEnd(8)}${c.reset} ${f.title}`);
1134
- console.log(`${pipeOrSpace} ${c.dim}${f.file}:${f.line}${c.reset} ${c.dim}${f.snippet || ''}${c.reset}`);
1193
+ console.log(` ${sc}┃${c.reset} ${sc}${f.severity.toUpperCase().padEnd(8)}${c.reset} ${c.bold}${f.title}${c.reset}`);
1194
+ console.log(` ${sc}┃${c.reset} ${c.dim}${f.file}:${f.line}${c.reset} ${c.dim}${f.snippet || ''}${c.reset}`);
1195
+ }
1196
+
1197
+ // Severity histogram
1198
+ const histLines = severityHistogram(findings);
1199
+ if (histLines.length > 1) {
1200
+ console.log();
1201
+ console.log(sectionHeader('Severity'));
1202
+ for (const line of histLines) console.log(line);
1135
1203
  }
1136
1204
  }
1137
-
1205
+
1138
1206
  // Registry status
1139
- console.log(`${icons.pipe}`);
1207
+ console.log();
1208
+ console.log(sectionHeader('Registry'));
1140
1209
  if (registryData) {
1141
1210
  const rd = registryData;
1142
1211
  const riskScore = rd.risk_score ?? rd.latest_risk_score ?? 0;
1143
- console.log(`${icons.treeLast} ${c.dim}registry${c.reset} ${riskBadge(riskScore)} Risk ${riskScore} ${c.dim}${REGISTRY_URL}/packages/${slug}${c.reset}`);
1212
+ console.log(` ${riskBadge(riskScore)} ${c.dim}${REGISTRY_URL}/packages/${slug}${c.reset}`);
1144
1213
  } else {
1145
- console.log(`${icons.treeLast} ${c.dim}registry${c.reset} ${c.dim}not audited yet${c.reset}`);
1214
+ console.log(` ${c.dim}not audited yet${c.reset}`);
1146
1215
  }
1147
-
1216
+
1148
1217
  console.log();
1149
1218
  }
1150
1219
 
@@ -1153,27 +1222,23 @@ function printSummary(results) {
1153
1222
  const safe = results.filter(r => r.findings.length === 0).length;
1154
1223
  const withFindings = total - safe;
1155
1224
  const totalFindings = results.reduce((sum, r) => sum + r.findings.length, 0);
1156
-
1157
- console.log(`${c.dim}${'─'.repeat(60)}${c.reset}`);
1158
- console.log(` ${c.bold}Summary${c.reset} ${total} packages scanned`);
1225
+ const allFindings = results.flatMap(r => r.findings);
1226
+
1227
+ console.log(sectionHeader(`Summary${total} packages scanned`));
1159
1228
  console.log();
1160
1229
  if (safe > 0) console.log(` ${icons.safe} ${c.green}${safe} clean${c.reset}`);
1161
1230
  if (withFindings > 0) console.log(` ${icons.caution} ${c.yellow}${withFindings} with findings${c.reset} (${totalFindings} total)`);
1162
-
1163
- // Breakdown by severity
1164
- const bySev = {};
1165
- results.forEach(r => r.findings.forEach(f => {
1166
- bySev[f.severity] = (bySev[f.severity] || 0) + 1;
1167
- }));
1168
- if (Object.keys(bySev).length > 0) {
1231
+ console.log();
1232
+ console.log(` ${coverageBar(safe, total)}`);
1233
+
1234
+ // Severity histogram
1235
+ const histLines = severityHistogram(allFindings);
1236
+ if (histLines.length > 0) {
1169
1237
  console.log();
1170
- for (const sev of ['critical', 'high', 'medium', 'low']) {
1171
- if (bySev[sev]) {
1172
- console.log(` ${severityIcon(sev)} ${bySev[sev]}× ${severityColor(sev)}${sev}${c.reset}`);
1173
- }
1174
- }
1238
+ console.log(sectionHeader('Severity'));
1239
+ for (const line of histLines) console.log(line);
1175
1240
  }
1176
-
1241
+
1177
1242
  console.log();
1178
1243
  }
1179
1244
 
@@ -1230,51 +1295,522 @@ async function scanRepo(url) {
1230
1295
 
1231
1296
  // ── Discover local MCP configs ──────────────────────────
1232
1297
 
1298
+ /**
1299
+ * Minimal YAML parser — extracts MCP server list entries from
1300
+ * Continue.dev (mcpServers: list) and Goose (extensions: list).
1301
+ * Zero dependencies. Only handles the subset of YAML used by these tools.
1302
+ */
1303
+ function parseSimpleYaml(text, rootKey) {
1304
+ const result = { mcpServers: {} };
1305
+ const lines = text.split('\n');
1306
+ let inSection = false;
1307
+ let currentName = null;
1308
+ let currentServer = {};
1309
+ let collectingArgs = false;
1310
+ let argsIndent = -1;
1311
+
1312
+ for (const line of lines) {
1313
+ const trimmed = line.trimEnd();
1314
+ if (trimmed === '' || /^\s*#/.test(trimmed)) continue;
1315
+ const indent = line.search(/\S/);
1316
+
1317
+ if (indent === 0 && trimmed === rootKey + ':') { inSection = true; continue; }
1318
+ if (indent === 0 && inSection && /^\w/.test(trimmed)) {
1319
+ if (currentName) result.mcpServers[currentName] = currentServer;
1320
+ break;
1321
+ }
1322
+ if (!inSection) continue;
1323
+
1324
+ const nameMatch = trimmed.match(/^\s*-\s+name:\s*(.+)/);
1325
+ if (nameMatch) {
1326
+ if (currentName) result.mcpServers[currentName] = currentServer;
1327
+ currentName = nameMatch[1].replace(/^["']|["']$/g, '');
1328
+ currentServer = {};
1329
+ collectingArgs = false;
1330
+ continue;
1331
+ }
1332
+
1333
+ if (collectingArgs && indent > argsIndent) {
1334
+ const argVal = trimmed.match(/^\s*-\s+(.+)/);
1335
+ if (argVal) {
1336
+ if (!currentServer.args) currentServer.args = [];
1337
+ currentServer.args.push(argVal[1].replace(/^["']|["']$/g, ''));
1338
+ continue;
1339
+ }
1340
+ }
1341
+ if (collectingArgs && indent <= argsIndent) collectingArgs = false;
1342
+ if (!currentName) continue;
1343
+
1344
+ const kvMatch = trimmed.match(/^\s+(command|cmd|type|url):\s*(.+)/);
1345
+ if (kvMatch) {
1346
+ collectingArgs = false;
1347
+ const key = kvMatch[1] === 'cmd' ? 'command' : kvMatch[1];
1348
+ currentServer[key] = kvMatch[2].replace(/^["']|["']$/g, '');
1349
+ continue;
1350
+ }
1351
+
1352
+ const argsMatch = trimmed.match(/^\s+(args):\s*(.*)/);
1353
+ if (argsMatch) {
1354
+ const inlineArr = argsMatch[2].match(/^\[(.+)\]$/);
1355
+ if (inlineArr) {
1356
+ currentServer.args = inlineArr[1].split(',').map(s => s.trim().replace(/^["']|["']$/g, ''));
1357
+ collectingArgs = false;
1358
+ } else {
1359
+ collectingArgs = true;
1360
+ argsIndent = indent;
1361
+ currentServer.args = [];
1362
+ }
1363
+ continue;
1364
+ }
1365
+ }
1366
+ if (currentName) result.mcpServers[currentName] = currentServer;
1367
+ return result;
1368
+ }
1369
+
1370
+ /**
1371
+ * Minimal TOML parser — extracts [mcp_servers.xxx] sections
1372
+ * from OpenAI Codex CLI config. Zero dependencies.
1373
+ */
1374
+ function parseSimpleToml(text) {
1375
+ const result = { mcpServers: {} };
1376
+ const lines = text.split('\n');
1377
+ let currentName = null;
1378
+ let currentServer = {};
1379
+
1380
+ for (const line of lines) {
1381
+ const trimmed = line.trim();
1382
+ if (trimmed === '' || trimmed.startsWith('#')) continue;
1383
+
1384
+ const sectionMatch = trimmed.match(/^\[mcp_servers\.(.+)\]$/);
1385
+ if (sectionMatch) {
1386
+ if (currentName) result.mcpServers[currentName] = currentServer;
1387
+ currentName = sectionMatch[1];
1388
+ currentServer = {};
1389
+ continue;
1390
+ }
1391
+ if (trimmed.startsWith('[') && !trimmed.startsWith('[mcp_servers.')) {
1392
+ if (currentName) result.mcpServers[currentName] = currentServer;
1393
+ currentName = null;
1394
+ continue;
1395
+ }
1396
+ if (!currentName) continue;
1397
+
1398
+ const strMatch = trimmed.match(/^(command|url)\s*=\s*"(.+?)"/);
1399
+ if (strMatch) { currentServer[strMatch[1]] = strMatch[2]; continue; }
1400
+
1401
+ const argsMatch = trimmed.match(/^args\s*=\s*\[(.+)\]/);
1402
+ if (argsMatch) {
1403
+ currentServer.args = argsMatch[1].split(',').map(s => s.trim().replace(/^["']|["']$/g, ''));
1404
+ continue;
1405
+ }
1406
+
1407
+ const boolMatch = trimmed.match(/^enabled\s*=\s*(true|false)/);
1408
+ if (boolMatch && boolMatch[1] === 'false') currentServer.disabled = true;
1409
+ }
1410
+ if (currentName) result.mcpServers[currentName] = currentServer;
1411
+ return result;
1412
+ }
1413
+
1414
+ /**
1415
+ * Comprehensive MCP config discovery across all major AI editors & tools.
1416
+ *
1417
+ * Supports: Claude Desktop, Claude Code, Cursor, Windsurf, VS Code,
1418
+ * Cline, Roo Code, Amazon Q, Gemini CLI, Zed, Continue.dev, Goose,
1419
+ * OpenAI Codex CLI, Visual Studio — global + project-level configs.
1420
+ */
1233
1421
  function findMcpConfigs() {
1234
1422
  const home = process.env.HOME || process.env.USERPROFILE || '';
1235
1423
  const platform = process.platform;
1236
-
1237
- // All known MCP config locations
1424
+ const cwd = process.cwd();
1425
+ const xdgConfig = process.env.XDG_CONFIG_HOME || path.join(home, '.config');
1426
+
1427
+ // Platform-specific app data directory
1428
+ // macOS: ~/Library/Application Support, Windows: ~/AppData/Roaming, Linux: ~/.config
1429
+ const appData = platform === 'darwin'
1430
+ ? path.join(home, 'Library', 'Application Support')
1431
+ : platform === 'win32'
1432
+ ? path.join(home, 'AppData', 'Roaming')
1433
+ : xdgConfig;
1434
+
1435
+ // Each candidate: { name, path, format: 'json'|'yaml'|'toml', key: top-level key }
1238
1436
  const candidates = [
1239
- // Claude Desktop
1240
- { name: 'Claude Desktop', path: path.join(home, '.claude', 'mcp.json') },
1241
- { name: 'Claude Desktop', path: path.join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json') },
1242
- { name: 'Claude Desktop', path: path.join(home, 'AppData', 'Roaming', 'Claude', 'claude_desktop_config.json') },
1243
- { name: 'Claude Desktop', path: path.join(home, '.config', 'claude', 'claude_desktop_config.json') },
1244
- // Cursor
1245
- { name: 'Cursor', path: path.join(home, '.cursor', 'mcp.json') },
1246
- // Windsurf / Codeium
1247
- { name: 'Windsurf', path: path.join(home, '.codeium', 'windsurf', 'mcp_config.json') },
1248
- // VS Code
1249
- { name: 'VS Code', path: path.join(home, '.vscode', 'mcp.json') },
1250
- // Continue.dev
1251
- { name: 'Continue', path: path.join(home, '.continue', 'config.json') },
1437
+ // ── Claude Desktop ──
1438
+ ...(platform === 'darwin' ? [{ name: 'Claude Desktop', path: path.join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'), format: 'json', key: 'mcpServers' }] : []),
1439
+ ...(platform === 'win32' ? [{ name: 'Claude Desktop', path: path.join(home, 'AppData', 'Roaming', 'Claude', 'claude_desktop_config.json'), format: 'json', key: 'mcpServers' }] : []),
1440
+ ...(platform === 'linux' ? [{ name: 'Claude Desktop', path: path.join(xdgConfig, 'Claude', 'claude_desktop_config.json'), format: 'json', key: 'mcpServers' }] : []),
1441
+
1442
+ // ── Claude Code ──
1443
+ { name: 'Claude Code', path: path.join(home, '.claude.json'), format: 'json', key: 'mcpServers' },
1444
+ { name: 'Claude Code', path: path.join(home, '.claude', 'mcp.json'), format: 'json', key: 'mcpServers' },
1445
+
1446
+ // ── Cursor (global) ──
1447
+ { name: 'Cursor', path: path.join(home, '.cursor', 'mcp.json'), format: 'json', key: 'mcpServers' },
1448
+
1449
+ // ── Windsurf / Codeium ──
1450
+ { name: 'Windsurf', path: path.join(home, '.codeium', 'windsurf', 'mcp_config.json'), format: 'json', key: 'mcpServers' },
1451
+
1452
+ // ── VS Code (global mcp.json — uses 'servers' key) ──
1453
+ ...(platform === 'darwin' ? [{ name: 'VS Code', path: path.join(home, 'Library', 'Application Support', 'Code', 'User', 'mcp.json'), format: 'json', key: 'servers' }] : []),
1454
+ ...(platform === 'win32' ? [{ name: 'VS Code', path: path.join(home, 'AppData', 'Roaming', 'Code', 'User', 'mcp.json'), format: 'json', key: 'servers' }] : []),
1455
+ ...(platform === 'linux' ? [{ name: 'VS Code', path: path.join(xdgConfig, 'Code', 'User', 'mcp.json'), format: 'json', key: 'servers' }] : []),
1456
+
1457
+ // ── VS Code settings.json (mcp.servers nested key) ──
1458
+ ...(platform === 'darwin' ? [{ name: 'VS Code (settings)', path: path.join(home, 'Library', 'Application Support', 'Code', 'User', 'settings.json'), format: 'json', key: 'mcp.servers' }] : []),
1459
+ ...(platform === 'win32' ? [{ name: 'VS Code (settings)', path: path.join(home, 'AppData', 'Roaming', 'Code', 'User', 'settings.json'), format: 'json', key: 'mcp.servers' }] : []),
1460
+ ...(platform === 'linux' ? [{ name: 'VS Code (settings)', path: path.join(xdgConfig, 'Code', 'User', 'settings.json'), format: 'json', key: 'mcp.servers' }] : []),
1461
+
1462
+ // ── Cline (VS Code extension) ──
1463
+ ...(platform === 'darwin' ? [{ name: 'Cline', path: path.join(home, 'Library', 'Application Support', 'Code', 'User', 'globalStorage', 'saoudrizwan.claude-dev', 'settings', 'cline_mcp_settings.json'), format: 'json', key: 'mcpServers' }] : []),
1464
+ ...(platform === 'win32' ? [{ name: 'Cline', path: path.join(home, 'AppData', 'Roaming', 'Code', 'User', 'globalStorage', 'saoudrizwan.claude-dev', 'settings', 'cline_mcp_settings.json'), format: 'json', key: 'mcpServers' }] : []),
1465
+ ...(platform === 'linux' ? [{ name: 'Cline', path: path.join(xdgConfig, 'Code', 'User', 'globalStorage', 'saoudrizwan.claude-dev', 'settings', 'cline_mcp_settings.json'), format: 'json', key: 'mcpServers' }] : []),
1466
+
1467
+ // ── Roo Code (VS Code extension) ──
1468
+ ...(platform === 'darwin' ? [{ name: 'Roo Code', path: path.join(home, 'Library', 'Application Support', 'Code', 'User', 'globalStorage', 'rooveterinaryinc.roo-cline', 'settings', 'mcp_settings.json'), format: 'json', key: 'mcpServers' }] : []),
1469
+ ...(platform === 'win32' ? [{ name: 'Roo Code', path: path.join(home, 'AppData', 'Roaming', 'Code', 'User', 'globalStorage', 'rooveterinaryinc.roo-cline', 'settings', 'mcp_settings.json'), format: 'json', key: 'mcpServers' }] : []),
1470
+ ...(platform === 'linux' ? [{ name: 'Roo Code', path: path.join(xdgConfig, 'Code', 'User', 'globalStorage', 'rooveterinaryinc.roo-cline', 'settings', 'mcp_settings.json'), format: 'json', key: 'mcpServers' }] : []),
1471
+
1472
+ // ── Amazon Q Developer ──
1473
+ { name: 'Amazon Q', path: path.join(home, '.aws', 'amazonq', 'mcp.json'), format: 'json', key: 'mcpServers' },
1474
+ { name: 'Amazon Q (IDE)', path: path.join(home, '.aws', 'amazonq', 'default.json'), format: 'json', key: 'mcpServers' },
1475
+
1476
+ // ── Gemini CLI ──
1477
+ { name: 'Gemini CLI', path: path.join(home, '.gemini', 'settings.json'), format: 'json', key: 'mcpServers' },
1478
+
1479
+ // ── Zed (macOS + Linux only, uses 'context_servers' key) ──
1480
+ ...(platform === 'darwin' ? [{ name: 'Zed', path: path.join(home, '.zed', 'settings.json'), format: 'json', key: 'context_servers' }] : []),
1481
+ ...(platform === 'linux' ? [{ name: 'Zed', path: path.join(xdgConfig, 'zed', 'settings.json'), format: 'json', key: 'context_servers' }] : []),
1482
+
1483
+ // ── Continue.dev ──
1484
+ { name: 'Continue', path: path.join(home, '.continue', 'config.json'), format: 'json', key: 'mcpServers' },
1485
+ { name: 'Continue', path: path.join(home, '.continue', 'config.yaml'), format: 'yaml', key: 'mcpServers' },
1486
+
1487
+ // ── Goose (Block/Square) ──
1488
+ { name: 'Goose', path: path.join(xdgConfig, 'goose', 'config.yaml'), format: 'yaml', key: 'extensions' },
1489
+
1490
+ // ── OpenAI Codex CLI ──
1491
+ { name: 'Codex CLI', path: path.join(home, '.codex', 'config.toml'), format: 'toml', key: 'mcp_servers' },
1492
+
1493
+ // ── Visual Studio (Windows only) ──
1494
+ ...(platform === 'win32' ? [{ name: 'Visual Studio', path: path.join(home, '.mcp.json'), format: 'json', key: 'mcpServers' }] : []),
1495
+
1496
+ // ── Project-level configs (cwd) ──
1497
+ { name: 'Claude Code (project)', path: path.join(cwd, '.mcp.json'), format: 'json', key: 'mcpServers' },
1498
+ { name: 'Cursor (project)', path: path.join(cwd, '.cursor', 'mcp.json'), format: 'json', key: 'mcpServers' },
1499
+ { name: 'VS Code (project)', path: path.join(cwd, '.vscode', 'mcp.json'), format: 'json', key: 'servers' },
1500
+ { name: 'Roo Code (project)', path: path.join(cwd, '.roo', 'mcp.json'), format: 'json', key: 'mcpServers' },
1501
+ { name: 'Amazon Q (project)', path: path.join(cwd, '.amazonq', 'mcp.json'), format: 'json', key: 'mcpServers' },
1502
+ { name: 'Gemini CLI (project)', path: path.join(cwd, '.gemini', 'settings.json'), format: 'json', key: 'mcpServers' },
1503
+ ...(platform !== 'win32' ? [{ name: 'Zed (project)', path: path.join(cwd, '.zed', 'settings.json'), format: 'json', key: 'context_servers' }] : []),
1504
+ { name: 'Codex CLI (project)', path: path.join(cwd, '.codex', 'config.toml'), format: 'toml', key: 'mcp_servers' },
1252
1505
  ];
1253
-
1254
- // Also check AGENTAUDIT_TEST_CONFIG env for testing
1506
+
1507
+ // Test config override
1255
1508
  if (process.env.AGENTAUDIT_TEST_CONFIG) {
1256
- candidates.push({ name: 'Test Config', path: process.env.AGENTAUDIT_TEST_CONFIG });
1509
+ candidates.push({ name: 'Test Config', path: process.env.AGENTAUDIT_TEST_CONFIG, format: 'json', key: 'mcpServers' });
1257
1510
  }
1258
-
1259
- // Also scan workspace .cursor/mcp.json, .vscode/mcp.json in cwd
1260
- const cwd = process.cwd();
1261
- candidates.push(
1262
- { name: 'Cursor (project)', path: path.join(cwd, '.cursor', 'mcp.json') },
1263
- { name: 'VS Code (project)', path: path.join(cwd, '.vscode', 'mcp.json') },
1264
- );
1265
-
1511
+
1512
+ // Continue.dev mcpServers drop-in directory (individual JSON files)
1513
+ const continueDropIn = path.join(home, '.continue', 'mcpServers');
1514
+ try {
1515
+ if (fs.existsSync(continueDropIn)) {
1516
+ for (const f of fs.readdirSync(continueDropIn)) {
1517
+ if (f.endsWith('.json')) {
1518
+ candidates.push({ name: 'Continue (drop-in)', path: path.join(continueDropIn, f), format: 'json', key: 'mcpServers' });
1519
+ }
1520
+ }
1521
+ }
1522
+ } catch {}
1523
+
1524
+ // Project-level Continue.dev drop-ins
1525
+ const cwdContinueDropIn = path.join(cwd, '.continue', 'mcpServers');
1526
+ try {
1527
+ if (fs.existsSync(cwdContinueDropIn)) {
1528
+ for (const f of fs.readdirSync(cwdContinueDropIn)) {
1529
+ if (f.endsWith('.json')) {
1530
+ candidates.push({ name: 'Continue (project drop-in)', path: path.join(cwdContinueDropIn, f), format: 'json', key: 'mcpServers' });
1531
+ }
1532
+ }
1533
+ }
1534
+ } catch {}
1535
+
1266
1536
  const found = [];
1537
+ const seenPaths = new Set();
1538
+
1267
1539
  for (const c of candidates) {
1268
- if (fs.existsSync(c.path)) {
1269
- try {
1270
- const content = JSON.parse(fs.readFileSync(c.path, 'utf8'));
1271
- found.push({ ...c, content });
1272
- } catch {}
1540
+ const resolved = path.resolve(c.path);
1541
+ if (seenPaths.has(resolved)) continue;
1542
+ if (!fs.existsSync(c.path)) continue;
1543
+ seenPaths.add(resolved);
1544
+
1545
+ try {
1546
+ const raw = fs.readFileSync(c.path, 'utf8');
1547
+ let content;
1548
+
1549
+ if (c.format === 'yaml') {
1550
+ content = parseSimpleYaml(raw, c.key);
1551
+ } else if (c.format === 'toml') {
1552
+ content = parseSimpleToml(raw);
1553
+ } else {
1554
+ content = JSON.parse(raw);
1555
+ // Normalize different JSON key structures to mcpServers
1556
+ if (c.key === 'mcp.servers' && content.mcp?.servers) {
1557
+ content = { mcpServers: content.mcp.servers };
1558
+ } else if (c.key === 'context_servers' && content.context_servers) {
1559
+ // Zed: normalize nested { command: { path, args } } → { command, args }
1560
+ const normalized = {};
1561
+ for (const [name, cfg] of Object.entries(content.context_servers)) {
1562
+ if (cfg.command && typeof cfg.command === 'object') {
1563
+ normalized[name] = { command: cfg.command.path || cfg.command.command, args: cfg.command.args || [], env: cfg.command.env || {} };
1564
+ } else {
1565
+ normalized[name] = cfg;
1566
+ }
1567
+ }
1568
+ content = { mcpServers: normalized };
1569
+ } else if (c.key === 'servers' && content.servers && !content.mcpServers) {
1570
+ content = { mcpServers: content.servers };
1571
+ }
1572
+ }
1573
+
1574
+ // Only include configs that actually have servers
1575
+ const servers = content?.mcpServers || content?.servers || {};
1576
+ if (Object.keys(servers).length > 0) {
1577
+ found.push({ name: c.name, path: c.path, content });
1578
+ }
1579
+ } catch {}
1580
+ }
1581
+
1582
+ return found;
1583
+ }
1584
+
1585
+ // ── Skill Discovery & Validation ─────────────────────────
1586
+
1587
+ /**
1588
+ * Parse YAML frontmatter from a SKILL.md file.
1589
+ * Returns { meta: {...}, body: string, errors: string[] }
1590
+ */
1591
+ function parseSkillFrontmatter(content) {
1592
+ const errors = [];
1593
+ const lines = content.split('\n');
1594
+
1595
+ // Must start with ---
1596
+ if (lines[0].trim() !== '---') {
1597
+ return { meta: null, body: content, errors: ['Missing YAML frontmatter (file must start with ---)'] };
1598
+ }
1599
+
1600
+ // Find closing ---
1601
+ let endIdx = -1;
1602
+ for (let i = 1; i < lines.length; i++) {
1603
+ if (lines[i].trim() === '---') { endIdx = i; break; }
1604
+ }
1605
+ if (endIdx === -1) {
1606
+ return { meta: null, body: content, errors: ['Unclosed frontmatter (missing closing ---)'] };
1607
+ }
1608
+
1609
+ // Parse YAML-like key: value pairs
1610
+ const meta = {};
1611
+ const yamlLines = lines.slice(1, endIdx);
1612
+ for (let i = 0; i < yamlLines.length; i++) {
1613
+ const line = yamlLines[i];
1614
+ if (line.trim() === '' || line.trim().startsWith('#')) continue;
1615
+
1616
+ // Check for tabs
1617
+ if (line.includes('\t')) {
1618
+ errors.push(`Line ${i + 2}: Tab character found (use spaces)`);
1273
1619
  }
1620
+
1621
+ const match = line.match(/^([a-z][a-z0-9_-]*):\s*(.*)/i);
1622
+ if (!match) {
1623
+ // Could be a continuation line (YAML multiline)
1624
+ continue;
1625
+ }
1626
+ const key = match[1].toLowerCase();
1627
+ let value = match[2].trim();
1628
+
1629
+ // Handle YAML lists on next lines
1630
+ if (value === '' && i + 1 < yamlLines.length && yamlLines[i + 1].match(/^\s+-\s/)) {
1631
+ const items = [];
1632
+ let j = i + 1;
1633
+ while (j < yamlLines.length && yamlLines[j].match(/^\s+-\s/)) {
1634
+ items.push(yamlLines[j].replace(/^\s+-\s*/, '').trim());
1635
+ j++;
1636
+ }
1637
+ value = items;
1638
+ }
1639
+
1640
+ // Strip surrounding quotes
1641
+ if (typeof value === 'string' && ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))) {
1642
+ value = value.slice(1, -1);
1643
+ }
1644
+
1645
+ meta[key] = value;
1274
1646
  }
1647
+
1648
+ const body = lines.slice(endIdx + 1).join('\n').trim();
1649
+ return { meta, body, errors };
1650
+ }
1651
+
1652
+ /**
1653
+ * Validate a parsed skill against the Claude Code SKILL.md spec.
1654
+ * Returns { errors: [...], warnings: [...], info: {...} }
1655
+ */
1656
+ function validateSkill(parsed) {
1657
+ const { meta, body, errors: parseErrors } = parsed;
1658
+ const errors = [...parseErrors];
1659
+ const warnings = [];
1660
+ const info = {};
1661
+
1662
+ if (!meta) return { errors, warnings, info };
1663
+
1664
+ // Known fields
1665
+ const knownFields = new Set([
1666
+ 'name', 'description', 'allowed-tools', 'user-invocable', 'user-invokable',
1667
+ 'disable-model-invocation', 'license', 'metadata', 'argument-hint',
1668
+ 'compatibility', 'version', 'author',
1669
+ ]);
1670
+
1671
+ // Check for unknown fields
1672
+ for (const key of Object.keys(meta)) {
1673
+ if (!knownFields.has(key)) {
1674
+ warnings.push(`Unknown frontmatter field: "${key}"`);
1675
+ }
1676
+ }
1677
+
1678
+ // Required: name
1679
+ if (!meta.name) {
1680
+ errors.push('Missing required field: name');
1681
+ } else {
1682
+ info.name = meta.name;
1683
+ if (meta.name.length > 64) errors.push(`name exceeds 64 chars (${meta.name.length})`);
1684
+ if (/<[^>]+>/.test(meta.name)) errors.push('name contains XML/HTML tags');
1685
+ }
1686
+
1687
+ // Required: description
1688
+ if (!meta.description) {
1689
+ errors.push('Missing required field: description');
1690
+ } else {
1691
+ info.description = typeof meta.description === 'string' ? meta.description.slice(0, 120) : String(meta.description).slice(0, 120);
1692
+ if (typeof meta.description === 'string' && meta.description.length > 1024) {
1693
+ warnings.push(`description is ${meta.description.length} chars (recommended max: 1024)`);
1694
+ }
1695
+ if (/<[^>]+>/.test(meta.description)) warnings.push('description contains XML/HTML tags');
1696
+ }
1697
+
1698
+ // Security: allowed-tools
1699
+ if (!meta['allowed-tools']) {
1700
+ warnings.push('No allowed-tools set — skill has access to ALL tools (security risk)');
1701
+ info.allowedTools = null;
1702
+ } else {
1703
+ const tools = typeof meta['allowed-tools'] === 'string'
1704
+ ? meta['allowed-tools'].split(',').map(t => t.trim()).filter(Boolean)
1705
+ : Array.isArray(meta['allowed-tools']) ? meta['allowed-tools'] : [];
1706
+ info.allowedTools = tools;
1707
+ // Check for wildcard/dangerous patterns
1708
+ if (tools.some(t => t === '*' || t === 'Bash' || t === 'Bash(*)')) {
1709
+ warnings.push('allowed-tools includes unrestricted Bash access');
1710
+ }
1711
+ }
1712
+
1713
+ // Boolean fields
1714
+ for (const boolField of ['user-invocable', 'user-invokable', 'disable-model-invocation']) {
1715
+ if (meta[boolField] !== undefined) {
1716
+ const val = String(meta[boolField]).toLowerCase();
1717
+ if (!['true', 'false'].includes(val)) {
1718
+ errors.push(`${boolField} must be true or false (got: "${meta[boolField]}")`);
1719
+ }
1720
+ }
1721
+ }
1722
+
1723
+ // Typo detection
1724
+ if (meta['user-invokable'] && !meta['user-invocable']) {
1725
+ warnings.push('Using "user-invokable" (known typo variant) — both spellings work');
1726
+ }
1727
+
1728
+ // Body checks
1729
+ if (body) {
1730
+ const bodyLines = body.split('\n').length;
1731
+ info.bodyLines = bodyLines;
1732
+ if (bodyLines > 500) warnings.push(`Body is ${bodyLines} lines (recommended max: 500)`);
1733
+
1734
+ // Check for potential prompt injection patterns in body
1735
+ const injectionPatterns = [
1736
+ { pattern: /ignore\s+(all\s+)?previous\s+(instructions|rules)/i, label: 'Prompt injection pattern' },
1737
+ { pattern: /<IMPORTANT>/i, label: 'Suspicious <IMPORTANT> tag' },
1738
+ { pattern: /system\s*:\s*you\s+are/i, label: 'System prompt override attempt' },
1739
+ ];
1740
+ for (const { pattern, label } of injectionPatterns) {
1741
+ if (pattern.test(body)) {
1742
+ warnings.push(`${label} detected in body`);
1743
+ }
1744
+ }
1745
+ }
1746
+
1747
+ // Extract MCP tool references
1748
+ const mcpRefs = [];
1749
+ const mcpPattern = /mcp__([a-z0-9_-]+)__([a-z0-9_]+)/gi;
1750
+ const fullText = (meta.description || '') + ' ' + (typeof meta['allowed-tools'] === 'string' ? meta['allowed-tools'] : '') + ' ' + (body || '');
1751
+ let mcpMatch;
1752
+ while ((mcpMatch = mcpPattern.exec(fullText)) !== null) {
1753
+ mcpRefs.push({ server: mcpMatch[1], tool: mcpMatch[2] });
1754
+ }
1755
+ info.mcpRefs = mcpRefs;
1756
+
1757
+ // Deduplicate MCP server names
1758
+ info.mcpServers = [...new Set(mcpRefs.map(r => r.server))];
1759
+
1760
+ return { errors, warnings, info };
1761
+ }
1762
+
1763
+ /**
1764
+ * Find all SKILL.md files in known skill directories.
1765
+ */
1766
+ function findSkills() {
1767
+ const home = process.env.HOME || process.env.USERPROFILE || '';
1768
+ const cwd = process.cwd();
1769
+ const found = [];
1770
+
1771
+ const skillDirs = [
1772
+ // Global skill dirs
1773
+ { name: 'Claude Code (global)', base: path.join(home, '.claude', 'skills') },
1774
+ { name: 'Cursor (global)', base: path.join(home, '.cursor', 'skills') },
1775
+ { name: 'Antigravity (global)', base: path.join(home, '.agent', 'skills') },
1776
+ // Project-level skill dirs
1777
+ { name: 'Claude Code (project)', base: path.join(cwd, '.claude', 'skills') },
1778
+ { name: 'Cursor (project)', base: path.join(cwd, '.cursor', 'skills') },
1779
+ { name: 'GitHub Skills (project)', base: path.join(cwd, '.github', 'skills') },
1780
+ { name: 'Antigravity (project)', base: path.join(cwd, '.agent', 'skills') },
1781
+ ];
1782
+
1783
+ for (const dir of skillDirs) {
1784
+ if (!fs.existsSync(dir.base)) continue;
1785
+ try {
1786
+ const entries = fs.readdirSync(dir.base, { withFileTypes: true });
1787
+ for (const entry of entries) {
1788
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
1789
+ const skillPath = path.join(dir.base, entry.name, 'SKILL.md');
1790
+ if (!fs.existsSync(skillPath)) continue;
1791
+ try {
1792
+ const content = fs.readFileSync(skillPath, 'utf8');
1793
+ const parsed = parseSkillFrontmatter(content);
1794
+ const validation = validateSkill(parsed);
1795
+ found.push({
1796
+ source: dir.name,
1797
+ dir: path.join(dir.base, entry.name),
1798
+ path: skillPath,
1799
+ dirName: entry.name,
1800
+ parsed,
1801
+ validation,
1802
+ isSymlink: entry.isSymbolicLink(),
1803
+ });
1804
+ } catch {}
1805
+ }
1806
+ } catch {}
1807
+ }
1808
+
1275
1809
  return found;
1276
1810
  }
1277
1811
 
1812
+ // ── Server Config Extraction ─────────────────────────────
1813
+
1278
1814
  function extractServersFromConfig(config) {
1279
1815
  // Handle both { mcpServers: {...} } and { servers: {...} } formats
1280
1816
  const servers = config.mcpServers || config.servers || {};
@@ -1336,7 +1872,10 @@ function extractServersFromConfig(config) {
1336
1872
  }
1337
1873
  } catch {}
1338
1874
  }
1339
-
1875
+
1876
+ // Resolve local installation directory
1877
+ info.localDir = resolveLocalDir(info);
1878
+
1340
1879
  result.push(info);
1341
1880
  }
1342
1881
  return result;
@@ -1349,6 +1888,196 @@ function serverSlug(server) {
1349
1888
  return server.name.toLowerCase().replace(/[^a-z0-9-]/gi, '-');
1350
1889
  }
1351
1890
 
1891
+ /**
1892
+ * Resolve the local installation directory for a discovered MCP server.
1893
+ * Returns an absolute path or null if not found.
1894
+ */
1895
+ function resolveLocalDir(server) {
1896
+ const home = os.homedir();
1897
+ const isWin = process.platform === 'win32';
1898
+
1899
+ // node /path/to/file → walk up to project root (package.json or .git)
1900
+ const allArgs = [server.command, ...server.args].filter(Boolean).join(' ');
1901
+ const nodePathMatch = allArgs.match(/node\s+["']?([^"'\s]+)/);
1902
+ if (nodePathMatch) {
1903
+ let dir = path.dirname(path.resolve(nodePathMatch[1]));
1904
+ for (let i = 0; i < 5; i++) {
1905
+ if (fs.existsSync(path.join(dir, 'package.json')) || fs.existsSync(path.join(dir, '.git'))) return dir;
1906
+ const parent = path.dirname(dir);
1907
+ if (parent === dir) break;
1908
+ dir = parent;
1909
+ }
1910
+ // Fallback: use the script's directory
1911
+ return path.dirname(path.resolve(nodePathMatch[1]));
1912
+ }
1913
+
1914
+ // python /path/to/file → same approach
1915
+ const pyPathMatch = allArgs.match(/python[3]?\s+["']?([^"'\s]+\.py)/);
1916
+ if (pyPathMatch) {
1917
+ let dir = path.dirname(path.resolve(pyPathMatch[1]));
1918
+ for (let i = 0; i < 5; i++) {
1919
+ if (fs.existsSync(path.join(dir, 'pyproject.toml')) || fs.existsSync(path.join(dir, 'setup.py')) || fs.existsSync(path.join(dir, '.git'))) return dir;
1920
+ const parent = path.dirname(dir);
1921
+ if (parent === dir) break;
1922
+ dir = parent;
1923
+ }
1924
+ return path.dirname(path.resolve(pyPathMatch[1]));
1925
+ }
1926
+
1927
+ // npm/npx package → check global node_modules
1928
+ if (server.npmPackage) {
1929
+ const pkgName = server.npmPackage.replace(/@latest$/, '').replace(/@[\d.]+$/, '');
1930
+ const candidates = [];
1931
+ // Global npm
1932
+ try {
1933
+ const globalRoot = execFileSync('npm', ['root', '-g'], { timeout: 5000, stdio: 'pipe' }).toString().trim();
1934
+ candidates.push(path.join(globalRoot, pkgName));
1935
+ } catch {}
1936
+ // Local node_modules (cwd)
1937
+ candidates.push(path.join(process.cwd(), 'node_modules', pkgName));
1938
+ for (const dir of candidates) {
1939
+ if (fs.existsSync(dir)) return dir;
1940
+ }
1941
+ }
1942
+
1943
+ // uvx/pip package → check uv tools cache and site-packages
1944
+ if (server.pyPackage) {
1945
+ const pkgName = server.pyPackage.replace(/@latest$/, '').replace(/@[\d.]+$/, '');
1946
+ const candidates = [];
1947
+ if (isWin) {
1948
+ const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
1949
+ candidates.push(path.join(localAppData, 'uv', 'tools', pkgName));
1950
+ } else {
1951
+ candidates.push(path.join(home, '.local', 'share', 'uv', 'tools', pkgName));
1952
+ }
1953
+ // Also try pip show
1954
+ try {
1955
+ const pipOut = execFileSync('pip', ['show', pkgName, '-f'], { timeout: 5000, stdio: 'pipe' }).toString();
1956
+ const locMatch = pipOut.match(/Location:\s*(.+)/);
1957
+ if (locMatch) {
1958
+ const normalized = pkgName.replace(/-/g, '_');
1959
+ const pkgDir = path.join(locMatch[1].trim(), normalized);
1960
+ if (fs.existsSync(pkgDir)) candidates.push(pkgDir);
1961
+ }
1962
+ } catch {}
1963
+ for (const dir of candidates) {
1964
+ if (fs.existsSync(dir)) return dir;
1965
+ }
1966
+ }
1967
+
1968
+ return null;
1969
+ }
1970
+
1971
+ /**
1972
+ * Scan a local directory (like scanRepo but without cloning).
1973
+ */
1974
+ async function scanLocalDir(localDir, serverName) {
1975
+ const start = Date.now();
1976
+ const slug = serverName.toLowerCase().replace(/[^a-z0-9-]/gi, '-');
1977
+
1978
+ if (!jsonMode) process.stdout.write(`${icons.scan} Scanning ${c.bold}${slug}${c.reset} ${c.dim}(local)${c.reset} ${c.dim}...${c.reset}`);
1979
+
1980
+ // Collect files from local dir
1981
+ const files = collectFiles(localDir);
1982
+ if (files.length === 0) {
1983
+ if (!jsonMode) process.stdout.write(` ${c.yellow}no scannable files found${c.reset}\n`);
1984
+ return null;
1985
+ }
1986
+
1987
+ // Detect info
1988
+ const info = detectPackageInfo(localDir, files);
1989
+
1990
+ // Quick checks
1991
+ const findings = quickChecks(files);
1992
+
1993
+ // Registry lookup
1994
+ const registryData = await checkRegistry(slug);
1995
+
1996
+ const duration = elapsed(start);
1997
+
1998
+ if (!jsonMode) {
1999
+ process.stdout.write('\r\x1b[K');
2000
+ printScanResult(`local://${localDir}`, info, files, findings, registryData, duration);
2001
+ }
2002
+
2003
+ return { slug, url: `local://${localDir}`, info, files: files.length, findings, registryData, duration };
2004
+ }
2005
+
2006
+ /**
2007
+ * Download package source from PyPI or npm to a temp dir and scan it.
2008
+ * Used as last resort when git clone fails and no local install exists.
2009
+ */
2010
+ async function downloadAndScan(server) {
2011
+ const start = Date.now();
2012
+ const slug = server.name.toLowerCase().replace(/[^a-z0-9-]/gi, '-');
2013
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentaudit-pkg-'));
2014
+
2015
+ try {
2016
+ if (server.pyPackage) {
2017
+ const pkgName = server.pyPackage.replace(/@latest$/, '').replace(/@[\d.]+$/, '');
2018
+ if (!jsonMode) process.stdout.write(`${icons.scan} Downloading ${c.bold}${pkgName}${c.reset} ${c.dim}from PyPI...${c.reset}`);
2019
+ // Download sdist/wheel without installing
2020
+ execFileSync('pip', ['download', '--no-deps', '-d', tmpDir, pkgName], { timeout: 30000, stdio: 'pipe' });
2021
+ // Extract any .tar.gz or .whl (zip) files
2022
+ const downloaded = fs.readdirSync(tmpDir);
2023
+ const extractDir = path.join(tmpDir, 'src');
2024
+ fs.mkdirSync(extractDir, { recursive: true });
2025
+ for (const f of downloaded) {
2026
+ const fp = path.join(tmpDir, f);
2027
+ if (f.endsWith('.whl') || f.endsWith('.zip')) {
2028
+ execFileSync('python', ['-m', 'zipfile', '-e', fp, extractDir], { timeout: 10000, stdio: 'pipe' });
2029
+ } else if (f.endsWith('.tar.gz') || f.endsWith('.tgz')) {
2030
+ execFileSync('tar', ['xzf', fp, '-C', extractDir], { timeout: 10000, stdio: 'pipe' });
2031
+ }
2032
+ }
2033
+ const files = collectFiles(extractDir);
2034
+ if (files.length === 0) return null;
2035
+ const info = detectPackageInfo(extractDir, files);
2036
+ const findings = quickChecks(files);
2037
+ const registryData = await checkRegistry(slug);
2038
+ const duration = elapsed(start);
2039
+ if (!jsonMode) {
2040
+ process.stdout.write('\r\x1b[K');
2041
+ printScanResult(`pypi://${pkgName}`, info, files, findings, registryData, duration);
2042
+ }
2043
+ return { slug, url: `pypi://${pkgName}`, info, files: files.length, findings, registryData, duration };
2044
+ }
2045
+
2046
+ if (server.npmPackage) {
2047
+ const pkgName = server.npmPackage.replace(/@latest$/, '').replace(/@[\d.]+$/, '');
2048
+ if (!jsonMode) process.stdout.write(`${icons.scan} Downloading ${c.bold}${pkgName}${c.reset} ${c.dim}from npm...${c.reset}`);
2049
+ // npm pack downloads tarball without installing
2050
+ execFileSync('npm', ['pack', pkgName, '--pack-destination', tmpDir], { timeout: 30000, stdio: 'pipe' });
2051
+ const tarballs = fs.readdirSync(tmpDir).filter(f => f.endsWith('.tgz'));
2052
+ if (tarballs.length === 0) return null;
2053
+ const extractDir = path.join(tmpDir, 'src');
2054
+ fs.mkdirSync(extractDir, { recursive: true });
2055
+ execFileSync('tar', ['xzf', path.join(tmpDir, tarballs[0]), '-C', extractDir], { timeout: 10000, stdio: 'pipe' });
2056
+ const files = collectFiles(extractDir);
2057
+ if (files.length === 0) return null;
2058
+ const info = detectPackageInfo(extractDir, files);
2059
+ const findings = quickChecks(files);
2060
+ const registryData = await checkRegistry(slug);
2061
+ const duration = elapsed(start);
2062
+ if (!jsonMode) {
2063
+ process.stdout.write('\r\x1b[K');
2064
+ printScanResult(`npm://${pkgName}`, info, files, findings, registryData, duration);
2065
+ }
2066
+ return { slug, url: `npm://${pkgName}`, info, files: files.length, findings, registryData, duration };
2067
+ }
2068
+ } catch (err) {
2069
+ if (!jsonMode) {
2070
+ process.stdout.write('\r\x1b[K');
2071
+ process.stdout.write(`${icons.scan} ${c.bold}${slug}${c.reset} ${c.yellow}download failed${c.reset}\n`);
2072
+ const msg = err.stderr?.toString().trim().split('\n')[0] || err.message?.split('\n')[0] || '';
2073
+ if (msg) console.log(` ${c.dim}${msg}${c.reset}`);
2074
+ }
2075
+ } finally {
2076
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
2077
+ }
2078
+ return null;
2079
+ }
2080
+
1352
2081
  async function searchGitHub(query) {
1353
2082
  try {
1354
2083
  const res = await fetch(`https://api.github.com/search/repositories?q=${encodeURIComponent(query)}&per_page=1`, {
@@ -1450,7 +2179,7 @@ async function discoverCommand(options = {}) {
1450
2179
  const interactiveAudit = options.audit || false;
1451
2180
 
1452
2181
  if (!jsonMode) {
1453
- console.log(` ${c.bold}Discovering MCP servers in your AI editors...${c.reset}`);
2182
+ console.log(` ${c.bold}Discovering MCP servers across all AI tools...${c.reset}`);
1454
2183
  console.log();
1455
2184
  }
1456
2185
 
@@ -1458,13 +2187,16 @@ async function discoverCommand(options = {}) {
1458
2187
 
1459
2188
  if (configs.length === 0) {
1460
2189
  console.log(` ${c.yellow}No MCP configurations found.${c.reset}`);
1461
- console.log(` ${c.dim}Searched: Claude Desktop, Cursor, Windsurf, VS Code${c.reset}`);
2190
+ console.log(` ${c.dim}Searched 15+ tools: Claude Desktop, Claude Code, Cursor, Windsurf, VS Code,${c.reset}`);
2191
+ console.log(` ${c.dim}Cline, Roo Code, Amazon Q, Gemini CLI, Zed, Continue.dev, Goose, Codex CLI${c.reset}`);
1462
2192
  console.log();
1463
- console.log(` ${c.dim}MCP config locations:${c.reset}`);
1464
- console.log(` ${c.dim} Claude: ~/.claude/mcp.json${c.reset}`);
1465
- console.log(` ${c.dim} Cursor: ~/.cursor/mcp.json${c.reset}`);
1466
- console.log(` ${c.dim} Windsurf: ~/.codeium/windsurf/mcp_config.json${c.reset}`);
1467
- console.log(` ${c.dim} VS Code: ~/.vscode/mcp.json${c.reset}`);
2193
+ console.log(` ${c.dim}Common MCP config locations:${c.reset}`);
2194
+ console.log(` ${c.dim} Claude Desktop: ~/Library/Application Support/Claude/claude_desktop_config.json${c.reset}`);
2195
+ console.log(` ${c.dim} Claude Code: ~/.claude.json${c.reset}`);
2196
+ console.log(` ${c.dim} Cursor: ~/.cursor/mcp.json${c.reset}`);
2197
+ console.log(` ${c.dim} Windsurf: ~/.codeium/windsurf/mcp_config.json${c.reset}`);
2198
+ console.log(` ${c.dim} VS Code: (platform)/Code/User/mcp.json${c.reset}`);
2199
+ console.log(` ${c.dim} Project-level: .mcp.json / .cursor/mcp.json / .vscode/mcp.json${c.reset}`);
1468
2200
  console.log();
1469
2201
  return;
1470
2202
  }
@@ -1527,15 +2259,18 @@ async function discoverCommand(options = {}) {
1527
2259
  const riskScore = regData.risk_score ?? regData.latest_risk_score ?? 0;
1528
2260
  const hasOfficial = regData.has_official_audit;
1529
2261
  console.log(`${branch} ${c.bold}${server.name}${c.reset} ${sourceLabel}`);
1530
- console.log(`${pipe} ${riskBadge(riskScore)} Risk ${riskScore} ${hasOfficial ? `${c.green}✔ official${c.reset} ` : ''}${c.dim}${REGISTRY_URL}/packages/${slug}${c.reset}`);
1531
- if (resolvedUrl) allServersWithUrls.push({ name: server.name, sourceUrl: resolvedUrl, hasAudit: true, regData });
2262
+ console.log(`${pipe} ${riskBadge(riskScore)} ${hasOfficial ? `${c.green}✔ official${c.reset} ` : ''}${c.dim}${REGISTRY_URL}/packages/${slug}${c.reset}`);
2263
+ if (resolvedUrl || server.localDir || server.pyPackage || server.npmPackage) allServersWithUrls.push({ name: server.name, sourceUrl: resolvedUrl, localDir: server.localDir, pyPackage: server.pyPackage, npmPackage: server.npmPackage, hasAudit: true, regData });
1532
2264
  } else {
1533
2265
  unauditedServers++;
1534
2266
  console.log(`${branch} ${c.bold}${server.name}${c.reset} ${sourceLabel}`);
1535
2267
  if (resolvedUrl) {
1536
2268
  console.log(`${pipe} ${c.yellow}⚠ not audited${c.reset} ${c.dim}Run: ${c.cyan}agentaudit audit ${resolvedUrl}${c.reset}`);
1537
2269
  unauditedWithUrls.push({ name: server.name, sourceUrl: resolvedUrl });
1538
- allServersWithUrls.push({ name: server.name, sourceUrl: resolvedUrl, hasAudit: false });
2270
+ allServersWithUrls.push({ name: server.name, sourceUrl: resolvedUrl, localDir: server.localDir, pyPackage: server.pyPackage, npmPackage: server.npmPackage, hasAudit: false });
2271
+ } else if (server.localDir || server.pyPackage || server.npmPackage) {
2272
+ console.log(`${pipe} ${c.yellow}⚠ not audited${c.reset} ${c.dim}${server.localDir ? 'local install found' : 'package registry available'} — will scan${c.reset}`);
2273
+ allServersWithUrls.push({ name: server.name, sourceUrl: null, localDir: server.localDir, pyPackage: server.pyPackage, npmPackage: server.npmPackage, hasAudit: false });
1539
2274
  } else {
1540
2275
  console.log(`${pipe} ${c.yellow}⚠ not audited${c.reset} ${c.dim}Source URL unknown — check the package's GitHub/npm page${c.reset}`);
1541
2276
  }
@@ -1550,43 +2285,131 @@ async function discoverCommand(options = {}) {
1550
2285
  }
1551
2286
 
1552
2287
  // Summary
1553
- console.log(`${c.dim}${''.repeat(60)}${c.reset}`);
1554
- console.log(` ${c.bold}Summary${c.reset} ${totalServers} server${totalServers !== 1 ? 's' : ''} across ${configs.length} config${configs.length !== 1 ? 's' : ''}`);
2288
+ console.log(sectionHeader(`Summary — ${totalServers} server${totalServers !== 1 ? 's' : ''} across ${configs.length} config${configs.length !== 1 ? 's' : ''}`));
1555
2289
  console.log();
1556
2290
  if (auditedServers > 0) console.log(` ${icons.safe} ${c.green}${auditedServers} audited${c.reset}`);
1557
2291
  if (unauditedServers > 0) console.log(` ${icons.caution} ${c.yellow}${unauditedServers} not audited${c.reset}`);
2292
+ if (totalServers > 0) {
2293
+ console.log();
2294
+ console.log(` ${coverageBar(auditedServers, totalServers)}`);
2295
+ }
1558
2296
  console.log();
1559
2297
 
1560
- // --scan: automatically scan all servers with resolved source URLs (git-cloneable only)
2298
+ // ── Skill Discovery ──────────────────────────────────
2299
+ const skills = findSkills();
2300
+ if (skills.length > 0) {
2301
+ console.log(sectionHeader(`Skills — ${skills.length} found`));
2302
+ console.log();
2303
+
2304
+ // Group by source
2305
+ const bySource = {};
2306
+ for (const skill of skills) {
2307
+ (bySource[skill.source] || (bySource[skill.source] = [])).push(skill);
2308
+ }
2309
+
2310
+ for (const [source, sourceSkills] of Object.entries(bySource)) {
2311
+ console.log(`${icons.bullet} ${c.bold}${source}${c.reset}`);
2312
+ console.log();
2313
+
2314
+ for (let i = 0; i < sourceSkills.length; i++) {
2315
+ const skill = sourceSkills[i];
2316
+ const isLast = i === sourceSkills.length - 1;
2317
+ const branch = isLast ? icons.treeLast : icons.tree;
2318
+ const pipe = isLast ? ' ' : `${icons.pipe} `;
2319
+ const { errors, warnings, info } = skill.validation;
2320
+ const name = info.name || skill.dirName;
2321
+ const hasErrors = errors.length > 0;
2322
+ const hasWarnings = warnings.length > 0;
2323
+
2324
+ // Status indicator
2325
+ let status;
2326
+ if (hasErrors) status = `${c.red}✖ ${errors.length} error${errors.length !== 1 ? 's' : ''}${c.reset}`;
2327
+ else if (hasWarnings) status = `${c.yellow}⚠ ${warnings.length} warning${warnings.length !== 1 ? 's' : ''}${c.reset}`;
2328
+ else status = `${c.green}✔ valid${c.reset}`;
2329
+
2330
+ console.log(`${branch} ${c.bold}${name}${c.reset} ${status}`);
2331
+
2332
+ // Description (truncated)
2333
+ if (info.description) {
2334
+ const desc = info.description.length > 70 ? info.description.slice(0, 67) + '...' : info.description;
2335
+ console.log(`${pipe} ${c.dim}${desc}${c.reset}`);
2336
+ }
2337
+
2338
+ // MCP tool references
2339
+ if (info.mcpServers && info.mcpServers.length > 0) {
2340
+ const serverList = info.mcpServers.map(s => `${c.cyan}${s}${c.reset}`).join(', ');
2341
+ console.log(`${pipe} ${c.dim}uses MCP:${c.reset} ${serverList}`);
2342
+ }
2343
+
2344
+ // Allowed tools summary
2345
+ if (info.allowedTools === null) {
2346
+ console.log(`${pipe} ${c.yellow}⚠ no allowed-tools — unrestricted access${c.reset}`);
2347
+ } else if (info.allowedTools && info.allowedTools.length > 0) {
2348
+ const toolCount = info.allowedTools.length;
2349
+ console.log(`${pipe} ${c.dim}${toolCount} allowed tool${toolCount !== 1 ? 's' : ''}${c.reset}`);
2350
+ }
2351
+
2352
+ // Show errors/warnings inline
2353
+ if (hasErrors) {
2354
+ for (const err of errors.slice(0, 3)) {
2355
+ console.log(`${pipe} ${c.red} ✖ ${err}${c.reset}`);
2356
+ }
2357
+ }
2358
+ if (hasWarnings && !hasErrors) {
2359
+ for (const warn of warnings.slice(0, 2)) {
2360
+ console.log(`${pipe} ${c.yellow} ⚠ ${warn}${c.reset}`);
2361
+ }
2362
+ }
2363
+ }
2364
+ console.log();
2365
+ }
2366
+ }
2367
+
2368
+ // --scan: automatically scan all servers (git clone + local fallback)
1561
2369
  if (autoScan) {
1562
2370
  const isCloneable = (url) => /^https?:\/\/(github\.com|gitlab\.com|bitbucket\.org)\//i.test(url);
1563
- const scanTargets = allServersWithUrls.filter(s => s.sourceUrl && isCloneable(s.sourceUrl));
1564
- // Deduplicate by sourceUrl
2371
+ // Include servers that are cloneable OR have a local dir OR a known package
2372
+ const scanTargets = allServersWithUrls.filter(s =>
2373
+ (s.sourceUrl && isCloneable(s.sourceUrl)) || s.localDir || s.pyPackage || s.npmPackage
2374
+ );
2375
+ // Deduplicate by sourceUrl or localDir
1565
2376
  const seen = new Set();
1566
2377
  const dedupedTargets = scanTargets.filter(s => {
1567
- if (seen.has(s.sourceUrl)) return false;
1568
- seen.add(s.sourceUrl);
2378
+ const key = (s.sourceUrl && isCloneable(s.sourceUrl)) ? s.sourceUrl : s.localDir;
2379
+ if (!key || seen.has(key)) return false;
2380
+ seen.add(key);
1569
2381
  return true;
1570
2382
  });
1571
- const skipped = allServersWithUrls.filter(s => s.sourceUrl && !isCloneable(s.sourceUrl));
2383
+ const skippedCount = allServersWithUrls.length - scanTargets.length;
1572
2384
  if (dedupedTargets.length > 0) {
1573
- console.log(`${c.dim}${''.repeat(60)}${c.reset}`);
1574
- console.log(` ${c.bold}${icons.scan} Auto-scanning ${dedupedTargets.length} server${dedupedTargets.length !== 1 ? 's' : ''}...${c.reset}`);
1575
- if (skipped.length > 0) {
1576
- console.log(` ${c.dim}(${skipped.length} skipped — no cloneable source URL)${c.reset}`);
2385
+ console.log(sectionHeader(`Auto-scanning ${dedupedTargets.length} server${dedupedTargets.length !== 1 ? 's' : ''}`));
2386
+ console.log(` ${c.bold}${icons.scan} Starting scans...${c.reset}`);
2387
+ if (skippedCount > 0) {
2388
+ console.log(` ${c.dim}(${skippedCount} skipped — remote-only, no local source)${c.reset}`);
1577
2389
  }
1578
2390
  console.log();
1579
-
2391
+
1580
2392
  const scanResults = [];
1581
2393
  for (const target of dedupedTargets) {
1582
- const result = await scanRepo(target.sourceUrl);
2394
+ let result = null;
2395
+ // Try git clone first if URL is cloneable
2396
+ if (target.sourceUrl && isCloneable(target.sourceUrl)) {
2397
+ result = await scanRepo(target.sourceUrl);
2398
+ }
2399
+ // Fallback 1: scan local installation
2400
+ if (!result && target.localDir) {
2401
+ result = await scanLocalDir(target.localDir, target.name);
2402
+ }
2403
+ // Fallback 2: download from PyPI/npm and scan
2404
+ if (!result && (target.pyPackage || target.npmPackage)) {
2405
+ result = await downloadAndScan(target);
2406
+ }
1583
2407
  if (result) scanResults.push({ ...result, serverName: target.name });
1584
2408
  }
1585
2409
 
1586
2410
  if (scanResults.length > 1) {
1587
2411
  // Print combined scan summary
1588
- console.log(`${c.dim}${''.repeat(60)}${c.reset}`);
1589
- console.log(` ${c.bold}Scan Summary${c.reset} ${scanResults.length} server${scanResults.length !== 1 ? 's' : ''} scanned`);
2412
+ console.log(sectionHeader(`Scan Summary — ${scanResults.length} server${scanResults.length !== 1 ? 's' : ''} scanned`));
1590
2413
  console.log();
1591
2414
 
1592
2415
  let totalFindings = 0;
@@ -1673,7 +2496,7 @@ async function discoverCommand(options = {}) {
1673
2496
  }
1674
2497
 
1675
2498
  if (!autoScan && !interactiveAudit && !jsonMode) {
1676
- console.log(` ${c.dim}Looking for general package scanning? Try ${c.cyan}pip audit${c.dim} or ${c.cyan}npm audit${c.dim}.${c.reset}`);
2499
+ console.log(` ${c.dim}Run ${c.cyan}agentaudit discover --quick${c.dim} to auto-scan all servers${c.reset}`);
1677
2500
  console.log();
1678
2501
  }
1679
2502
  }
@@ -1695,7 +2518,7 @@ async function auditRepo(url) {
1695
2518
  console.log();
1696
2519
 
1697
2520
  // Step 1: Clone
1698
- process.stdout.write(` ${c.dim}[1/4]${c.reset} Cloning repository...`);
2521
+ process.stdout.write(` ${stepProgress(1, 4)} Cloning repository...`);
1699
2522
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentaudit-'));
1700
2523
  const repoPath = path.join(tmpDir, 'repo');
1701
2524
  try {
@@ -1710,12 +2533,12 @@ async function auditRepo(url) {
1710
2533
  }
1711
2534
 
1712
2535
  // Step 2: Collect files
1713
- process.stdout.write(` ${c.dim}[2/4]${c.reset} Collecting source files...`);
2536
+ process.stdout.write(` ${stepProgress(2, 4)} Collecting source files...`);
1714
2537
  const files = collectFiles(repoPath);
1715
2538
  console.log(` ${c.green}${files.length} files${c.reset}`);
1716
2539
 
1717
2540
  // Step 3: Build audit payload
1718
- process.stdout.write(` ${c.dim}[3/4]${c.reset} Preparing audit payload...`);
2541
+ process.stdout.write(` ${stepProgress(3, 4)} Preparing audit payload...`);
1719
2542
  const auditPrompt = loadAuditPrompt();
1720
2543
 
1721
2544
  let codeBlock = '';
@@ -1788,7 +2611,7 @@ async function auditRepo(url) {
1788
2611
 
1789
2612
  // We have an API key — run LLM audit
1790
2613
  const modelLabel = modelOverride ? `${activeProvider} → ${activeLlm.model}` : activeProvider;
1791
- process.stdout.write(` ${c.dim}[4/4]${c.reset} Running LLM analysis ${c.dim}(${modelLabel})${c.reset}...`);
2614
+ process.stdout.write(` ${stepProgress(4, 4)} Running LLM analysis ${c.dim}(${modelLabel})${c.reset}...`);
1792
2615
 
1793
2616
  const systemPrompt = auditPrompt || 'You are a security auditor. Analyze the code and report findings as JSON.';
1794
2617
  const userMessage = [
@@ -2002,17 +2825,26 @@ async function auditRepo(url) {
2002
2825
  // Display results
2003
2826
  console.log();
2004
2827
  const riskScore = report.risk_score || 0;
2005
- console.log(` ${riskBadge(riskScore)} Risk ${riskScore}/100 ${c.bold}${report.result || 'unknown'}${c.reset}`);
2828
+ console.log(sectionHeader('Result'));
2829
+ console.log(` ${riskBadge(riskScore)}`);
2006
2830
  console.log();
2007
-
2831
+
2008
2832
  if (report.findings && report.findings.length > 0) {
2009
- console.log(` ${c.bold}Findings (${report.findings.length})${c.reset}`);
2833
+ console.log(sectionHeader(`Findings (${report.findings.length})`));
2010
2834
  console.log();
2011
2835
  for (const f of report.findings) {
2012
2836
  const sc = severityColor(f.severity);
2013
- console.log(` ${severityIcon(f.severity)} ${sc}${(f.severity || '').toUpperCase().padEnd(8)}${c.reset} ${f.title}`);
2014
- if (f.file) console.log(` ${c.dim}${f.file}${f.line ? ':' + f.line : ''}${c.reset}`);
2015
- if (f.description) console.log(` ${c.dim}${f.description.slice(0, 120)}${c.reset}`);
2837
+ console.log(` ${sc}┃${c.reset} ${sc}${(f.severity || '').toUpperCase().padEnd(8)}${c.reset} ${c.bold}${f.title}${c.reset}`);
2838
+ if (f.file) console.log(` ${sc}┃${c.reset} ${c.dim}${f.file}${f.line ? ':' + f.line : ''}${c.reset}`);
2839
+ if (f.description) console.log(` ${sc}┃${c.reset} ${c.dim}${f.description.slice(0, 120)}${c.reset}`);
2840
+ console.log();
2841
+ }
2842
+
2843
+ // Severity histogram
2844
+ const histLines = severityHistogram(report.findings);
2845
+ if (histLines.length > 1) {
2846
+ console.log(sectionHeader('Severity'));
2847
+ for (const line of histLines) console.log(line);
2016
2848
  console.log();
2017
2849
  }
2018
2850
  } else {
@@ -2247,7 +3079,7 @@ function renderLeaderboardTab(data, width, opts = {}) {
2247
3079
  }
2248
3080
 
2249
3081
  const maxPts = leaderboard[0]?.total_points || 1;
2250
- const medals = ['🥇', '🥈', '🥉'];
3082
+ const medals = [`${c.yellow}★${c.reset}`, `${c.cyan}★${c.reset}`, `${c.magenta}★${c.reset}`];
2251
3083
 
2252
3084
  for (let i = 0; i < leaderboard.length; i++) {
2253
3085
  const entry = leaderboard[i];
@@ -2289,20 +3121,22 @@ function renderBenchmarkTab(data, width) {
2289
3121
  lines.push(` ${c.bold}${fmtNum(benchmark.models.length)}${c.reset} models ${c.dim}│${c.reset} ${c.bold}${fmtNum(overview.total_reports || 0)}${c.reset} audits ${c.dim}│${c.reset} ${c.bold}${fmtNum(overview.total_findings || 0)}${c.reset} findings`);
2290
3122
  lines.push('');
2291
3123
 
2292
- // Header
2293
- const nameW = 28;
2294
- const hdr = ` ${padRight(`${c.bold}Model${c.reset}`, nameW + 9)} ${padRight('Audits', 7)} ${padRight('Risk', 5)} ${padRight('Detection', 16)} Severity`;
2295
- lines.push(hdr);
3124
+ // Header — fixed column widths for alignment
3125
+ const nameW = 30;
3126
+ const auditsW = 6;
3127
+ const riskW = 5;
3128
+ const hdr = ` ${padRight('Model', nameW)} ${padLeft('Audits', auditsW)} ${padLeft('Risk', riskW)} ${'Detection'.padEnd(14)} Severity`;
3129
+ lines.push(` ${c.bold}${stripAnsi(hdr).trim()}${c.reset}`);
2296
3130
  lines.push(` ${c.dim}${'─'.repeat(Math.min(width - 4, 86))}${c.reset}`);
2297
3131
 
2298
3132
  for (const m of benchmark.models) {
2299
3133
  const name = (m.audit_model || 'unknown').slice(0, nameW - 2);
2300
- const audits = padLeft(fmtNum(m.total_audits), 5);
3134
+ const audits = padLeft(fmtNum(m.total_audits), auditsW);
2301
3135
  const riskVal = parseFloat(m.avg_risk_score) || 0;
2302
3136
  const riskColor = riskVal <= 20 ? c.green : riskVal <= 40 ? c.yellow : c.red;
2303
- const risk = `${riskColor}${padLeft(String(Math.round(riskVal)), 3)}${c.reset}`;
3137
+ const risk = `${riskColor}${padLeft(String(Math.round(riskVal)), riskW)}${c.reset}`;
2304
3138
  const detection = renderGauge(m.detection_rate || 0, 100, 10);
2305
- // Severity as compact text instead of dots
3139
+ // Severity as compact text
2306
3140
  const sev = m.severity_breakdown || {};
2307
3141
  const sevParts = [];
2308
3142
  if (sev.critical) sevParts.push(`${c.red}${sev.critical}C${c.reset}`);
@@ -2310,7 +3144,7 @@ function renderBenchmarkTab(data, width) {
2310
3144
  if (sev.medium) sevParts.push(`${c.yellow}${sev.medium}M${c.reset}`);
2311
3145
  if (sev.low) sevParts.push(`${c.blue}${sev.low}L${c.reset}`);
2312
3146
  const sevStr = sevParts.length > 0 ? sevParts.join(' ') : `${c.dim}—${c.reset}`;
2313
- lines.push(` ${padRight(name, nameW)} ${audits} ${risk} ${detection} ${sevStr}`);
3147
+ lines.push(` ${padRight(name, nameW)} ${audits} ${risk} ${detection} ${sevStr}`);
2314
3148
  }
2315
3149
 
2316
3150
  // Vulnerability landscape
@@ -2651,7 +3485,7 @@ async function leaderboardCommand(args) {
2651
3485
  }
2652
3486
 
2653
3487
  const maxPts = data[0]?.total_points || 1;
2654
- const medals = ['🥇', '🥈', '🥉'];
3488
+ const medals = [`${c.yellow}★${c.reset}`, `${c.cyan}★${c.reset}`, `${c.magenta}★${c.reset}`];
2655
3489
  const barW = 24;
2656
3490
 
2657
3491
  for (let i = 0; i < data.length; i++) {
@@ -2997,7 +3831,9 @@ async function main() {
2997
3831
  discover: [
2998
3832
  `${c.bold}agentaudit discover${c.reset} [options]`,
2999
3833
  ``,
3000
- `Find MCP servers configured in your AI editors (Cursor, Claude Desktop, VS Code, Windsurf).`,
3834
+ `Find MCP servers across 15+ AI tools (Claude Desktop, Claude Code, Cursor, VS Code,`,
3835
+ `Windsurf, Cline, Roo Code, Amazon Q, Gemini CLI, Zed, Continue.dev, Goose, Codex CLI).`,
3836
+ `Checks global + project-level configs on macOS, Windows, and Linux.`,
3001
3837
  ``,
3002
3838
  `${c.bold}Options:${c.reset}`,
3003
3839
  ` --quick, -s Auto-scan all discovered servers (regex-based)`,
@@ -3185,6 +4021,21 @@ async function main() {
3185
4021
  ` agentaudit find fastmcp`,
3186
4022
  ],
3187
4023
  find: null, // alias → search
4024
+ profile: [
4025
+ `${c.bold}agentaudit profile${c.reset}`,
4026
+ ``,
4027
+ `Show your AgentAudit profile — rank, points, audit stats,`,
4028
+ `and a link to your public profile on agentaudit.dev.`,
4029
+ ``,
4030
+ `Requires being logged in (run ${c.cyan}agentaudit setup${c.reset} first).`,
4031
+ ``,
4032
+ `${c.bold}Options:${c.reset}`,
4033
+ ` --json Machine-readable JSON output`,
4034
+ ``,
4035
+ `${c.bold}Examples:${c.reset}`,
4036
+ ` agentaudit profile`,
4037
+ ` agentaudit profile --json`,
4038
+ ],
3188
4039
  };
3189
4040
 
3190
4041
  // Show subcommand help: `agentaudit help <cmd>` or `agentaudit <cmd> --help`
@@ -3231,9 +4082,10 @@ async function main() {
3231
4082
  console.log(` agentaudit <command> [options]`);
3232
4083
  console.log();
3233
4084
  console.log(` ${c.bold}SCAN & AUDIT${c.reset}`);
3234
- console.log(` ${c.cyan}discover${c.reset} Find MCP servers in your AI editors`);
4085
+ console.log(` ${c.cyan}discover${c.reset} Find MCP servers & skills in your AI tools`);
3235
4086
  console.log(` ${c.cyan}scan${c.reset} <url> [url...] Quick static scan (regex, ~2s)`);
3236
4087
  console.log(` ${c.cyan}audit${c.reset} <url> [url...] Deep LLM-powered security audit (~30s)`);
4088
+ console.log(` ${c.cyan}validate${c.reset} [path] Validate SKILL.md format & security`);
3237
4089
  console.log(` ${c.cyan}lookup${c.reset} <name> Look up package in registry`);
3238
4090
  console.log();
3239
4091
  console.log(` ${c.bold}COMMUNITY${c.reset}`);
@@ -3247,6 +4099,7 @@ async function main() {
3247
4099
  console.log(` ${c.cyan}model${c.reset} Configure LLM provider + model`);
3248
4100
  console.log(` ${c.cyan}setup${c.reset} Log in to agentaudit.dev (for report uploads)`);
3249
4101
  console.log(` ${c.cyan}status${c.reset} Show current config + auth status`);
4102
+ console.log(` ${c.cyan}profile${c.reset} Your profile — rank, points, audit stats`);
3250
4103
  console.log();
3251
4104
  console.log(` ${c.bold}FLAGS${c.reset}`);
3252
4105
  console.log(` ${c.dim}--json Machine-readable JSON output${c.reset}`);
@@ -3405,6 +4258,103 @@ async function main() {
3405
4258
  return;
3406
4259
  }
3407
4260
 
4261
+ if (command === 'profile') {
4262
+ const creds = loadCredentials();
4263
+ if (!creds) {
4264
+ console.log(` ${c.yellow}Not logged in.${c.reset}`);
4265
+ console.log(` ${c.dim}Run ${c.cyan}agentaudit setup${c.dim} to link your API key.${c.reset}`);
4266
+ console.log();
4267
+ return;
4268
+ }
4269
+
4270
+ const agentName = creds.agent_name || 'unknown';
4271
+ console.log(` ${c.bold}Profile${c.reset} ${c.cyan}${agentName}${c.reset}`);
4272
+ console.log();
4273
+
4274
+ try {
4275
+ process.stdout.write(` ${c.dim}Fetching profile data...${c.reset}`);
4276
+ const [agentRes, lbRes] = await Promise.all([
4277
+ fetch(`${REGISTRY_URL}/api/agents/${encodeURIComponent(agentName)}`, {
4278
+ headers: { 'Authorization': `Bearer ${creds.api_key}` },
4279
+ signal: AbortSignal.timeout(10_000),
4280
+ }).then(r => r.ok ? r.json() : null),
4281
+ fetch(`${REGISTRY_URL}/api/leaderboard?limit=100`, {
4282
+ signal: AbortSignal.timeout(10_000),
4283
+ }).then(r => r.ok ? r.json() : null),
4284
+ ]);
4285
+ process.stdout.write('\r\x1b[K');
4286
+
4287
+ if (!agentRes) {
4288
+ console.log(` ${c.yellow}Could not fetch profile data.${c.reset}`);
4289
+ console.log(` ${c.dim}Your account may not have submitted any audits yet.${c.reset}`);
4290
+ console.log();
4291
+ console.log(` ${c.dim}Web profile: ${c.cyan}${REGISTRY_URL}/profile${c.reset}`);
4292
+ console.log();
4293
+ return;
4294
+ }
4295
+
4296
+ let rank = null;
4297
+ if (Array.isArray(lbRes)) {
4298
+ const idx = lbRes.findIndex(e => e.agent_name === agentName);
4299
+ if (idx >= 0) rank = idx + 1;
4300
+ }
4301
+
4302
+ // Update cache
4303
+ saveProfileCache({
4304
+ agent_name: agentName,
4305
+ rank,
4306
+ total_points: agentRes.total_points || 0,
4307
+ total_reports: agentRes.total_reports || 0,
4308
+ });
4309
+
4310
+ if (jsonMode) {
4311
+ console.log(JSON.stringify({
4312
+ agent_name: agentName,
4313
+ rank: rank ? { position: rank, total: lbRes?.length || 0 } : null,
4314
+ total_points: agentRes.total_points || 0,
4315
+ total_reports: agentRes.total_reports || 0,
4316
+ total_findings_submitted: agentRes.total_findings_submitted || 0,
4317
+ total_findings_confirmed: agentRes.total_findings_confirmed || 0,
4318
+ profile_url: `${REGISTRY_URL}/profile`,
4319
+ }, null, 2));
4320
+ return;
4321
+ }
4322
+
4323
+ const boxW = 44;
4324
+ const rankStr = rank ? `#${rank} of ${lbRes.length}` : '—';
4325
+ const pts = agentRes.total_points || 0;
4326
+ const audits = agentRes.total_reports || 0;
4327
+ const findingsSubmitted = agentRes.total_findings_submitted || 0;
4328
+ const findingsConfirmed = agentRes.total_findings_confirmed || 0;
4329
+ const ptsBar = renderBar(pts, Math.max(pts, 1000), boxW - 16);
4330
+
4331
+ const contentLines = [
4332
+ '',
4333
+ `${c.bold}${c.cyan}${agentName}${c.reset}${' '.repeat(Math.max(1, boxW - 6 - agentName.length - rankStr.length))}${c.bold}${rankStr}${c.reset}`,
4334
+ '',
4335
+ `Points ${c.bold}${padLeft(fmtNum(pts), 8)}${c.reset}`,
4336
+ `Audits ${c.bold}${padLeft(fmtNum(audits), 8)}${c.reset}`,
4337
+ `Findings ${c.bold}${padLeft(fmtNum(findingsSubmitted), 8)}${c.reset} ${c.dim}(${fmtNum(findingsConfirmed)} confirmed)${c.reset}`,
4338
+ '',
4339
+ ptsBar,
4340
+ '',
4341
+ `${c.dim}${REGISTRY_URL}/profile${c.reset}`,
4342
+ `${c.dim}Key: ${creds.api_key.slice(0, 12)}...${c.reset}`,
4343
+ '',
4344
+ ];
4345
+ const boxLines = drawBox('Profile', contentLines, boxW + 4);
4346
+ for (const line of boxLines) console.log(line);
4347
+ console.log();
4348
+ } catch (err) {
4349
+ process.stdout.write('\r\x1b[K');
4350
+ console.log(` ${c.yellow}Failed to fetch profile: ${err.message}${c.reset}`);
4351
+ console.log();
4352
+ console.log(` ${c.dim}Web profile: ${c.cyan}${REGISTRY_URL}/profile${c.reset}`);
4353
+ console.log();
4354
+ }
4355
+ return;
4356
+ }
4357
+
3408
4358
  if (command === 'model') {
3409
4359
  const newModel = targets.filter(t => !t.startsWith('--'))[0];
3410
4360
  const config = loadLlmConfig();
@@ -3568,6 +4518,124 @@ async function main() {
3568
4518
  return;
3569
4519
  }
3570
4520
 
4521
+ if (command === 'validate') {
4522
+ const paths = targets.filter(t => !t.startsWith('--'));
4523
+
4524
+ // If no path given, find all skills and validate them
4525
+ if (paths.length === 0) {
4526
+ const skills = findSkills();
4527
+ if (skills.length === 0) {
4528
+ console.log(` ${c.yellow}No SKILL.md files found${c.reset}`);
4529
+ console.log(` ${c.dim}Searched: ~/.claude/skills/, ~/.cursor/skills/, .claude/skills/, .cursor/skills/${c.reset}`);
4530
+ console.log();
4531
+ console.log(` ${c.dim}Usage: ${c.cyan}agentaudit validate [path/to/SKILL.md]${c.reset}`);
4532
+ return;
4533
+ }
4534
+
4535
+ console.log(` ${c.bold}Validating ${skills.length} skill${skills.length !== 1 ? 's' : ''}${c.reset}`);
4536
+ console.log();
4537
+
4538
+ let totalErrors = 0;
4539
+ let totalWarnings = 0;
4540
+
4541
+ for (const skill of skills) {
4542
+ const { errors, warnings, info } = skill.validation;
4543
+ totalErrors += errors.length;
4544
+ totalWarnings += warnings.length;
4545
+
4546
+ const name = info.name || skill.dirName;
4547
+ const hasErrors = errors.length > 0;
4548
+ const hasWarnings = warnings.length > 0;
4549
+
4550
+ if (hasErrors) {
4551
+ console.log(` ${c.red}✖${c.reset} ${c.bold}${name}${c.reset} ${c.dim}${skill.path}${c.reset}`);
4552
+ for (const err of errors) console.log(` ${c.red}✖ ${err}${c.reset}`);
4553
+ for (const warn of warnings) console.log(` ${c.yellow}⚠ ${warn}${c.reset}`);
4554
+ } else if (hasWarnings) {
4555
+ console.log(` ${c.yellow}⚠${c.reset} ${c.bold}${name}${c.reset} ${c.dim}${skill.path}${c.reset}`);
4556
+ for (const warn of warnings) console.log(` ${c.yellow}⚠ ${warn}${c.reset}`);
4557
+ } else {
4558
+ console.log(` ${c.green}✔${c.reset} ${c.bold}${name}${c.reset} ${c.dim}${skill.path}${c.reset}`);
4559
+ }
4560
+
4561
+ // Show MCP references
4562
+ if (info.mcpServers && info.mcpServers.length > 0) {
4563
+ console.log(` ${c.dim}MCP servers: ${info.mcpServers.join(', ')}${c.reset}`);
4564
+ }
4565
+ if (info.allowedTools === null) {
4566
+ console.log(` ${c.yellow}⚠ no allowed-tools — unrestricted tool access${c.reset}`);
4567
+ }
4568
+ console.log();
4569
+ }
4570
+
4571
+ // Summary
4572
+ console.log(sectionHeader('Validation Summary'));
4573
+ console.log();
4574
+ if (totalErrors === 0 && totalWarnings === 0) {
4575
+ console.log(` ${c.green}✔ All ${skills.length} skills valid${c.reset}`);
4576
+ } else {
4577
+ if (totalErrors > 0) console.log(` ${c.red}✖ ${totalErrors} error${totalErrors !== 1 ? 's' : ''}${c.reset}`);
4578
+ if (totalWarnings > 0) console.log(` ${c.yellow}⚠ ${totalWarnings} warning${totalWarnings !== 1 ? 's' : ''}${c.reset}`);
4579
+ }
4580
+ console.log();
4581
+
4582
+ if (jsonMode) {
4583
+ console.log(JSON.stringify(skills.map(s => ({
4584
+ name: s.validation.info.name || s.dirName,
4585
+ path: s.path,
4586
+ source: s.source,
4587
+ errors: s.validation.errors,
4588
+ warnings: s.validation.warnings,
4589
+ mcpServers: s.validation.info.mcpServers,
4590
+ allowedTools: s.validation.info.allowedTools,
4591
+ })), null, 2));
4592
+ }
4593
+
4594
+ process.exitCode = totalErrors > 0 ? 1 : 0;
4595
+ return;
4596
+ }
4597
+
4598
+ // Validate specific file(s)
4599
+ for (const p of paths) {
4600
+ const resolved = path.resolve(p);
4601
+ if (!fs.existsSync(resolved)) {
4602
+ console.log(` ${c.red}✖ File not found: ${p}${c.reset}`);
4603
+ process.exitCode = 1;
4604
+ continue;
4605
+ }
4606
+
4607
+ const content = fs.readFileSync(resolved, 'utf8');
4608
+ const parsed = parseSkillFrontmatter(content);
4609
+ const { errors, warnings, info } = validateSkill(parsed);
4610
+ const name = info.name || path.basename(path.dirname(resolved));
4611
+
4612
+ console.log(` ${c.bold}${name}${c.reset} ${c.dim}${resolved}${c.reset}`);
4613
+ console.log();
4614
+
4615
+ if (errors.length === 0 && warnings.length === 0) {
4616
+ console.log(` ${c.green}✔ Valid skill format${c.reset}`);
4617
+ }
4618
+
4619
+ for (const err of errors) console.log(` ${c.red}✖ ${err}${c.reset}`);
4620
+ for (const warn of warnings) console.log(` ${c.yellow}⚠ ${warn}${c.reset}`);
4621
+
4622
+ if (info.name) console.log(` ${c.dim}name:${c.reset} ${info.name}`);
4623
+ if (info.description) console.log(` ${c.dim}description:${c.reset} ${info.description.slice(0, 80)}${info.description.length > 80 ? '...' : ''}`);
4624
+ if (info.allowedTools) console.log(` ${c.dim}allowed-tools:${c.reset} ${info.allowedTools.join(', ')}`);
4625
+ else if (info.allowedTools === null) console.log(` ${c.yellow}allowed-tools: none (unrestricted)${c.reset}`);
4626
+ if (info.mcpServers?.length > 0) console.log(` ${c.dim}MCP servers:${c.reset} ${info.mcpServers.join(', ')}`);
4627
+ if (info.bodyLines) console.log(` ${c.dim}body:${c.reset} ${info.bodyLines} lines`);
4628
+ console.log();
4629
+
4630
+ if (jsonMode) {
4631
+ console.log(JSON.stringify({ name: info.name, path: resolved, errors, warnings, info }, null, 2));
4632
+ }
4633
+
4634
+ process.exitCode = errors.length > 0 ? 1 : 0;
4635
+ }
4636
+ return;
4637
+ }
4638
+
3571
4639
  if (command === 'discover') {
3572
4640
  const scanFlag = targets.includes('--quick') || targets.includes('--scan') || targets.includes('-s');
3573
4641
  const auditFlag = targets.includes('--deep') || targets.includes('--audit') || targets.includes('-a');