agentaudit 3.12.0 → 3.12.2

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 (3) hide show
  1. package/cli.mjs +1234 -329
  2. package/index.mjs +1 -1
  3. package/package.json +1 -1
package/cli.mjs CHANGED
@@ -150,6 +150,36 @@ const USER_CRED_DIR = path.join(xdgConfig, 'agentaudit');
150
150
  const USER_CRED_FILE = path.join(USER_CRED_DIR, 'credentials.json');
151
151
  const SKILL_CRED_FILE = path.join(SKILL_DIR, 'config', 'credentials.json');
152
152
  const PROFILE_CACHE_FILE = path.join(USER_CRED_DIR, 'profile-cache.json');
153
+ const HISTORY_DIR = path.join(USER_CRED_DIR, 'history');
154
+
155
+ function saveHistory(report) {
156
+ try {
157
+ fs.mkdirSync(HISTORY_DIR, { recursive: true });
158
+ const slug = report.skill_slug || 'unknown';
159
+ const model = (report.audit_model || 'unknown').replace(/[^a-z0-9-]/gi, '-').slice(0, 30);
160
+ const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
161
+ const filename = `${ts}_${slug}_${model}.json`;
162
+ fs.writeFileSync(path.join(HISTORY_DIR, filename), JSON.stringify(report, null, 2));
163
+ } catch {}
164
+ }
165
+
166
+ function loadHistory(limit = 20) {
167
+ try {
168
+ if (!fs.existsSync(HISTORY_DIR)) return [];
169
+ const files = fs.readdirSync(HISTORY_DIR)
170
+ .filter(f => f.endsWith('.json'))
171
+ .sort()
172
+ .reverse()
173
+ .slice(0, limit);
174
+ return files.map(f => {
175
+ try {
176
+ const data = JSON.parse(fs.readFileSync(path.join(HISTORY_DIR, f), 'utf8'));
177
+ data._file = f;
178
+ return data;
179
+ } catch { return null; }
180
+ }).filter(Boolean);
181
+ } catch { return []; }
182
+ }
153
183
 
154
184
  function loadCredentials() {
155
185
  for (const f of [SKILL_CRED_FILE, USER_CRED_FILE]) {
@@ -222,6 +252,37 @@ function resolveProvider() {
222
252
  return LLM_PROVIDERS.find(p => process.env[p.key]) || null;
223
253
  }
224
254
 
255
+ function resolveModel(modelName) {
256
+ // model with '/' → OpenRouter
257
+ if (modelName.includes('/')) {
258
+ const p = LLM_PROVIDERS.find(p => p.provider === 'openrouter' && process.env[p.key]);
259
+ if (p) return { ...p, model: modelName };
260
+ return null;
261
+ }
262
+ // Known prefix → native provider
263
+ const prefixes = [
264
+ ['claude', 'anthropic'], ['gemini', 'google'], ['gpt', 'openai'],
265
+ ['deepseek', 'deepseek'], ['mistral', 'mistral'], ['grok', 'xai'], ['glm', 'zhipu'],
266
+ ];
267
+ for (const [prefix, prov] of prefixes) {
268
+ if (modelName.toLowerCase().startsWith(prefix)) {
269
+ const p = LLM_PROVIDERS.find(p => p.provider === prov && process.env[p.key]);
270
+ if (p) return { ...p, model: modelName };
271
+ }
272
+ }
273
+ // Check PROVIDER_MODELS for exact match
274
+ for (const [prov, models] of Object.entries(PROVIDER_MODELS)) {
275
+ if (models.some(m => m.value === modelName)) {
276
+ const p = LLM_PROVIDERS.find(p => p.provider === prov && process.env[p.key]);
277
+ if (p) return { ...p, model: modelName };
278
+ }
279
+ }
280
+ // Last resort: OpenRouter
281
+ const or = LLM_PROVIDERS.find(p => p.provider === 'openrouter' && process.env[p.key]);
282
+ if (or) return { ...or, model: modelName };
283
+ return null;
284
+ }
285
+
225
286
  function saveCredentials(data) {
226
287
  const json = JSON.stringify(data, null, 2);
227
288
  fs.mkdirSync(USER_CRED_DIR, { recursive: true });
@@ -643,15 +704,17 @@ function padLeft(str, len) {
643
704
 
644
705
  function drawBox(title, contentLines, width) {
645
706
  const inner = width - 4; // 2 for "│ " + 2 for " │"
707
+ const totalDash = inner + 2; // total horizontal line chars between corners
646
708
  const lines = [];
647
709
  const titleStr = title ? ` ${title} ` : '';
648
710
  const titleLen = visLen(titleStr);
649
- const topDash = BOX.h.repeat(Math.max(1, inner + 2 - titleLen));
650
- lines.push(` ${BOX.tl}${c.dim}─${c.reset}${c.bold}${titleStr}${c.reset}${c.dim}${topDash}${c.reset}${BOX.tr}`);
711
+ // Top: ╭─ Title ────────────╮ (1 dash before title + title + remaining dashes)
712
+ const topDash = BOX.h.repeat(Math.max(1, totalDash - 1 - titleLen));
713
+ lines.push(` ${BOX.tl}${c.dim}${BOX.h}${c.reset}${c.bold}${titleStr}${c.reset}${c.dim}${topDash}${c.reset}${BOX.tr}`);
651
714
  for (const line of contentLines) {
652
715
  lines.push(` ${BOX.v} ${padRight(line, inner + 1)}${BOX.v}`);
653
716
  }
654
- lines.push(` ${BOX.bl}${c.dim}${BOX.h.repeat(inner + 2)}${c.reset}${BOX.br}`);
717
+ lines.push(` ${BOX.bl}${c.dim}${BOX.h.repeat(totalDash)}${c.reset}${BOX.br}`);
655
718
  return lines;
656
719
  }
657
720
 
@@ -929,24 +992,38 @@ function detectPackageInfo(repoPath, files) {
929
992
  info.type = 'library';
930
993
  }
931
994
 
932
- // Extract MCP tools (look for tool definitions)
995
+ // Extract MCP tools only from files that reference MCP SDK
996
+ const mcpKeywords = ['modelcontextprotocol', 'FastMCP', 'mcp.server', 'mcp_server', '@mcp.tool', '@server.tool', '.tool(', 'ListTools', 'CallTool'];
997
+ const mcpFiles = files.filter(f => mcpKeywords.some(kw => f.content.includes(kw)));
998
+ // Fallback: if no MCP-specific files found, try entrypoint files
999
+ if (mcpFiles.length === 0) {
1000
+ const entryNames = ['index.js', 'index.ts', 'index.mjs', 'main.py', 'server.py', 'app.py', 'src/index.ts', 'src/main.ts', 'src/index.js'];
1001
+ for (const f of files) {
1002
+ if (entryNames.includes(f.path)) mcpFiles.push(f);
1003
+ }
1004
+ }
1005
+
933
1006
  const toolPatterns = [
934
- // JS/TS: name: 'tool_name' or "tool_name" in tool definitions
935
- /(?:name|tool_name)['":\s]+['"]([a-z_][a-z0-9_]*)['"]/gi,
936
- // Python: @mcp.tool() def func_name or Tool(name="...")
937
- /(?:@(?:mcp|server)\.tool\(\)[\s\S]*?def\s+([a-z_][a-z0-9_]*))|(?:Tool\s*\(\s*name\s*=\s*['"]([a-z_][a-z0-9_]*)['"])/gi,
938
- // Direct: tool names in ListTools handlers
939
- /['"]name['"]\s*:\s*['"]([a-z_][a-z0-9_]*)['"]/gi,
1007
+ // JS/TS MCP SDK: server.tool('name', ...) or .setTool('name', ...)
1008
+ /\.tool\s*\(\s*['"]([a-z_][a-z0-9_]*)['"]/gi,
1009
+ // Python: @mcp.tool() / @server.tool() followed by def name
1010
+ /@(?:mcp|server)\.tool\s*\(.*?\)[\s\S]*?def\s+([a-z_][a-z0-9_]*)/gi,
1011
+ // Python: Tool(name="xxx")
1012
+ /Tool\s*\(\s*name\s*=\s*['"]([a-z_][a-z0-9_]*)['"]/gi,
1013
+ // ListTools handler: { name: "tool_name", description: ... }
1014
+ /{\s*(?:['"]?)name(?:['"]?)\s*:\s*['"]([a-z_][a-z0-9_]*)['"]\s*,\s*(?:['"]?)description(?:['"]?)\s*:/gi,
940
1015
  ];
941
-
1016
+
1017
+ 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']);
1018
+
942
1019
  const toolSet = new Set();
943
- for (const file of files) {
1020
+ for (const file of mcpFiles) {
944
1021
  for (const pattern of toolPatterns) {
945
1022
  pattern.lastIndex = 0;
946
1023
  let m;
947
1024
  while ((m = pattern.exec(file.content)) !== null) {
948
1025
  const name = m[1] || m[2];
949
- if (name && name.length > 2 && name.length < 50 && !['type', 'name', 'string', 'object', 'number', 'boolean', 'array', 'required', 'description', 'default', 'null', 'true', 'false', 'none'].includes(name)) {
1026
+ if (name && name.length > 2 && name.length < 50 && !toolBlacklist.has(name)) {
950
1027
  toolSet.add(name);
951
1028
  }
952
1029
  }
@@ -1566,6 +1643,235 @@ function findMcpConfigs() {
1566
1643
  return found;
1567
1644
  }
1568
1645
 
1646
+ // ── Skill Discovery & Validation ─────────────────────────
1647
+
1648
+ /**
1649
+ * Parse YAML frontmatter from a SKILL.md file.
1650
+ * Returns { meta: {...}, body: string, errors: string[] }
1651
+ */
1652
+ function parseSkillFrontmatter(content) {
1653
+ const errors = [];
1654
+ const lines = content.split('\n');
1655
+
1656
+ // Must start with ---
1657
+ if (lines[0].trim() !== '---') {
1658
+ return { meta: null, body: content, errors: ['Missing YAML frontmatter (file must start with ---)'] };
1659
+ }
1660
+
1661
+ // Find closing ---
1662
+ let endIdx = -1;
1663
+ for (let i = 1; i < lines.length; i++) {
1664
+ if (lines[i].trim() === '---') { endIdx = i; break; }
1665
+ }
1666
+ if (endIdx === -1) {
1667
+ return { meta: null, body: content, errors: ['Unclosed frontmatter (missing closing ---)'] };
1668
+ }
1669
+
1670
+ // Parse YAML-like key: value pairs
1671
+ const meta = {};
1672
+ const yamlLines = lines.slice(1, endIdx);
1673
+ for (let i = 0; i < yamlLines.length; i++) {
1674
+ const line = yamlLines[i];
1675
+ if (line.trim() === '' || line.trim().startsWith('#')) continue;
1676
+
1677
+ // Check for tabs
1678
+ if (line.includes('\t')) {
1679
+ errors.push(`Line ${i + 2}: Tab character found (use spaces)`);
1680
+ }
1681
+
1682
+ const match = line.match(/^([a-z][a-z0-9_-]*):\s*(.*)/i);
1683
+ if (!match) {
1684
+ // Could be a continuation line (YAML multiline)
1685
+ continue;
1686
+ }
1687
+ const key = match[1].toLowerCase();
1688
+ let value = match[2].trim();
1689
+
1690
+ // Handle YAML lists on next lines
1691
+ if (value === '' && i + 1 < yamlLines.length && yamlLines[i + 1].match(/^\s+-\s/)) {
1692
+ const items = [];
1693
+ let j = i + 1;
1694
+ while (j < yamlLines.length && yamlLines[j].match(/^\s+-\s/)) {
1695
+ items.push(yamlLines[j].replace(/^\s+-\s*/, '').trim());
1696
+ j++;
1697
+ }
1698
+ value = items;
1699
+ }
1700
+
1701
+ // Strip surrounding quotes
1702
+ if (typeof value === 'string' && ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))) {
1703
+ value = value.slice(1, -1);
1704
+ }
1705
+
1706
+ meta[key] = value;
1707
+ }
1708
+
1709
+ const body = lines.slice(endIdx + 1).join('\n').trim();
1710
+ return { meta, body, errors };
1711
+ }
1712
+
1713
+ /**
1714
+ * Validate a parsed skill against the Claude Code SKILL.md spec.
1715
+ * Returns { errors: [...], warnings: [...], info: {...} }
1716
+ */
1717
+ function validateSkill(parsed) {
1718
+ const { meta, body, errors: parseErrors } = parsed;
1719
+ const errors = [...parseErrors];
1720
+ const warnings = [];
1721
+ const info = {};
1722
+
1723
+ if (!meta) return { errors, warnings, info };
1724
+
1725
+ // Known fields
1726
+ const knownFields = new Set([
1727
+ 'name', 'description', 'allowed-tools', 'user-invocable', 'user-invokable',
1728
+ 'disable-model-invocation', 'license', 'metadata', 'argument-hint',
1729
+ 'compatibility', 'version', 'author',
1730
+ ]);
1731
+
1732
+ // Check for unknown fields
1733
+ for (const key of Object.keys(meta)) {
1734
+ if (!knownFields.has(key)) {
1735
+ warnings.push(`Unknown frontmatter field: "${key}"`);
1736
+ }
1737
+ }
1738
+
1739
+ // Required: name
1740
+ if (!meta.name) {
1741
+ errors.push('Missing required field: name');
1742
+ } else {
1743
+ info.name = meta.name;
1744
+ if (meta.name.length > 64) errors.push(`name exceeds 64 chars (${meta.name.length})`);
1745
+ if (/<[^>]+>/.test(meta.name)) errors.push('name contains XML/HTML tags');
1746
+ }
1747
+
1748
+ // Required: description
1749
+ if (!meta.description) {
1750
+ errors.push('Missing required field: description');
1751
+ } else {
1752
+ info.description = typeof meta.description === 'string' ? meta.description.slice(0, 120) : String(meta.description).slice(0, 120);
1753
+ if (typeof meta.description === 'string' && meta.description.length > 1024) {
1754
+ warnings.push(`description is ${meta.description.length} chars (recommended max: 1024)`);
1755
+ }
1756
+ if (/<[^>]+>/.test(meta.description)) warnings.push('description contains XML/HTML tags');
1757
+ }
1758
+
1759
+ // Security: allowed-tools
1760
+ if (!meta['allowed-tools']) {
1761
+ warnings.push('No allowed-tools set — skill has access to ALL tools (security risk)');
1762
+ info.allowedTools = null;
1763
+ } else {
1764
+ const tools = typeof meta['allowed-tools'] === 'string'
1765
+ ? meta['allowed-tools'].split(',').map(t => t.trim()).filter(Boolean)
1766
+ : Array.isArray(meta['allowed-tools']) ? meta['allowed-tools'] : [];
1767
+ info.allowedTools = tools;
1768
+ // Check for wildcard/dangerous patterns
1769
+ if (tools.some(t => t === '*' || t === 'Bash' || t === 'Bash(*)')) {
1770
+ warnings.push('allowed-tools includes unrestricted Bash access');
1771
+ }
1772
+ }
1773
+
1774
+ // Boolean fields
1775
+ for (const boolField of ['user-invocable', 'user-invokable', 'disable-model-invocation']) {
1776
+ if (meta[boolField] !== undefined) {
1777
+ const val = String(meta[boolField]).toLowerCase();
1778
+ if (!['true', 'false'].includes(val)) {
1779
+ errors.push(`${boolField} must be true or false (got: "${meta[boolField]}")`);
1780
+ }
1781
+ }
1782
+ }
1783
+
1784
+ // Typo detection
1785
+ if (meta['user-invokable'] && !meta['user-invocable']) {
1786
+ warnings.push('Using "user-invokable" (known typo variant) — both spellings work');
1787
+ }
1788
+
1789
+ // Body checks
1790
+ if (body) {
1791
+ const bodyLines = body.split('\n').length;
1792
+ info.bodyLines = bodyLines;
1793
+ if (bodyLines > 500) warnings.push(`Body is ${bodyLines} lines (recommended max: 500)`);
1794
+
1795
+ // Check for potential prompt injection patterns in body
1796
+ const injectionPatterns = [
1797
+ { pattern: /ignore\s+(all\s+)?previous\s+(instructions|rules)/i, label: 'Prompt injection pattern' },
1798
+ { pattern: /<IMPORTANT>/i, label: 'Suspicious <IMPORTANT> tag' },
1799
+ { pattern: /system\s*:\s*you\s+are/i, label: 'System prompt override attempt' },
1800
+ ];
1801
+ for (const { pattern, label } of injectionPatterns) {
1802
+ if (pattern.test(body)) {
1803
+ warnings.push(`${label} detected in body`);
1804
+ }
1805
+ }
1806
+ }
1807
+
1808
+ // Extract MCP tool references
1809
+ const mcpRefs = [];
1810
+ const mcpPattern = /mcp__([a-z0-9_-]+)__([a-z0-9_]+)/gi;
1811
+ const fullText = (meta.description || '') + ' ' + (typeof meta['allowed-tools'] === 'string' ? meta['allowed-tools'] : '') + ' ' + (body || '');
1812
+ let mcpMatch;
1813
+ while ((mcpMatch = mcpPattern.exec(fullText)) !== null) {
1814
+ mcpRefs.push({ server: mcpMatch[1], tool: mcpMatch[2] });
1815
+ }
1816
+ info.mcpRefs = mcpRefs;
1817
+
1818
+ // Deduplicate MCP server names
1819
+ info.mcpServers = [...new Set(mcpRefs.map(r => r.server))];
1820
+
1821
+ return { errors, warnings, info };
1822
+ }
1823
+
1824
+ /**
1825
+ * Find all SKILL.md files in known skill directories.
1826
+ */
1827
+ function findSkills() {
1828
+ const home = process.env.HOME || process.env.USERPROFILE || '';
1829
+ const cwd = process.cwd();
1830
+ const found = [];
1831
+
1832
+ const skillDirs = [
1833
+ // Global skill dirs
1834
+ { name: 'Claude Code (global)', base: path.join(home, '.claude', 'skills') },
1835
+ { name: 'Cursor (global)', base: path.join(home, '.cursor', 'skills') },
1836
+ { name: 'Antigravity (global)', base: path.join(home, '.agent', 'skills') },
1837
+ // Project-level skill dirs
1838
+ { name: 'Claude Code (project)', base: path.join(cwd, '.claude', 'skills') },
1839
+ { name: 'Cursor (project)', base: path.join(cwd, '.cursor', 'skills') },
1840
+ { name: 'GitHub Skills (project)', base: path.join(cwd, '.github', 'skills') },
1841
+ { name: 'Antigravity (project)', base: path.join(cwd, '.agent', 'skills') },
1842
+ ];
1843
+
1844
+ for (const dir of skillDirs) {
1845
+ if (!fs.existsSync(dir.base)) continue;
1846
+ try {
1847
+ const entries = fs.readdirSync(dir.base, { withFileTypes: true });
1848
+ for (const entry of entries) {
1849
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
1850
+ const skillPath = path.join(dir.base, entry.name, 'SKILL.md');
1851
+ if (!fs.existsSync(skillPath)) continue;
1852
+ try {
1853
+ const content = fs.readFileSync(skillPath, 'utf8');
1854
+ const parsed = parseSkillFrontmatter(content);
1855
+ const validation = validateSkill(parsed);
1856
+ found.push({
1857
+ source: dir.name,
1858
+ dir: path.join(dir.base, entry.name),
1859
+ path: skillPath,
1860
+ dirName: entry.name,
1861
+ parsed,
1862
+ validation,
1863
+ isSymlink: entry.isSymbolicLink(),
1864
+ });
1865
+ } catch {}
1866
+ }
1867
+ } catch {}
1868
+ }
1869
+
1870
+ return found;
1871
+ }
1872
+
1873
+ // ── Server Config Extraction ─────────────────────────────
1874
+
1569
1875
  function extractServersFromConfig(config) {
1570
1876
  // Handle both { mcpServers: {...} } and { servers: {...} } formats
1571
1877
  const servers = config.mcpServers || config.servers || {};
@@ -1627,7 +1933,10 @@ function extractServersFromConfig(config) {
1627
1933
  }
1628
1934
  } catch {}
1629
1935
  }
1630
-
1936
+
1937
+ // Resolve local installation directory
1938
+ info.localDir = resolveLocalDir(info);
1939
+
1631
1940
  result.push(info);
1632
1941
  }
1633
1942
  return result;
@@ -1640,6 +1949,196 @@ function serverSlug(server) {
1640
1949
  return server.name.toLowerCase().replace(/[^a-z0-9-]/gi, '-');
1641
1950
  }
1642
1951
 
1952
+ /**
1953
+ * Resolve the local installation directory for a discovered MCP server.
1954
+ * Returns an absolute path or null if not found.
1955
+ */
1956
+ function resolveLocalDir(server) {
1957
+ const home = os.homedir();
1958
+ const isWin = process.platform === 'win32';
1959
+
1960
+ // node /path/to/file → walk up to project root (package.json or .git)
1961
+ const allArgs = [server.command, ...server.args].filter(Boolean).join(' ');
1962
+ const nodePathMatch = allArgs.match(/node\s+["']?([^"'\s]+)/);
1963
+ if (nodePathMatch) {
1964
+ let dir = path.dirname(path.resolve(nodePathMatch[1]));
1965
+ for (let i = 0; i < 5; i++) {
1966
+ if (fs.existsSync(path.join(dir, 'package.json')) || fs.existsSync(path.join(dir, '.git'))) return dir;
1967
+ const parent = path.dirname(dir);
1968
+ if (parent === dir) break;
1969
+ dir = parent;
1970
+ }
1971
+ // Fallback: use the script's directory
1972
+ return path.dirname(path.resolve(nodePathMatch[1]));
1973
+ }
1974
+
1975
+ // python /path/to/file → same approach
1976
+ const pyPathMatch = allArgs.match(/python[3]?\s+["']?([^"'\s]+\.py)/);
1977
+ if (pyPathMatch) {
1978
+ let dir = path.dirname(path.resolve(pyPathMatch[1]));
1979
+ for (let i = 0; i < 5; i++) {
1980
+ if (fs.existsSync(path.join(dir, 'pyproject.toml')) || fs.existsSync(path.join(dir, 'setup.py')) || fs.existsSync(path.join(dir, '.git'))) return dir;
1981
+ const parent = path.dirname(dir);
1982
+ if (parent === dir) break;
1983
+ dir = parent;
1984
+ }
1985
+ return path.dirname(path.resolve(pyPathMatch[1]));
1986
+ }
1987
+
1988
+ // npm/npx package → check global node_modules
1989
+ if (server.npmPackage) {
1990
+ const pkgName = server.npmPackage.replace(/@latest$/, '').replace(/@[\d.]+$/, '');
1991
+ const candidates = [];
1992
+ // Global npm
1993
+ try {
1994
+ const globalRoot = execFileSync('npm', ['root', '-g'], { timeout: 5000, stdio: 'pipe' }).toString().trim();
1995
+ candidates.push(path.join(globalRoot, pkgName));
1996
+ } catch {}
1997
+ // Local node_modules (cwd)
1998
+ candidates.push(path.join(process.cwd(), 'node_modules', pkgName));
1999
+ for (const dir of candidates) {
2000
+ if (fs.existsSync(dir)) return dir;
2001
+ }
2002
+ }
2003
+
2004
+ // uvx/pip package → check uv tools cache and site-packages
2005
+ if (server.pyPackage) {
2006
+ const pkgName = server.pyPackage.replace(/@latest$/, '').replace(/@[\d.]+$/, '');
2007
+ const candidates = [];
2008
+ if (isWin) {
2009
+ const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
2010
+ candidates.push(path.join(localAppData, 'uv', 'tools', pkgName));
2011
+ } else {
2012
+ candidates.push(path.join(home, '.local', 'share', 'uv', 'tools', pkgName));
2013
+ }
2014
+ // Also try pip show
2015
+ try {
2016
+ const pipOut = execFileSync('pip', ['show', pkgName, '-f'], { timeout: 5000, stdio: 'pipe' }).toString();
2017
+ const locMatch = pipOut.match(/Location:\s*(.+)/);
2018
+ if (locMatch) {
2019
+ const normalized = pkgName.replace(/-/g, '_');
2020
+ const pkgDir = path.join(locMatch[1].trim(), normalized);
2021
+ if (fs.existsSync(pkgDir)) candidates.push(pkgDir);
2022
+ }
2023
+ } catch {}
2024
+ for (const dir of candidates) {
2025
+ if (fs.existsSync(dir)) return dir;
2026
+ }
2027
+ }
2028
+
2029
+ return null;
2030
+ }
2031
+
2032
+ /**
2033
+ * Scan a local directory (like scanRepo but without cloning).
2034
+ */
2035
+ async function scanLocalDir(localDir, serverName) {
2036
+ const start = Date.now();
2037
+ const slug = serverName.toLowerCase().replace(/[^a-z0-9-]/gi, '-');
2038
+
2039
+ if (!jsonMode) process.stdout.write(`${icons.scan} Scanning ${c.bold}${slug}${c.reset} ${c.dim}(local)${c.reset} ${c.dim}...${c.reset}`);
2040
+
2041
+ // Collect files from local dir
2042
+ const files = collectFiles(localDir);
2043
+ if (files.length === 0) {
2044
+ if (!jsonMode) process.stdout.write(` ${c.yellow}no scannable files found${c.reset}\n`);
2045
+ return null;
2046
+ }
2047
+
2048
+ // Detect info
2049
+ const info = detectPackageInfo(localDir, files);
2050
+
2051
+ // Quick checks
2052
+ const findings = quickChecks(files);
2053
+
2054
+ // Registry lookup
2055
+ const registryData = await checkRegistry(slug);
2056
+
2057
+ const duration = elapsed(start);
2058
+
2059
+ if (!jsonMode) {
2060
+ process.stdout.write('\r\x1b[K');
2061
+ printScanResult(`local://${localDir}`, info, files, findings, registryData, duration);
2062
+ }
2063
+
2064
+ return { slug, url: `local://${localDir}`, info, files: files.length, findings, registryData, duration };
2065
+ }
2066
+
2067
+ /**
2068
+ * Download package source from PyPI or npm to a temp dir and scan it.
2069
+ * Used as last resort when git clone fails and no local install exists.
2070
+ */
2071
+ async function downloadAndScan(server) {
2072
+ const start = Date.now();
2073
+ const slug = server.name.toLowerCase().replace(/[^a-z0-9-]/gi, '-');
2074
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentaudit-pkg-'));
2075
+
2076
+ try {
2077
+ if (server.pyPackage) {
2078
+ const pkgName = server.pyPackage.replace(/@latest$/, '').replace(/@[\d.]+$/, '');
2079
+ if (!jsonMode) process.stdout.write(`${icons.scan} Downloading ${c.bold}${pkgName}${c.reset} ${c.dim}from PyPI...${c.reset}`);
2080
+ // Download sdist/wheel without installing
2081
+ execFileSync('pip', ['download', '--no-deps', '-d', tmpDir, pkgName], { timeout: 30000, stdio: 'pipe' });
2082
+ // Extract any .tar.gz or .whl (zip) files
2083
+ const downloaded = fs.readdirSync(tmpDir);
2084
+ const extractDir = path.join(tmpDir, 'src');
2085
+ fs.mkdirSync(extractDir, { recursive: true });
2086
+ for (const f of downloaded) {
2087
+ const fp = path.join(tmpDir, f);
2088
+ if (f.endsWith('.whl') || f.endsWith('.zip')) {
2089
+ execFileSync('python', ['-m', 'zipfile', '-e', fp, extractDir], { timeout: 10000, stdio: 'pipe' });
2090
+ } else if (f.endsWith('.tar.gz') || f.endsWith('.tgz')) {
2091
+ execFileSync('tar', ['xzf', fp, '-C', extractDir], { timeout: 10000, stdio: 'pipe' });
2092
+ }
2093
+ }
2094
+ const files = collectFiles(extractDir);
2095
+ if (files.length === 0) return null;
2096
+ const info = detectPackageInfo(extractDir, files);
2097
+ const findings = quickChecks(files);
2098
+ const registryData = await checkRegistry(slug);
2099
+ const duration = elapsed(start);
2100
+ if (!jsonMode) {
2101
+ process.stdout.write('\r\x1b[K');
2102
+ printScanResult(`pypi://${pkgName}`, info, files, findings, registryData, duration);
2103
+ }
2104
+ return { slug, url: `pypi://${pkgName}`, info, files: files.length, findings, registryData, duration };
2105
+ }
2106
+
2107
+ if (server.npmPackage) {
2108
+ const pkgName = server.npmPackage.replace(/@latest$/, '').replace(/@[\d.]+$/, '');
2109
+ if (!jsonMode) process.stdout.write(`${icons.scan} Downloading ${c.bold}${pkgName}${c.reset} ${c.dim}from npm...${c.reset}`);
2110
+ // npm pack downloads tarball without installing
2111
+ execFileSync('npm', ['pack', pkgName, '--pack-destination', tmpDir], { timeout: 30000, stdio: 'pipe' });
2112
+ const tarballs = fs.readdirSync(tmpDir).filter(f => f.endsWith('.tgz'));
2113
+ if (tarballs.length === 0) return null;
2114
+ const extractDir = path.join(tmpDir, 'src');
2115
+ fs.mkdirSync(extractDir, { recursive: true });
2116
+ execFileSync('tar', ['xzf', path.join(tmpDir, tarballs[0]), '-C', extractDir], { timeout: 10000, stdio: 'pipe' });
2117
+ const files = collectFiles(extractDir);
2118
+ if (files.length === 0) return null;
2119
+ const info = detectPackageInfo(extractDir, files);
2120
+ const findings = quickChecks(files);
2121
+ const registryData = await checkRegistry(slug);
2122
+ const duration = elapsed(start);
2123
+ if (!jsonMode) {
2124
+ process.stdout.write('\r\x1b[K');
2125
+ printScanResult(`npm://${pkgName}`, info, files, findings, registryData, duration);
2126
+ }
2127
+ return { slug, url: `npm://${pkgName}`, info, files: files.length, findings, registryData, duration };
2128
+ }
2129
+ } catch (err) {
2130
+ if (!jsonMode) {
2131
+ process.stdout.write('\r\x1b[K');
2132
+ process.stdout.write(`${icons.scan} ${c.bold}${slug}${c.reset} ${c.yellow}download failed${c.reset}\n`);
2133
+ const msg = err.stderr?.toString().trim().split('\n')[0] || err.message?.split('\n')[0] || '';
2134
+ if (msg) console.log(` ${c.dim}${msg}${c.reset}`);
2135
+ }
2136
+ } finally {
2137
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
2138
+ }
2139
+ return null;
2140
+ }
2141
+
1643
2142
  async function searchGitHub(query) {
1644
2143
  try {
1645
2144
  const res = await fetch(`https://api.github.com/search/repositories?q=${encodeURIComponent(query)}&per_page=1`, {
@@ -1822,14 +2321,17 @@ async function discoverCommand(options = {}) {
1822
2321
  const hasOfficial = regData.has_official_audit;
1823
2322
  console.log(`${branch} ${c.bold}${server.name}${c.reset} ${sourceLabel}`);
1824
2323
  console.log(`${pipe} ${riskBadge(riskScore)} ${hasOfficial ? `${c.green}✔ official${c.reset} ` : ''}${c.dim}${REGISTRY_URL}/packages/${slug}${c.reset}`);
1825
- if (resolvedUrl) allServersWithUrls.push({ name: server.name, sourceUrl: resolvedUrl, hasAudit: true, regData });
2324
+ 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 });
1826
2325
  } else {
1827
2326
  unauditedServers++;
1828
2327
  console.log(`${branch} ${c.bold}${server.name}${c.reset} ${sourceLabel}`);
1829
2328
  if (resolvedUrl) {
1830
2329
  console.log(`${pipe} ${c.yellow}⚠ not audited${c.reset} ${c.dim}Run: ${c.cyan}agentaudit audit ${resolvedUrl}${c.reset}`);
1831
2330
  unauditedWithUrls.push({ name: server.name, sourceUrl: resolvedUrl });
1832
- allServersWithUrls.push({ name: server.name, sourceUrl: resolvedUrl, hasAudit: false });
2331
+ allServersWithUrls.push({ name: server.name, sourceUrl: resolvedUrl, localDir: server.localDir, pyPackage: server.pyPackage, npmPackage: server.npmPackage, hasAudit: false });
2332
+ } else if (server.localDir || server.pyPackage || server.npmPackage) {
2333
+ console.log(`${pipe} ${c.yellow}⚠ not audited${c.reset} ${c.dim}${server.localDir ? 'local install found' : 'package registry available'} — will scan${c.reset}`);
2334
+ allServersWithUrls.push({ name: server.name, sourceUrl: null, localDir: server.localDir, pyPackage: server.pyPackage, npmPackage: server.npmPackage, hasAudit: false });
1833
2335
  } else {
1834
2336
  console.log(`${pipe} ${c.yellow}⚠ not audited${c.reset} ${c.dim}Source URL unknown — check the package's GitHub/npm page${c.reset}`);
1835
2337
  }
@@ -1854,29 +2356,115 @@ async function discoverCommand(options = {}) {
1854
2356
  }
1855
2357
  console.log();
1856
2358
 
1857
- // --scan: automatically scan all servers with resolved source URLs (git-cloneable only)
2359
+ // ── Skill Discovery ──────────────────────────────────
2360
+ const skills = findSkills();
2361
+ if (skills.length > 0) {
2362
+ console.log(sectionHeader(`Skills — ${skills.length} found`));
2363
+ console.log();
2364
+
2365
+ // Group by source
2366
+ const bySource = {};
2367
+ for (const skill of skills) {
2368
+ (bySource[skill.source] || (bySource[skill.source] = [])).push(skill);
2369
+ }
2370
+
2371
+ for (const [source, sourceSkills] of Object.entries(bySource)) {
2372
+ console.log(`${icons.bullet} ${c.bold}${source}${c.reset}`);
2373
+ console.log();
2374
+
2375
+ for (let i = 0; i < sourceSkills.length; i++) {
2376
+ const skill = sourceSkills[i];
2377
+ const isLast = i === sourceSkills.length - 1;
2378
+ const branch = isLast ? icons.treeLast : icons.tree;
2379
+ const pipe = isLast ? ' ' : `${icons.pipe} `;
2380
+ const { errors, warnings, info } = skill.validation;
2381
+ const name = info.name || skill.dirName;
2382
+ const hasErrors = errors.length > 0;
2383
+ const hasWarnings = warnings.length > 0;
2384
+
2385
+ // Status indicator
2386
+ let status;
2387
+ if (hasErrors) status = `${c.red}✖ ${errors.length} error${errors.length !== 1 ? 's' : ''}${c.reset}`;
2388
+ else if (hasWarnings) status = `${c.yellow}⚠ ${warnings.length} warning${warnings.length !== 1 ? 's' : ''}${c.reset}`;
2389
+ else status = `${c.green}✔ valid${c.reset}`;
2390
+
2391
+ console.log(`${branch} ${c.bold}${name}${c.reset} ${status}`);
2392
+
2393
+ // Description (truncated)
2394
+ if (info.description) {
2395
+ const desc = info.description.length > 70 ? info.description.slice(0, 67) + '...' : info.description;
2396
+ console.log(`${pipe} ${c.dim}${desc}${c.reset}`);
2397
+ }
2398
+
2399
+ // MCP tool references
2400
+ if (info.mcpServers && info.mcpServers.length > 0) {
2401
+ const serverList = info.mcpServers.map(s => `${c.cyan}${s}${c.reset}`).join(', ');
2402
+ console.log(`${pipe} ${c.dim}uses MCP:${c.reset} ${serverList}`);
2403
+ }
2404
+
2405
+ // Allowed tools summary
2406
+ if (info.allowedTools === null) {
2407
+ console.log(`${pipe} ${c.yellow}⚠ no allowed-tools — unrestricted access${c.reset}`);
2408
+ } else if (info.allowedTools && info.allowedTools.length > 0) {
2409
+ const toolCount = info.allowedTools.length;
2410
+ console.log(`${pipe} ${c.dim}${toolCount} allowed tool${toolCount !== 1 ? 's' : ''}${c.reset}`);
2411
+ }
2412
+
2413
+ // Show errors/warnings inline
2414
+ if (hasErrors) {
2415
+ for (const err of errors.slice(0, 3)) {
2416
+ console.log(`${pipe} ${c.red} ✖ ${err}${c.reset}`);
2417
+ }
2418
+ }
2419
+ if (hasWarnings && !hasErrors) {
2420
+ for (const warn of warnings.slice(0, 2)) {
2421
+ console.log(`${pipe} ${c.yellow} ⚠ ${warn}${c.reset}`);
2422
+ }
2423
+ }
2424
+ }
2425
+ console.log();
2426
+ }
2427
+ }
2428
+
2429
+ // --scan: automatically scan all servers (git clone + local fallback)
1858
2430
  if (autoScan) {
1859
2431
  const isCloneable = (url) => /^https?:\/\/(github\.com|gitlab\.com|bitbucket\.org)\//i.test(url);
1860
- const scanTargets = allServersWithUrls.filter(s => s.sourceUrl && isCloneable(s.sourceUrl));
1861
- // Deduplicate by sourceUrl
2432
+ // Include servers that are cloneable OR have a local dir OR a known package
2433
+ const scanTargets = allServersWithUrls.filter(s =>
2434
+ (s.sourceUrl && isCloneable(s.sourceUrl)) || s.localDir || s.pyPackage || s.npmPackage
2435
+ );
2436
+ // Deduplicate by sourceUrl or localDir
1862
2437
  const seen = new Set();
1863
2438
  const dedupedTargets = scanTargets.filter(s => {
1864
- if (seen.has(s.sourceUrl)) return false;
1865
- seen.add(s.sourceUrl);
2439
+ const key = (s.sourceUrl && isCloneable(s.sourceUrl)) ? s.sourceUrl : s.localDir;
2440
+ if (!key || seen.has(key)) return false;
2441
+ seen.add(key);
1866
2442
  return true;
1867
2443
  });
1868
- const skipped = allServersWithUrls.filter(s => s.sourceUrl && !isCloneable(s.sourceUrl));
2444
+ const skippedCount = allServersWithUrls.length - scanTargets.length;
1869
2445
  if (dedupedTargets.length > 0) {
1870
2446
  console.log(sectionHeader(`Auto-scanning ${dedupedTargets.length} server${dedupedTargets.length !== 1 ? 's' : ''}`));
1871
2447
  console.log(` ${c.bold}${icons.scan} Starting scans...${c.reset}`);
1872
- if (skipped.length > 0) {
1873
- console.log(` ${c.dim}(${skipped.length} skipped — no cloneable source URL)${c.reset}`);
2448
+ if (skippedCount > 0) {
2449
+ console.log(` ${c.dim}(${skippedCount} skipped — remote-only, no local source)${c.reset}`);
1874
2450
  }
1875
2451
  console.log();
1876
-
2452
+
1877
2453
  const scanResults = [];
1878
2454
  for (const target of dedupedTargets) {
1879
- const result = await scanRepo(target.sourceUrl);
2455
+ let result = null;
2456
+ // Try git clone first if URL is cloneable
2457
+ if (target.sourceUrl && isCloneable(target.sourceUrl)) {
2458
+ result = await scanRepo(target.sourceUrl);
2459
+ }
2460
+ // Fallback 1: scan local installation
2461
+ if (!result && target.localDir) {
2462
+ result = await scanLocalDir(target.localDir, target.name);
2463
+ }
2464
+ // Fallback 2: download from PyPI/npm and scan
2465
+ if (!result && (target.pyPackage || target.npmPackage)) {
2466
+ result = await downloadAndScan(target);
2467
+ }
1880
2468
  if (result) scanResults.push({ ...result, serverName: target.name });
1881
2469
  }
1882
2470
 
@@ -1969,7 +2557,7 @@ async function discoverCommand(options = {}) {
1969
2557
  }
1970
2558
 
1971
2559
  if (!autoScan && !interactiveAudit && !jsonMode) {
1972
- 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}`);
2560
+ console.log(` ${c.dim}Run ${c.cyan}agentaudit discover --quick${c.dim} to auto-scan all servers${c.reset}`);
1973
2561
  console.log();
1974
2562
  }
1975
2563
  }
@@ -1982,6 +2570,91 @@ function loadAuditPrompt() {
1982
2570
  return null;
1983
2571
  }
1984
2572
 
2573
+ async function callLlm(llmConfig, systemPrompt, userMessage) {
2574
+ const apiKey = process.env[llmConfig.key];
2575
+ if (!apiKey) return { error: `Missing API key: ${llmConfig.key}` };
2576
+ const start = Date.now();
2577
+ let _text = '';
2578
+ try {
2579
+ let data;
2580
+ if (llmConfig.type === 'anthropic') {
2581
+ const res = await fetch(llmConfig.url, {
2582
+ method: 'POST',
2583
+ headers: { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' },
2584
+ body: JSON.stringify({ model: llmConfig.model, max_tokens: 8192, system: systemPrompt, messages: [{ role: 'user', content: userMessage }] }),
2585
+ signal: AbortSignal.timeout(120_000),
2586
+ });
2587
+ data = await res.json();
2588
+ if (data.error) {
2589
+ const friendly = formatApiError(data.error, llmConfig.provider, res.status);
2590
+ return { error: friendly?.text || data.error.message || JSON.stringify(data.error), hint: friendly?.hint, duration: Date.now() - start };
2591
+ }
2592
+ _text = data.content?.[0]?.text || '';
2593
+ const report = extractJSON(_text);
2594
+ if (report) {
2595
+ report.audit_model = data.model || llmConfig.model;
2596
+ report.audit_provider = llmConfig.provider;
2597
+ if (data.id) report.provider_msg_id = data.id;
2598
+ if (data.usage) { report.input_tokens = data.usage.input_tokens; report.output_tokens = data.usage.output_tokens; }
2599
+ }
2600
+ return { report, text: _text, duration: Date.now() - start };
2601
+ } else if (llmConfig.type === 'gemini') {
2602
+ const res = await fetch(`${llmConfig.url}/${llmConfig.model}:generateContent?key=${apiKey}`, {
2603
+ method: 'POST',
2604
+ headers: { 'Content-Type': 'application/json' },
2605
+ body: JSON.stringify({
2606
+ systemInstruction: { parts: [{ text: systemPrompt }] },
2607
+ contents: [{ role: 'user', parts: [{ text: userMessage }] }],
2608
+ generationConfig: { maxOutputTokens: 8192 },
2609
+ }),
2610
+ signal: AbortSignal.timeout(120_000),
2611
+ });
2612
+ data = await res.json();
2613
+ if (data.error) {
2614
+ const friendly = formatApiError(data.error, llmConfig.provider, res.status);
2615
+ return { error: friendly?.text || data.error.message || JSON.stringify(data.error), hint: friendly?.hint, duration: Date.now() - start };
2616
+ }
2617
+ _text = data.candidates?.[0]?.content?.parts?.[0]?.text || '';
2618
+ const report = extractJSON(_text);
2619
+ if (report) {
2620
+ report.audit_model = data.modelVersion || llmConfig.model;
2621
+ report.audit_provider = llmConfig.provider;
2622
+ if (data.usageMetadata) { report.input_tokens = data.usageMetadata.promptTokenCount; report.output_tokens = data.usageMetadata.candidatesTokenCount; }
2623
+ }
2624
+ return { report, text: _text, duration: Date.now() - start };
2625
+ } else {
2626
+ const headers = { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' };
2627
+ if (llmConfig.provider === 'openrouter') { headers['HTTP-Referer'] = 'https://agentaudit.dev'; headers['X-Title'] = 'AgentAudit CLI'; }
2628
+ const res = await fetch(llmConfig.url, {
2629
+ method: 'POST',
2630
+ headers,
2631
+ body: JSON.stringify({ model: llmConfig.model, max_tokens: 8192, messages: [{ role: 'system', content: systemPrompt }, { role: 'user', content: userMessage }] }),
2632
+ signal: AbortSignal.timeout(120_000),
2633
+ });
2634
+ data = await res.json();
2635
+ if (data.error) {
2636
+ const friendly = formatApiError(data.error, llmConfig.provider, res.status);
2637
+ return { error: friendly?.text || data.error.message || JSON.stringify(data.error), hint: friendly?.hint, duration: Date.now() - start };
2638
+ }
2639
+ _text = data.choices?.[0]?.message?.content || '';
2640
+ const report = extractJSON(_text);
2641
+ if (report) {
2642
+ report.audit_model = data.model || llmConfig.model;
2643
+ report.audit_provider = llmConfig.provider;
2644
+ if (data.id) report.provider_msg_id = data.id;
2645
+ if (data.system_fingerprint) report.provider_fingerprint = data.system_fingerprint;
2646
+ if (data.usage) { report.input_tokens = data.usage.prompt_tokens; report.output_tokens = data.usage.completion_tokens; }
2647
+ }
2648
+ return { report, text: _text, duration: Date.now() - start };
2649
+ }
2650
+ } catch (err) {
2651
+ const dur = Date.now() - start;
2652
+ if (err.name === 'TimeoutError' || err.message?.includes('timeout')) return { error: 'Request timed out (120s)', hint: 'Try again or use a faster model', duration: dur };
2653
+ if (err.code === 'ENOTFOUND' || err.code === 'ECONNREFUSED' || err.message?.includes('fetch failed')) return { error: `Network error: could not reach ${llmConfig.provider}`, hint: 'Check your internet connection', duration: dur };
2654
+ return { error: err.message, duration: dur };
2655
+ }
2656
+ }
2657
+
1985
2658
  async function auditRepo(url) {
1986
2659
  const start = Date.now();
1987
2660
  const slug = slugFromUrl(url);
@@ -2020,72 +2693,24 @@ async function auditRepo(url) {
2020
2693
  }
2021
2694
  console.log(` ${c.green}done${c.reset}`);
2022
2695
 
2023
- // Step 4: LLM Analysis
2024
- // Resolve provider: preferred_provider from config → first match fallback
2025
- const activeLlm = resolveProvider();
2026
- const llmApiKey = activeLlm ? process.env[activeLlm.key] : null;
2027
- const activeProvider = activeLlm ? activeLlm.name : null;
2028
-
2029
- // Model override: --model flag > AGENTAUDIT_MODEL env > credentials.json > provider default
2030
- const modelArgIdx = process.argv.indexOf('--model');
2031
- const modelFlag = modelArgIdx !== -1 ? process.argv[modelArgIdx + 1] : null;
2032
- const modelEnv = process.env.AGENTAUDIT_MODEL;
2033
- const modelConfig = loadLlmConfig()?.llm_model;
2034
- const modelOverride = modelFlag || modelEnv || modelConfig || null;
2035
- if (activeLlm && modelOverride) {
2036
- activeLlm.model = modelOverride;
2037
- }
2038
-
2039
- if (!activeLlm) {
2040
- // No LLM API key — compact explanation
2041
- console.log();
2042
- console.log(` ${c.yellow}No LLM API key found.${c.reset} The ${c.bold}audit${c.reset} command needs an LLM to analyze code.`);
2043
- console.log();
2044
- console.log(` ${c.bold}Set an API key${c.reset} (e.g. ${c.cyan}export OPENROUTER_API_KEY=sk-or-...${c.reset})`);
2045
- console.log(` ${c.dim}Run "agentaudit model" to configure provider + model interactively${c.reset}`);
2046
- console.log();
2047
- console.log(` ${c.bold}Or export for manual review:${c.reset} ${c.cyan}agentaudit audit ${url} --export${c.reset}`);
2048
- console.log(` ${c.bold}Or use as MCP server${c.reset} in Cursor/Claude ${c.dim}(no extra API key needed)${c.reset}`);
2049
- console.log(` ${c.dim}{ "agentaudit": { "command": "npx", "args": ["-y", "agentaudit"] } }${c.reset}`);
2050
- console.log();
2051
-
2052
- // Check if --export flag
2053
- if (process.argv.includes('--export')) {
2054
- const exportPath = path.join(process.cwd(), `audit-${slug}.md`);
2055
- const exportContent = [
2056
- `# Security Audit: ${slug}`,
2057
- `**Source:** ${url}`,
2058
- `**Files:** ${files.length}`,
2059
- ``,
2060
- `## Audit Instructions`,
2061
- ``,
2062
- auditPrompt || '(audit prompt not found)',
2063
- ``,
2064
- `## Report Format`,
2065
- ``,
2066
- `After analysis, produce a JSON report:`,
2067
- '```json',
2068
- `{ "skill_slug": "${slug}", "source_url": "${url}", "risk_score": 0, "result": "safe", "findings": [] }`,
2069
- '```',
2070
- ``,
2071
- `## Source Code`,
2072
- ``,
2073
- codeBlock,
2074
- ].join('\n');
2075
- fs.writeFileSync(exportPath, exportContent);
2076
- console.log(` ${icons.safe} Exported to ${c.bold}${exportPath}${c.reset}`);
2077
- console.log(` ${c.dim}Paste this into any LLM (Claude, ChatGPT, etc.) for analysis${c.reset}`);
2078
- }
2079
-
2080
- // Cleanup
2081
- try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
2082
- return null;
2083
- }
2084
-
2085
- // We have an API key — run LLM audit
2086
- const modelLabel = modelOverride ? `${activeProvider} → ${activeLlm.model}` : activeProvider;
2087
- process.stdout.write(` ${stepProgress(4, 4)} Running LLM analysis ${c.dim}(${modelLabel})${c.reset}...`);
2696
+ // Step 4: Provenance + type detection (needs repoPath on disk)
2697
+ let commitSha = '';
2698
+ try { commitSha = execSync('git rev-parse HEAD', { cwd: repoPath, encoding: 'utf8' }).trim(); } catch {}
2699
+ const sourceHash = crypto.createHash('sha256').update(
2700
+ files.slice().sort((a, b) => a.path.localeCompare(b.path))
2701
+ .map(f => f.path + '\n' + f.content).join('\n')
2702
+ ).digest('hex');
2703
+ const pkgInfo = detectPackageInfo(repoPath, files);
2704
+ const KNOWN_MCP_LIBS = new Set(['fastmcp', 'jlowin-fastmcp', 'mcp-go', 'fastapi-mcp', 'fastapi_mcp', 'mcp-use', 'mcp-agent']);
2705
+ const KNOWN_CLI = new Set(['mcp-cli', 'mcp-scan', 'inspector']);
2706
+ let detectedType = pkgInfo.type === 'unknown' ? 'other' : pkgInfo.type;
2707
+ if (KNOWN_MCP_LIBS.has(slug)) detectedType = 'library';
2708
+ if (KNOWN_CLI.has(slug)) detectedType = 'cli-tool';
2088
2709
 
2710
+ // Cleanup repo (files in memory, provenance captured)
2711
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
2712
+
2713
+ // Build prompts
2089
2714
  const systemPrompt = auditPrompt || 'You are a security auditor. Analyze the code and report findings as JSON.';
2090
2715
  const userMessage = [
2091
2716
  `Audit this package: **${slug}** (${url})`,
@@ -2101,205 +2726,278 @@ async function auditRepo(url) {
2101
2726
  codeBlock,
2102
2727
  ].join('\n');
2103
2728
 
2104
- let report = null;
2105
- let _lastLlmText = '';
2729
+ // Helper: add provenance to a report
2730
+ const enrichReport = (report, duration) => {
2731
+ report.skill_slug = slug;
2732
+ report.package_type = detectedType;
2733
+ report.audit_duration_ms = duration || (Date.now() - start);
2734
+ report.files_scanned = files.length;
2735
+ if (commitSha) report.commit_sha = commitSha;
2736
+ report.source_hash = sourceHash;
2737
+ };
2106
2738
 
2107
- try {
2108
- let data;
2109
- if (activeLlm.type === 'anthropic') {
2110
- // Anthropic Messages API (unique format)
2111
- const res = await fetch(activeLlm.url, {
2739
+ // Helper: upload one report
2740
+ const uploadReport = async (report, creds) => {
2741
+ if (!creds) return;
2742
+ process.stdout.write(` Uploading report${report.audit_model ? ` (${report.audit_model})` : ''}...`);
2743
+ try {
2744
+ const res = await fetch(`${REGISTRY_URL}/api/reports`, {
2112
2745
  method: 'POST',
2113
- headers: {
2114
- 'x-api-key': llmApiKey,
2115
- 'anthropic-version': '2023-06-01',
2116
- 'content-type': 'application/json',
2117
- },
2118
- body: JSON.stringify({
2119
- model: activeLlm.model,
2120
- max_tokens: 8192,
2121
- system: systemPrompt,
2122
- messages: [{ role: 'user', content: userMessage }],
2123
- }),
2124
- signal: AbortSignal.timeout(120_000),
2746
+ headers: { 'Authorization': `Bearer ${creds.api_key}`, 'Content-Type': 'application/json' },
2747
+ body: JSON.stringify(report),
2748
+ signal: AbortSignal.timeout(15_000),
2125
2749
  });
2126
- data = await res.json();
2127
- if (data.error) {
2128
- console.log(` ${c.red}failed${c.reset}`);
2129
- const friendly = formatApiError(data.error, activeLlm.provider, res.status);
2130
- if (friendly) {
2131
- console.log(` ${c.red}${friendly.text}${c.reset}`);
2132
- console.log(` ${c.dim}${friendly.hint}${c.reset}`);
2133
- } else {
2134
- console.log(` ${c.red}API error: ${data.error.message || JSON.stringify(data.error)}${c.reset}`);
2135
- }
2136
- try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
2137
- return null;
2750
+ if (res.ok) {
2751
+ console.log(` ${c.green}done${c.reset}`);
2752
+ } else {
2753
+ let errBody = ''; try { errBody = await res.text(); } catch {}
2754
+ console.log(` ${c.yellow}failed (HTTP ${res.status})${c.reset}`);
2755
+ if (errBody && process.argv.includes('--debug')) console.log(` ${c.dim}Server: ${errBody.slice(0, 300)}${c.reset}`);
2138
2756
  }
2139
- _lastLlmText = data.content?.[0]?.text || '';
2140
- report = extractJSON(_lastLlmText);
2141
- if (report) {
2142
- report.audit_model = data.model || activeLlm.model;
2143
- report.audit_provider = activeLlm.provider;
2144
- if (data.id) report.provider_msg_id = data.id;
2145
- if (data.usage) {
2146
- report.input_tokens = data.usage.input_tokens;
2147
- report.output_tokens = data.usage.output_tokens;
2148
- }
2757
+ } catch { console.log(` ${c.yellow}failed${c.reset}`); }
2758
+ };
2759
+
2760
+ // Step 5: Resolve models
2761
+ const modelsArgIdx = process.argv.indexOf('--models');
2762
+ const modelsFlag = modelsArgIdx !== -1 ? process.argv[modelsArgIdx + 1] : null;
2763
+ const modelNames = modelsFlag ? modelsFlag.split(',').map(m => m.trim()).filter(Boolean) : [];
2764
+ const isMultiModel = modelNames.length > 1;
2765
+
2766
+ // ── Multi-Model Path ─────────────────────────────────────
2767
+ if (isMultiModel) {
2768
+ const resolvedModels = [];
2769
+ const failedModels = [];
2770
+ for (const name of modelNames) {
2771
+ const config = resolveModel(name);
2772
+ if (!config) { failedModels.push(name); continue; }
2773
+ resolvedModels.push({ name, config });
2774
+ }
2775
+
2776
+ if (resolvedModels.length === 0) {
2777
+ console.log();
2778
+ console.log(` ${c.red}No API keys available for requested models${c.reset}`);
2779
+ for (const name of failedModels) console.log(` ${c.dim}${name}: no matching API key${c.reset}`);
2780
+ console.log(` ${c.dim}Run "agentaudit model" to configure providers${c.reset}`);
2781
+ return null;
2782
+ }
2783
+
2784
+ // Progress
2785
+ const totalSteps = resolvedModels.length;
2786
+ console.log(` ${stepProgress(4, 4)} Running LLM analysis ${c.dim}(${totalSteps} models in parallel)${c.reset}`);
2787
+ if (failedModels.length > 0) {
2788
+ for (const name of failedModels) console.log(` ${c.yellow}⚠${c.reset} ${name.padEnd(30)} ${c.dim}skipped (no API key)${c.reset}`);
2789
+ }
2790
+
2791
+ // Parallel LLM calls
2792
+ const results = await Promise.allSettled(
2793
+ resolvedModels.map(async ({ name, config }) => {
2794
+ const result = await callLlm(config, systemPrompt, userMessage);
2795
+ return { name, ...result };
2796
+ })
2797
+ );
2798
+
2799
+ // Process results
2800
+ const reports = [];
2801
+ for (let i = 0; i < results.length; i++) {
2802
+ const name = resolvedModels[i].name;
2803
+ const r = results[i];
2804
+ if (r.status === 'rejected') {
2805
+ console.log(` ${c.red}✗${c.reset} ${name.padEnd(30)} ${c.red}error${c.reset}`);
2806
+ continue;
2149
2807
  }
2150
- } else if (activeLlm.type === 'gemini') {
2151
- // Google Gemini API (unique format)
2152
- const res = await fetch(`${activeLlm.url}/${activeLlm.model}:generateContent?key=${llmApiKey}`, {
2153
- method: 'POST',
2154
- headers: { 'Content-Type': 'application/json' },
2155
- body: JSON.stringify({
2156
- systemInstruction: { parts: [{ text: systemPrompt }] },
2157
- contents: [{ role: 'user', parts: [{ text: userMessage }] }],
2158
- generationConfig: { maxOutputTokens: 8192 },
2159
- }),
2160
- signal: AbortSignal.timeout(120_000),
2161
- });
2162
- data = await res.json();
2163
- if (data.error) {
2164
- console.log(` ${c.red}failed${c.reset}`);
2165
- const friendly = formatApiError(data.error, activeLlm.provider, res.status);
2166
- if (friendly) {
2167
- console.log(` ${c.red}${friendly.text}${c.reset}`);
2168
- console.log(` ${c.dim}${friendly.hint}${c.reset}`);
2169
- } else {
2170
- console.log(` ${c.red}API error: ${data.error.message || JSON.stringify(data.error)}${c.reset}`);
2808
+ const { report, text, error, hint, duration } = r.value;
2809
+ if (error) {
2810
+ console.log(` ${c.red}✗${c.reset} ${name.padEnd(30)} ${c.red}${error}${c.reset}`);
2811
+ if (hint) console.log(` ${c.dim}${hint}${c.reset}`);
2812
+ continue;
2813
+ }
2814
+ if (!report) {
2815
+ console.log(` ${c.yellow}✗${c.reset} ${name.padEnd(30)} ${c.yellow}JSON parse failed${c.reset}`);
2816
+ if (process.argv.includes('--debug') && text) {
2817
+ console.log(` ${c.dim}${text.slice(0, 200)}...${c.reset}`);
2171
2818
  }
2172
- try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
2173
- return null;
2819
+ continue;
2174
2820
  }
2175
- _lastLlmText = data.candidates?.[0]?.content?.parts?.[0]?.text || '';
2176
- report = extractJSON(_lastLlmText);
2177
- if (report) {
2178
- report.audit_model = data.modelVersion || activeLlm.model;
2179
- report.audit_provider = activeLlm.provider;
2180
- if (data.usageMetadata) {
2181
- report.input_tokens = data.usageMetadata.promptTokenCount;
2182
- report.output_tokens = data.usageMetadata.candidatesTokenCount;
2821
+ const durSec = Math.round((duration || 0) / 1000);
2822
+ console.log(` ${c.green}✓${c.reset} ${name.padEnd(30)} ${c.green}done${c.reset} ${c.dim}(${durSec}s)${c.reset}`);
2823
+ enrichReport(report, duration);
2824
+ saveHistory(report);
2825
+ reports.push({ name, report });
2826
+ }
2827
+
2828
+ if (reports.length === 0) {
2829
+ console.log();
2830
+ console.log(` ${c.red}No models returned valid results${c.reset}`);
2831
+ return null;
2832
+ }
2833
+
2834
+ // Display per-model results
2835
+ console.log();
2836
+ for (const { name, report } of reports) {
2837
+ console.log(sectionHeader(name));
2838
+ console.log(` ${riskBadge(report.risk_score || 0)}`);
2839
+ const fc = report.findings?.length || 0;
2840
+ if (fc > 0) {
2841
+ const counts = {};
2842
+ for (const f of report.findings) { const s = (f.severity || 'info').toLowerCase(); counts[s] = (counts[s] || 0) + 1; }
2843
+ const parts = [];
2844
+ for (const sev of ['critical', 'high', 'medium', 'low', 'info']) { if (counts[sev]) parts.push(`${counts[sev]} ${sev}`); }
2845
+ console.log(` ${c.dim}${fc} findings: ${parts.join(', ')}${c.reset}`);
2846
+ } else {
2847
+ console.log(` ${c.green}No findings${c.reset}`);
2848
+ }
2849
+ console.log();
2850
+ }
2851
+
2852
+ // Consensus comparison
2853
+ if (reports.length > 1) {
2854
+ console.log(sectionHeader('Consensus'));
2855
+
2856
+ // Risk range
2857
+ const risks = reports.map(r => r.report.risk_score || 0);
2858
+ const minRisk = Math.min(...risks);
2859
+ const maxRisk = Math.max(...risks);
2860
+ const avgRisk = Math.round(risks.reduce((a, b) => a + b, 0) / risks.length);
2861
+ console.log(` Risk: ${riskBadge(avgRisk)} ${c.dim}(range ${minRisk}–${maxRisk})${c.reset}`);
2862
+ console.log();
2863
+
2864
+ // Severity agreement
2865
+ const severities = reports.map(r => (r.report.max_severity || 'none').toLowerCase());
2866
+ const allSameSev = severities.every(s => s === severities[0]);
2867
+ if (allSameSev) {
2868
+ console.log(` ${c.green}${reports.length}/${reports.length} models agree:${c.reset} ${severities[0].toUpperCase()}`);
2869
+ } else {
2870
+ console.log(` ${c.yellow}Models disagree on severity:${c.reset}`);
2871
+ for (const { name, report } of reports) {
2872
+ const sev = (report.max_severity || 'none').toUpperCase();
2873
+ const sc = severityColor(report.max_severity);
2874
+ console.log(` ${sc}${sev.padEnd(10)}${c.reset} ${c.dim}${name}${c.reset}`);
2183
2875
  }
2184
2876
  }
2185
- } else {
2186
- // OpenAI-compatible API (OpenAI, Mistral, Groq, OpenRouter, etc.)
2187
- const headers = {
2188
- 'Authorization': `Bearer ${llmApiKey}`,
2189
- 'Content-Type': 'application/json',
2190
- };
2191
- // OpenRouter requires additional headers
2192
- if (activeLlm.provider === 'openrouter') {
2193
- headers['HTTP-Referer'] = 'https://agentaudit.dev';
2194
- headers['X-Title'] = 'AgentAudit CLI';
2877
+ console.log();
2878
+
2879
+ // Finding intersection (match by normalized title)
2880
+ const findingsByTitle = new Map();
2881
+ for (const { name, report } of reports) {
2882
+ for (const f of (report.findings || [])) {
2883
+ const key = (f.title || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
2884
+ if (!key) continue;
2885
+ if (!findingsByTitle.has(key)) findingsByTitle.set(key, { title: f.title, severity: f.severity, models: [] });
2886
+ findingsByTitle.get(key).models.push(name);
2887
+ }
2195
2888
  }
2196
- const res = await fetch(activeLlm.url, {
2197
- method: 'POST',
2198
- headers,
2199
- body: JSON.stringify({
2200
- model: activeLlm.model,
2201
- max_tokens: 8192,
2202
- messages: [
2203
- { role: 'system', content: systemPrompt },
2204
- { role: 'user', content: userMessage },
2205
- ],
2206
- }),
2207
- signal: AbortSignal.timeout(120_000),
2208
- });
2209
- data = await res.json();
2210
- if (data.error) {
2211
- console.log(` ${c.red}failed${c.reset}`);
2212
- const friendly = formatApiError(data.error, activeLlm.provider, res.status);
2213
- if (friendly) {
2214
- console.log(` ${c.red}${friendly.text}${c.reset}`);
2215
- console.log(` ${c.dim}${friendly.hint}${c.reset}`);
2216
- } else {
2217
- console.log(` ${c.red}API error: ${data.error.message || JSON.stringify(data.error)}${c.reset}`);
2889
+
2890
+ const shared = [...findingsByTitle.values()].filter(f => f.models.length > 1);
2891
+ const unique = [...findingsByTitle.values()].filter(f => f.models.length === 1);
2892
+
2893
+ if (shared.length > 0) {
2894
+ console.log(` ${c.bold}Shared findings (${shared.length}):${c.reset}`);
2895
+ for (const f of shared) {
2896
+ const sc = severityColor(f.severity);
2897
+ console.log(` ${sc}┃${c.reset} ${sc}${(f.severity || '').toUpperCase().padEnd(8)}${c.reset} ${f.title} ${c.dim}(${f.models.length}/${reports.length})${c.reset}`);
2218
2898
  }
2219
- try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
2220
- return null;
2899
+ console.log();
2221
2900
  }
2222
- _lastLlmText = data.choices?.[0]?.message?.content || '';
2223
- report = extractJSON(_lastLlmText);
2224
- if (report) {
2225
- report.audit_model = data.model || activeLlm.model;
2226
- report.audit_provider = activeLlm.provider;
2227
- if (data.id) report.provider_msg_id = data.id;
2228
- if (data.system_fingerprint) report.provider_fingerprint = data.system_fingerprint;
2229
- if (data.usage) {
2230
- report.input_tokens = data.usage.prompt_tokens;
2231
- report.output_tokens = data.usage.completion_tokens;
2901
+
2902
+ if (unique.length > 0) {
2903
+ console.log(` ${c.bold}Unique findings (${unique.length}):${c.reset}`);
2904
+ for (const f of unique) {
2905
+ const sc = severityColor(f.severity);
2906
+ console.log(` ${sc}┃${c.reset} ${sc}${(f.severity || '').toUpperCase().padEnd(8)}${c.reset} ${f.title} ${c.dim}(${f.models[0]} only)${c.reset}`);
2232
2907
  }
2908
+ console.log();
2233
2909
  }
2234
2910
  }
2235
-
2236
- console.log(` ${c.green}done${c.reset} ${c.dim}(${elapsed(start)})${c.reset}`);
2237
- } catch (err) {
2238
- console.log(` ${c.red}failed${c.reset}`);
2239
- if (err.name === 'TimeoutError' || err.message?.includes('timeout')) {
2240
- console.log(` ${c.red}Request timed out (120s)${c.reset}`);
2241
- console.log(` ${c.dim}The provider took too long to respond. Try again or use a faster model${c.reset}`);
2242
- } else if (err.code === 'ENOTFOUND' || err.code === 'ECONNREFUSED' || err.message?.includes('fetch failed')) {
2243
- console.log(` ${c.red}Network error: could not reach ${activeProvider}${c.reset}`);
2244
- console.log(` ${c.dim}Check your internet connection or provider status${c.reset}`);
2245
- } else {
2246
- console.log(` ${c.red}${err.message}${c.reset}`);
2911
+
2912
+ // Upload each report
2913
+ const noUpload = process.argv.includes('--no-upload');
2914
+ const creds = loadCredentials();
2915
+ if (!noUpload && creds) {
2916
+ for (const { report } of reports) await uploadReport(report, creds);
2917
+ console.log(` ${c.dim}Reports: ${REGISTRY_URL}/packages/${slug}${c.reset}`);
2918
+ } else if (!noUpload && !creds) {
2919
+ console.log(` ${c.dim}Run ${c.cyan}agentaudit setup${c.dim} to upload reports to agentaudit.dev${c.reset}`);
2920
+ }
2921
+
2922
+ console.log();
2923
+ return reports.map(r => r.report);
2924
+ }
2925
+
2926
+ // ── Single-Model Path ────────────────────────────────────
2927
+ // If --models has exactly 1 model, use it; otherwise resolve via --model / config / env
2928
+ let activeLlm;
2929
+ if (modelNames.length === 1) {
2930
+ activeLlm = resolveModel(modelNames[0]);
2931
+ } else {
2932
+ activeLlm = resolveProvider();
2933
+ // Model override: --model flag > AGENTAUDIT_MODEL env > credentials.json > provider default
2934
+ const modelArgIdx2 = process.argv.indexOf('--model');
2935
+ const modelFlag2 = modelArgIdx2 !== -1 ? process.argv[modelArgIdx2 + 1] : null;
2936
+ const modelOverride = modelFlag2 || process.env.AGENTAUDIT_MODEL || loadLlmConfig()?.llm_model || null;
2937
+ if (activeLlm && modelOverride) activeLlm.model = modelOverride;
2938
+ }
2939
+
2940
+ if (!activeLlm) {
2941
+ console.log();
2942
+ console.log(` ${c.yellow}No LLM API key found.${c.reset} The ${c.bold}audit${c.reset} command needs an LLM to analyze code.`);
2943
+ console.log();
2944
+ console.log(` ${c.bold}Set an API key${c.reset} (e.g. ${c.cyan}export OPENROUTER_API_KEY=sk-or-...${c.reset})`);
2945
+ console.log(` ${c.dim}Run "agentaudit model" to configure provider + model interactively${c.reset}`);
2946
+ console.log();
2947
+ console.log(` ${c.bold}Or export for manual review:${c.reset} ${c.cyan}agentaudit audit ${url} --export${c.reset}`);
2948
+ console.log(` ${c.bold}Or use as MCP server${c.reset} in Cursor/Claude ${c.dim}(no extra API key needed)${c.reset}`);
2949
+ console.log(` ${c.dim}{ "agentaudit": { "command": "npx", "args": ["-y", "agentaudit"] } }${c.reset}`);
2950
+ console.log();
2951
+ if (process.argv.includes('--export')) {
2952
+ const exportPath = path.join(process.cwd(), `audit-${slug}.md`);
2953
+ const exportContent = [
2954
+ `# Security Audit: ${slug}`, `**Source:** ${url}`, `**Files:** ${files.length}`, ``,
2955
+ `## Audit Instructions`, ``, auditPrompt || '(audit prompt not found)', ``,
2956
+ `## Report Format`, ``, `After analysis, produce a JSON report:`,
2957
+ '```json', `{ "skill_slug": "${slug}", "source_url": "${url}", "risk_score": 0, "result": "safe", "findings": [] }`, '```',
2958
+ ``, `## Source Code`, ``, codeBlock,
2959
+ ].join('\n');
2960
+ fs.writeFileSync(exportPath, exportContent);
2961
+ console.log(` ${icons.safe} Exported to ${c.bold}${exportPath}${c.reset}`);
2962
+ console.log(` ${c.dim}Paste this into any LLM (Claude, ChatGPT, etc.) for analysis${c.reset}`);
2247
2963
  }
2248
- try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
2249
2964
  return null;
2250
2965
  }
2251
-
2252
- // Provenance: compute BEFORE cleanup (needs repoPath on disk)
2253
- let commitSha = '';
2254
- try {
2255
- commitSha = execSync('git rev-parse HEAD', { cwd: repoPath, encoding: 'utf8' }).trim();
2256
- } catch { /* shallow clone without HEAD — unlikely but safe */ }
2257
- const sourceHash = crypto.createHash('sha256').update(
2258
- files.slice().sort((a, b) => a.path.localeCompare(b.path))
2259
- .map(f => f.path + '\n' + f.content).join('\n')
2260
- ).digest('hex');
2261
- // Code-based type detection (uses files array in memory + repoPath for context)
2262
- const pkgInfo = detectPackageInfo(repoPath, files);
2263
- // Known MCP frameworks are libraries, not servers (they contain MCP patterns but ARE the SDK)
2264
- const KNOWN_MCP_LIBS = new Set(['fastmcp', 'jlowin-fastmcp', 'mcp-go', 'fastapi-mcp', 'fastapi_mcp', 'mcp-use', 'mcp-agent']);
2265
- const KNOWN_CLI = new Set(['mcp-cli', 'mcp-scan', 'inspector']);
2266
- let detectedType = pkgInfo.type === 'unknown' ? 'other' : pkgInfo.type;
2267
- if (KNOWN_MCP_LIBS.has(slug)) detectedType = 'library';
2268
- if (KNOWN_CLI.has(slug)) detectedType = 'cli-tool';
2269
2966
 
2270
- // Cleanup repo (safe now — provenance data captured above)
2271
- try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
2967
+ // Single LLM call via callLlm()
2968
+ const modelLabel = `${activeLlm.name} ${activeLlm.model}`;
2969
+ process.stdout.write(` ${stepProgress(4, 4)} Running LLM analysis ${c.dim}(${modelLabel})${c.reset}...`);
2970
+
2971
+ const llmResult = await callLlm(activeLlm, systemPrompt, userMessage);
2972
+
2973
+ if (llmResult.error) {
2974
+ console.log(` ${c.red}failed${c.reset}`);
2975
+ console.log(` ${c.red}${llmResult.error}${c.reset}`);
2976
+ if (llmResult.hint) console.log(` ${c.dim}${llmResult.hint}${c.reset}`);
2977
+ return null;
2978
+ }
2979
+
2980
+ console.log(` ${c.green}done${c.reset} ${c.dim}(${elapsed(start)})${c.reset}`);
2272
2981
 
2982
+ const report = llmResult.report;
2273
2983
  if (!report) {
2274
2984
  console.log(` ${c.red}Could not parse LLM response as JSON${c.reset}`);
2275
2985
  console.log(` ${c.dim}Hint: run with --debug to see the raw LLM response${c.reset}`);
2276
2986
  if (process.argv.includes('--debug')) {
2277
2987
  console.log(` ${c.dim}--- Raw LLM response (first 2000 chars) ---${c.reset}`);
2278
- console.log((typeof _lastLlmText === 'string' ? _lastLlmText : '(empty)').slice(0, 2000));
2988
+ console.log((llmResult.text || '(empty)').slice(0, 2000));
2279
2989
  console.log(` ${c.dim}--- end ---${c.reset}`);
2280
2990
  }
2281
2991
  return null;
2282
2992
  }
2283
2993
 
2284
- // Force slug from URL — never trust LLM-provided skill_slug
2285
- report.skill_slug = slug;
2286
-
2287
- // Force package_type from code detection — never trust LLM-provided type
2288
- report.package_type = detectedType;
2289
-
2290
- // Add scan metadata for benchmarking
2291
- report.audit_duration_ms = Date.now() - start;
2292
- report.files_scanned = files.length;
2293
-
2294
- // Set provenance data
2295
- if (commitSha) report.commit_sha = commitSha;
2296
- report.source_hash = sourceHash;
2994
+ enrichReport(report);
2995
+ saveHistory(report);
2297
2996
 
2298
2997
  // Display results
2299
2998
  console.log();
2300
- const riskScore = report.risk_score || 0;
2301
2999
  console.log(sectionHeader('Result'));
2302
- console.log(` ${riskBadge(riskScore)}`);
3000
+ console.log(` ${riskBadge(report.risk_score || 0)}`);
2303
3001
  console.log();
2304
3002
 
2305
3003
  if (report.findings && report.findings.length > 0) {
@@ -2312,8 +3010,6 @@ async function auditRepo(url) {
2312
3010
  if (f.description) console.log(` ${sc}┃${c.reset} ${c.dim}${f.description.slice(0, 120)}${c.reset}`);
2313
3011
  console.log();
2314
3012
  }
2315
-
2316
- // Severity histogram
2317
3013
  const histLines = severityHistogram(report.findings);
2318
3014
  if (histLines.length > 1) {
2319
3015
  console.log(sectionHeader('Severity'));
@@ -2324,41 +3020,16 @@ async function auditRepo(url) {
2324
3020
  console.log(` ${c.green}No findings — package looks clean.${c.reset}`);
2325
3021
  console.log();
2326
3022
  }
2327
-
2328
- // Upload to registry (skip with --no-upload)
3023
+
3024
+ // Upload to registry
2329
3025
  const noUpload = process.argv.includes('--no-upload');
2330
3026
  let creds = loadCredentials();
2331
3027
  if (noUpload) {
2332
3028
  // Skip silently
2333
3029
  } else if (creds) {
2334
- process.stdout.write(` Uploading report to registry...`);
2335
- try {
2336
- const res = await fetch(`${REGISTRY_URL}/api/reports`, {
2337
- method: 'POST',
2338
- headers: {
2339
- 'Authorization': `Bearer ${creds.api_key}`,
2340
- 'Content-Type': 'application/json',
2341
- },
2342
- body: JSON.stringify(report),
2343
- signal: AbortSignal.timeout(15_000),
2344
- });
2345
- if (res.ok) {
2346
- const data = await res.json();
2347
- console.log(` ${c.green}done${c.reset}`);
2348
- console.log(` ${c.dim}Report: ${REGISTRY_URL}/packages/${slug}${c.reset}`);
2349
- } else {
2350
- let errBody = '';
2351
- try { errBody = await res.text(); } catch {}
2352
- console.log(` ${c.yellow}failed (HTTP ${res.status})${c.reset}`);
2353
- if (errBody && process.argv.includes('--debug')) {
2354
- console.log(` ${c.dim}Server: ${errBody.slice(0, 300)}${c.reset}`);
2355
- }
2356
- }
2357
- } catch (err) {
2358
- console.log(` ${c.yellow}failed${c.reset}`);
2359
- }
3030
+ await uploadReport(report, creds);
3031
+ console.log(` ${c.dim}Report: ${REGISTRY_URL}/packages/${slug}${c.reset}`);
2360
3032
  } else if (process.stdin.isTTY) {
2361
- // No credentials — prompt to paste key or set up
2362
3033
  console.log();
2363
3034
  console.log(` ${c.bold}Want to upload this report to agentaudit.dev?${c.reset}`);
2364
3035
  console.log(` ${c.dim}Create an API key at ${c.cyan}${REGISTRY_URL}/profile${c.dim} (sign in with GitHub)${c.reset}`);
@@ -2372,27 +3043,8 @@ async function auditRepo(url) {
2372
3043
  saveCredentials({ api_key: pastedKey.trim(), agent_name: agentName });
2373
3044
  creds = { api_key: pastedKey.trim(), agent_name: agentName };
2374
3045
  console.log(` ${c.green}valid!${c.reset}`);
2375
- process.stdout.write(` Uploading report...`);
2376
- try {
2377
- const res = await fetch(`${REGISTRY_URL}/api/reports`, {
2378
- method: 'POST',
2379
- headers: {
2380
- 'Authorization': `Bearer ${creds.api_key}`,
2381
- 'Content-Type': 'application/json',
2382
- },
2383
- body: JSON.stringify(report),
2384
- signal: AbortSignal.timeout(15_000),
2385
- });
2386
- if (res.ok) {
2387
- console.log(` ${c.green}done${c.reset}`);
2388
- console.log(` ${c.dim}Report: ${REGISTRY_URL}/packages/${slug}${c.reset}`);
2389
- } else {
2390
- console.log(` ${c.yellow}failed (HTTP ${res.status})${c.reset}`);
2391
- }
2392
- } catch (err) {
2393
- console.log(` ${c.red}failed${c.reset}`);
2394
- console.log(` ${c.dim}${err.message}${c.reset}`);
2395
- }
3046
+ await uploadReport(report, creds);
3047
+ console.log(` ${c.dim}Report: ${REGISTRY_URL}/packages/${slug}${c.reset}`);
2396
3048
  } else {
2397
3049
  console.log(` ${c.red}invalid key${c.reset}`);
2398
3050
  console.log(` ${c.dim}Run ${c.cyan}agentaudit setup${c.dim} to configure.${c.reset}`);
@@ -2401,7 +3053,7 @@ async function auditRepo(url) {
2401
3053
  } else {
2402
3054
  console.log(` ${c.dim}Run ${c.cyan}agentaudit setup${c.dim} to configure your API key and upload reports${c.reset}`);
2403
3055
  }
2404
-
3056
+
2405
3057
  console.log();
2406
3058
  return report;
2407
3059
  }
@@ -2594,20 +3246,22 @@ function renderBenchmarkTab(data, width) {
2594
3246
  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`);
2595
3247
  lines.push('');
2596
3248
 
2597
- // Header
2598
- const nameW = 28;
2599
- const hdr = ` ${padRight(`${c.bold}Model${c.reset}`, nameW + 9)} ${padRight('Audits', 7)} ${padRight('Risk', 5)} ${padRight('Detection', 16)} Severity`;
2600
- lines.push(hdr);
3249
+ // Header — fixed column widths for alignment
3250
+ const nameW = 30;
3251
+ const auditsW = 6;
3252
+ const riskW = 5;
3253
+ const hdr = ` ${padRight('Model', nameW)} ${padLeft('Audits', auditsW)} ${padLeft('Risk', riskW)} ${'Detection'.padEnd(14)} Severity`;
3254
+ lines.push(` ${c.bold}${stripAnsi(hdr).trim()}${c.reset}`);
2601
3255
  lines.push(` ${c.dim}${'─'.repeat(Math.min(width - 4, 86))}${c.reset}`);
2602
3256
 
2603
3257
  for (const m of benchmark.models) {
2604
3258
  const name = (m.audit_model || 'unknown').slice(0, nameW - 2);
2605
- const audits = padLeft(fmtNum(m.total_audits), 5);
3259
+ const audits = padLeft(fmtNum(m.total_audits), auditsW);
2606
3260
  const riskVal = parseFloat(m.avg_risk_score) || 0;
2607
3261
  const riskColor = riskVal <= 20 ? c.green : riskVal <= 40 ? c.yellow : c.red;
2608
- const risk = `${riskColor}${padLeft(String(Math.round(riskVal)), 3)}${c.reset}`;
3262
+ const risk = `${riskColor}${padLeft(String(Math.round(riskVal)), riskW)}${c.reset}`;
2609
3263
  const detection = renderGauge(m.detection_rate || 0, 100, 10);
2610
- // Severity as compact text instead of dots
3264
+ // Severity as compact text
2611
3265
  const sev = m.severity_breakdown || {};
2612
3266
  const sevParts = [];
2613
3267
  if (sev.critical) sevParts.push(`${c.red}${sev.critical}C${c.reset}`);
@@ -2615,7 +3269,7 @@ function renderBenchmarkTab(data, width) {
2615
3269
  if (sev.medium) sevParts.push(`${c.yellow}${sev.medium}M${c.reset}`);
2616
3270
  if (sev.low) sevParts.push(`${c.blue}${sev.low}L${c.reset}`);
2617
3271
  const sevStr = sevParts.length > 0 ? sevParts.join(' ') : `${c.dim}—${c.reset}`;
2618
- lines.push(` ${padRight(name, nameW)} ${audits} ${risk} ${detection} ${sevStr}`);
3272
+ lines.push(` ${padRight(name, nameW)} ${audits} ${risk} ${detection} ${sevStr}`);
2619
3273
  }
2620
3274
 
2621
3275
  // Vulnerability landscape
@@ -3283,9 +3937,11 @@ async function main() {
3283
3937
  // Strip global flags from args (including --model <value>)
3284
3938
  const globalFlags = new Set(['--json', '--quiet', '-q', '--no-color', '--no-upload']);
3285
3939
  let args = rawArgs.filter(a => !globalFlags.has(a));
3286
- // Remove --model <value> pair
3940
+ // Remove --model <value> and --models <value> pairs
3287
3941
  const modelIdx = args.indexOf('--model');
3288
3942
  if (modelIdx !== -1) args.splice(modelIdx, 2);
3943
+ const modelsIdx = args.indexOf('--models');
3944
+ if (modelsIdx !== -1) args.splice(modelsIdx, 2);
3289
3945
 
3290
3946
  // Detect per-command --help BEFORE stripping (e.g. `agentaudit model --help`)
3291
3947
  const wantsHelp = args.includes('--help') || args.includes('-h');
@@ -3335,15 +3991,16 @@ async function main() {
3335
3991
  `Deep LLM-powered 3-pass security audit (~30s). Requires an LLM API key.`,
3336
3992
  ``,
3337
3993
  `${c.bold}Options:${c.reset}`,
3338
- ` --model <name> Override LLM model for this run`,
3339
- ` --no-upload Skip uploading report to registry`,
3340
- ` --export Export audit payload as markdown (for manual LLM review)`,
3341
- ` --debug Show raw LLM response on parse errors`,
3994
+ ` --model <name> Override LLM model for this run`,
3995
+ ` --models <a,b,c> Multi-model audit (parallel calls, consensus comparison)`,
3996
+ ` --no-upload Skip uploading report to registry`,
3997
+ ` --export Export audit payload as markdown (for manual LLM review)`,
3998
+ ` --debug Show raw LLM response on parse errors`,
3342
3999
  ``,
3343
4000
  `${c.bold}Examples:${c.reset}`,
3344
4001
  ` agentaudit audit https://github.com/owner/repo`,
3345
- ` agentaudit audit https://github.com/owner/repo --no-upload`,
3346
4002
  ` agentaudit audit https://github.com/owner/repo --model gpt-4o`,
4003
+ ` agentaudit audit https://github.com/owner/repo --models gemini-2.5-flash,claude-sonnet-4-20250514`,
3347
4004
  ` agentaudit audit https://github.com/owner/repo --export`,
3348
4005
  ],
3349
4006
  lookup: [
@@ -3457,10 +4114,32 @@ async function main() {
3457
4114
  ` agentaudit benchmark --json`,
3458
4115
  ],
3459
4116
  bench: null, // alias → benchmark
4117
+ consensus: [
4118
+ `${c.bold}agentaudit consensus${c.reset} <package-name>`,
4119
+ ``,
4120
+ `View multi-model consensus status from the AgentAudit registry.`,
4121
+ `Shows agreement across different LLM models and peer reviewers.`,
4122
+ ``,
4123
+ `${c.bold}Options:${c.reset}`,
4124
+ ` --json Machine-readable JSON output`,
4125
+ ``,
4126
+ `${c.bold}Examples:${c.reset}`,
4127
+ ` agentaudit consensus nanobanana-mcp-server`,
4128
+ ` agentaudit consensus fastmcp --json`,
4129
+ ],
4130
+ history: [
4131
+ `${c.bold}agentaudit history${c.reset} [options]`,
4132
+ ``,
4133
+ `Show your local audit history. Results are stored in ~/.config/agentaudit/history/`,
4134
+ `after every audit run. No internet connection required.`,
4135
+ ``,
4136
+ `${c.bold}Options:${c.reset}`,
4137
+ ` --json Machine-readable JSON output`,
4138
+ ],
3460
4139
  activity: [
3461
4140
  `${c.bold}agentaudit activity${c.reset} [options]`,
3462
4141
  ``,
3463
- `Show your recent audits and findings from the AgentAudit registry.`,
4142
+ `Show your recent audits and findings from the AgentAudit registry (online).`,
3464
4143
  `Requires being logged in (run ${c.cyan}agentaudit setup${c.reset} first).`,
3465
4144
  ``,
3466
4145
  `${c.bold}Options:${c.reset}`,
@@ -3553,16 +4232,19 @@ async function main() {
3553
4232
  console.log(` agentaudit <command> [options]`);
3554
4233
  console.log();
3555
4234
  console.log(` ${c.bold}SCAN & AUDIT${c.reset}`);
3556
- console.log(` ${c.cyan}discover${c.reset} Find MCP servers in your AI editors`);
4235
+ console.log(` ${c.cyan}discover${c.reset} Find MCP servers & skills in your AI tools`);
3557
4236
  console.log(` ${c.cyan}scan${c.reset} <url> [url...] Quick static scan (regex, ~2s)`);
3558
4237
  console.log(` ${c.cyan}audit${c.reset} <url> [url...] Deep LLM-powered security audit (~30s)`);
4238
+ console.log(` ${c.cyan}validate${c.reset} [path] Validate SKILL.md format & security`);
3559
4239
  console.log(` ${c.cyan}lookup${c.reset} <name> Look up package in registry`);
4240
+ console.log(` ${c.cyan}consensus${c.reset} <name> View multi-model consensus for a package`);
3560
4241
  console.log();
3561
4242
  console.log(` ${c.bold}COMMUNITY${c.reset}`);
3562
4243
  console.log(` ${c.cyan}dashboard${c.reset} Interactive dashboard (full-screen)`);
3563
4244
  console.log(` ${c.cyan}leaderboard${c.reset} Top contributors ranking`);
3564
4245
  console.log(` ${c.cyan}benchmark${c.reset} LLM model performance comparison`);
3565
- console.log(` ${c.cyan}activity${c.reset} Your recent audits & findings`);
4246
+ console.log(` ${c.cyan}history${c.reset} Your local audit history`);
4247
+ console.log(` ${c.cyan}activity${c.reset} Your recent audits & findings (online)`);
3566
4248
  console.log(` ${c.cyan}search${c.reset} <query> Search packages in registry`);
3567
4249
  console.log();
3568
4250
  console.log(` ${c.bold}CONFIGURATION${c.reset}`);
@@ -3576,6 +4258,7 @@ async function main() {
3576
4258
  console.log(` ${c.dim}--quiet Suppress banner${c.reset}`);
3577
4259
  console.log(` ${c.dim}--no-color Disable ANSI colors (also: NO_COLOR env)${c.reset}`);
3578
4260
  console.log(` ${c.dim}--model <name> Override LLM model for this run${c.reset}`);
4261
+ console.log(` ${c.dim}--models <a,b,c> Multi-model audit (parallel, with consensus)${c.reset}`);
3579
4262
  console.log(` ${c.dim}--no-upload Skip uploading report to registry${c.reset}`);
3580
4263
  console.log(` ${c.dim}--export Export audit payload as markdown${c.reset}`);
3581
4264
  console.log(` ${c.dim}--debug Show raw LLM response on parse errors${c.reset}`);
@@ -3584,6 +4267,7 @@ async function main() {
3584
4267
  console.log(` agentaudit discover --quick`);
3585
4268
  console.log(` agentaudit scan https://github.com/owner/repo`);
3586
4269
  console.log(` agentaudit audit https://github.com/owner/repo`);
4270
+ console.log(` agentaudit audit <url> --models gemini-2.5-flash,claude-sonnet-4-20250514`);
3587
4271
  console.log(` agentaudit lookup fastmcp --json`);
3588
4272
  console.log();
3589
4273
  console.log(` ${c.bold}LEARN MORE${c.reset}`);
@@ -3610,6 +4294,37 @@ async function main() {
3610
4294
  await benchmarkCommand(targets);
3611
4295
  return;
3612
4296
  }
4297
+ if (command === 'history') {
4298
+ banner();
4299
+ const entries = loadHistory(30);
4300
+ if (entries.length === 0) {
4301
+ console.log(` ${c.dim}No local audit history yet. Run ${c.cyan}agentaudit audit <url>${c.dim} to start.${c.reset}`);
4302
+ console.log();
4303
+ return;
4304
+ }
4305
+
4306
+ if (jsonMode) {
4307
+ console.log(JSON.stringify(entries, null, 2));
4308
+ return;
4309
+ }
4310
+
4311
+ console.log(sectionHeader(`Local History (${entries.length})`));
4312
+ console.log();
4313
+
4314
+ for (const entry of entries) {
4315
+ const slug = entry.skill_slug || 'unknown';
4316
+ const risk = entry.risk_score ?? '?';
4317
+ const sev = entry.max_severity || 'none';
4318
+ const sc = severityColor(sev);
4319
+ const model = entry.audit_model || '?';
4320
+ const fc = entry.findings?.length || 0;
4321
+ const ts = entry._file?.slice(0, 10) || '';
4322
+ console.log(` ${sc}┃${c.reset} ${c.bold}${slug.padEnd(30)}${c.reset} ${riskBadge(risk)} ${c.dim}${model}${c.reset}`);
4323
+ console.log(` ${sc}┃${c.reset} ${c.dim}${ts} ${fc} findings ${sev.toUpperCase()}${c.reset}`);
4324
+ console.log();
4325
+ }
4326
+ return;
4327
+ }
3613
4328
  if (command === 'activity' || command === 'my') {
3614
4329
  await activityCommand(targets);
3615
4330
  return;
@@ -3618,6 +4333,73 @@ async function main() {
3618
4333
  await searchCommand(targets);
3619
4334
  return;
3620
4335
  }
4336
+ if (command === 'consensus') {
4337
+ banner();
4338
+ const pkg = targets[0];
4339
+ if (!pkg) {
4340
+ console.log(` ${c.red}Error: package name required${c.reset}`);
4341
+ console.log(` ${c.dim}Usage: ${c.cyan}agentaudit consensus <package-name>${c.reset}`);
4342
+ process.exitCode = 2;
4343
+ return;
4344
+ }
4345
+ const slug = pkg.toLowerCase().replace(/[^a-z0-9-]/g, '-');
4346
+ if (!jsonMode) console.log(` Fetching consensus for ${c.bold}${slug}${c.reset}...`);
4347
+ try {
4348
+ const res = await fetch(`${REGISTRY_URL}/api/packages/${slug}/consensus`, { signal: AbortSignal.timeout(10_000) });
4349
+ if (!res.ok) {
4350
+ if (res.status === 404) {
4351
+ console.log(` ${c.yellow}Not found${c.reset} — "${slug}" hasn't been audited yet.`);
4352
+ console.log(` ${c.dim}Run: ${c.cyan}agentaudit audit <repo-url>${c.dim} to create the first audit${c.reset}`);
4353
+ } else {
4354
+ console.log(` ${c.red}API error (HTTP ${res.status})${c.reset}`);
4355
+ }
4356
+ return;
4357
+ }
4358
+ const data = await res.json();
4359
+ if (jsonMode) { console.log(JSON.stringify(data, null, 2)); return; }
4360
+
4361
+ console.log();
4362
+ console.log(sectionHeader(`Consensus: ${slug}`));
4363
+ console.log();
4364
+
4365
+ // Status
4366
+ const status = data.consensus_status || data.status || 'pending';
4367
+ const statusColor = status === 'reached' ? c.green : status === 'disputed' ? c.yellow : c.dim;
4368
+ console.log(` Status: ${statusColor}${status.toUpperCase()}${c.reset}`);
4369
+
4370
+ // Risk + Severity
4371
+ if (data.consensus_risk_score != null) console.log(` Risk: ${riskBadge(data.consensus_risk_score)}`);
4372
+ if (data.consensus_severity) {
4373
+ const sc = severityColor(data.consensus_severity);
4374
+ console.log(` Severity: ${sc}${data.consensus_severity.toUpperCase()}${c.reset}`);
4375
+ }
4376
+
4377
+ // Models
4378
+ if (data.models && data.models.length > 0) {
4379
+ console.log();
4380
+ console.log(` ${c.bold}Models (${data.models.length}):${c.reset}`);
4381
+ for (const m of data.models) {
4382
+ const sc = severityColor(m.severity || m.max_severity);
4383
+ const risk = m.risk_score ?? '?';
4384
+ console.log(` ${sc}┃${c.reset} ${(m.model || m.audit_model || '?').padEnd(30)} ${c.dim}risk ${risk}${c.reset} ${sc}${(m.severity || m.max_severity || '').toUpperCase()}${c.reset}`);
4385
+ }
4386
+ }
4387
+
4388
+ // Reviewers
4389
+ if (data.reviews != null || data.reviewer_count != null) {
4390
+ const count = data.reviewer_count || data.reviews?.length || 0;
4391
+ console.log();
4392
+ console.log(` ${c.dim}Reviews: ${count} | Threshold: 5 reviewers, >60% agreement${c.reset}`);
4393
+ }
4394
+
4395
+ console.log();
4396
+ console.log(` ${c.dim}Full details: ${REGISTRY_URL}/packages/${slug}${c.reset}`);
4397
+ console.log();
4398
+ } catch (err) {
4399
+ console.log(` ${c.red}Failed: ${err.message}${c.reset}`);
4400
+ }
4401
+ return;
4402
+ }
3621
4403
 
3622
4404
  banner();
3623
4405
 
@@ -3988,6 +4770,124 @@ async function main() {
3988
4770
  return;
3989
4771
  }
3990
4772
 
4773
+ if (command === 'validate') {
4774
+ const paths = targets.filter(t => !t.startsWith('--'));
4775
+
4776
+ // If no path given, find all skills and validate them
4777
+ if (paths.length === 0) {
4778
+ const skills = findSkills();
4779
+ if (skills.length === 0) {
4780
+ console.log(` ${c.yellow}No SKILL.md files found${c.reset}`);
4781
+ console.log(` ${c.dim}Searched: ~/.claude/skills/, ~/.cursor/skills/, .claude/skills/, .cursor/skills/${c.reset}`);
4782
+ console.log();
4783
+ console.log(` ${c.dim}Usage: ${c.cyan}agentaudit validate [path/to/SKILL.md]${c.reset}`);
4784
+ return;
4785
+ }
4786
+
4787
+ console.log(` ${c.bold}Validating ${skills.length} skill${skills.length !== 1 ? 's' : ''}${c.reset}`);
4788
+ console.log();
4789
+
4790
+ let totalErrors = 0;
4791
+ let totalWarnings = 0;
4792
+
4793
+ for (const skill of skills) {
4794
+ const { errors, warnings, info } = skill.validation;
4795
+ totalErrors += errors.length;
4796
+ totalWarnings += warnings.length;
4797
+
4798
+ const name = info.name || skill.dirName;
4799
+ const hasErrors = errors.length > 0;
4800
+ const hasWarnings = warnings.length > 0;
4801
+
4802
+ if (hasErrors) {
4803
+ console.log(` ${c.red}✖${c.reset} ${c.bold}${name}${c.reset} ${c.dim}${skill.path}${c.reset}`);
4804
+ for (const err of errors) console.log(` ${c.red}✖ ${err}${c.reset}`);
4805
+ for (const warn of warnings) console.log(` ${c.yellow}⚠ ${warn}${c.reset}`);
4806
+ } else if (hasWarnings) {
4807
+ console.log(` ${c.yellow}⚠${c.reset} ${c.bold}${name}${c.reset} ${c.dim}${skill.path}${c.reset}`);
4808
+ for (const warn of warnings) console.log(` ${c.yellow}⚠ ${warn}${c.reset}`);
4809
+ } else {
4810
+ console.log(` ${c.green}✔${c.reset} ${c.bold}${name}${c.reset} ${c.dim}${skill.path}${c.reset}`);
4811
+ }
4812
+
4813
+ // Show MCP references
4814
+ if (info.mcpServers && info.mcpServers.length > 0) {
4815
+ console.log(` ${c.dim}MCP servers: ${info.mcpServers.join(', ')}${c.reset}`);
4816
+ }
4817
+ if (info.allowedTools === null) {
4818
+ console.log(` ${c.yellow}⚠ no allowed-tools — unrestricted tool access${c.reset}`);
4819
+ }
4820
+ console.log();
4821
+ }
4822
+
4823
+ // Summary
4824
+ console.log(sectionHeader('Validation Summary'));
4825
+ console.log();
4826
+ if (totalErrors === 0 && totalWarnings === 0) {
4827
+ console.log(` ${c.green}✔ All ${skills.length} skills valid${c.reset}`);
4828
+ } else {
4829
+ if (totalErrors > 0) console.log(` ${c.red}✖ ${totalErrors} error${totalErrors !== 1 ? 's' : ''}${c.reset}`);
4830
+ if (totalWarnings > 0) console.log(` ${c.yellow}⚠ ${totalWarnings} warning${totalWarnings !== 1 ? 's' : ''}${c.reset}`);
4831
+ }
4832
+ console.log();
4833
+
4834
+ if (jsonMode) {
4835
+ console.log(JSON.stringify(skills.map(s => ({
4836
+ name: s.validation.info.name || s.dirName,
4837
+ path: s.path,
4838
+ source: s.source,
4839
+ errors: s.validation.errors,
4840
+ warnings: s.validation.warnings,
4841
+ mcpServers: s.validation.info.mcpServers,
4842
+ allowedTools: s.validation.info.allowedTools,
4843
+ })), null, 2));
4844
+ }
4845
+
4846
+ process.exitCode = totalErrors > 0 ? 1 : 0;
4847
+ return;
4848
+ }
4849
+
4850
+ // Validate specific file(s)
4851
+ for (const p of paths) {
4852
+ const resolved = path.resolve(p);
4853
+ if (!fs.existsSync(resolved)) {
4854
+ console.log(` ${c.red}✖ File not found: ${p}${c.reset}`);
4855
+ process.exitCode = 1;
4856
+ continue;
4857
+ }
4858
+
4859
+ const content = fs.readFileSync(resolved, 'utf8');
4860
+ const parsed = parseSkillFrontmatter(content);
4861
+ const { errors, warnings, info } = validateSkill(parsed);
4862
+ const name = info.name || path.basename(path.dirname(resolved));
4863
+
4864
+ console.log(` ${c.bold}${name}${c.reset} ${c.dim}${resolved}${c.reset}`);
4865
+ console.log();
4866
+
4867
+ if (errors.length === 0 && warnings.length === 0) {
4868
+ console.log(` ${c.green}✔ Valid skill format${c.reset}`);
4869
+ }
4870
+
4871
+ for (const err of errors) console.log(` ${c.red}✖ ${err}${c.reset}`);
4872
+ for (const warn of warnings) console.log(` ${c.yellow}⚠ ${warn}${c.reset}`);
4873
+
4874
+ if (info.name) console.log(` ${c.dim}name:${c.reset} ${info.name}`);
4875
+ if (info.description) console.log(` ${c.dim}description:${c.reset} ${info.description.slice(0, 80)}${info.description.length > 80 ? '...' : ''}`);
4876
+ if (info.allowedTools) console.log(` ${c.dim}allowed-tools:${c.reset} ${info.allowedTools.join(', ')}`);
4877
+ else if (info.allowedTools === null) console.log(` ${c.yellow}allowed-tools: none (unrestricted)${c.reset}`);
4878
+ if (info.mcpServers?.length > 0) console.log(` ${c.dim}MCP servers:${c.reset} ${info.mcpServers.join(', ')}`);
4879
+ if (info.bodyLines) console.log(` ${c.dim}body:${c.reset} ${info.bodyLines} lines`);
4880
+ console.log();
4881
+
4882
+ if (jsonMode) {
4883
+ console.log(JSON.stringify({ name: info.name, path: resolved, errors, warnings, info }, null, 2));
4884
+ }
4885
+
4886
+ process.exitCode = errors.length > 0 ? 1 : 0;
4887
+ }
4888
+ return;
4889
+ }
4890
+
3991
4891
  if (command === 'discover') {
3992
4892
  const scanFlag = targets.includes('--quick') || targets.includes('--scan') || targets.includes('-s');
3993
4893
  const auditFlag = targets.includes('--deep') || targets.includes('--audit') || targets.includes('-a');
@@ -4081,8 +4981,13 @@ async function main() {
4081
4981
 
4082
4982
  let hasFindings = false;
4083
4983
  for (const url of urls) {
4084
- const report = await auditRepo(url);
4085
- if (report?.findings?.length > 0) hasFindings = true;
4984
+ const result = await auditRepo(url);
4985
+ // Multi-model returns array, single-model returns object
4986
+ if (Array.isArray(result)) {
4987
+ if (result.some(r => r?.findings?.length > 0)) hasFindings = true;
4988
+ } else if (result?.findings?.length > 0) {
4989
+ hasFindings = true;
4990
+ }
4086
4991
  }
4087
4992
  process.exitCode = hasFindings ? 1 : 0;
4088
4993
  return;