agent-skill-doctor 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,250 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('node:fs');
5
+ const os = require('node:os');
6
+ const path = require('node:path');
7
+ const { DatabaseSync } = require('node:sqlite');
8
+ const phase2 = require('../src/doctor/phase2');
9
+
10
+ function expandHome(p) {
11
+ if (!p) return p;
12
+ if (p === '~') return os.homedir();
13
+ if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
14
+ return p;
15
+ }
16
+
17
+ function home() {
18
+ return expandHome(process.env.AGENT_SKILL_DOCTOR_HOME || '~/.agent-skill-doctor');
19
+ }
20
+
21
+ function nowIso() {
22
+ return new Date().toISOString();
23
+ }
24
+
25
+ function parseArgs(argv) {
26
+ const args = { _: [] };
27
+ for (let i = 0; i < argv.length; i++) {
28
+ const token = argv[i];
29
+ if (!token.startsWith('--')) {
30
+ args._.push(token);
31
+ continue;
32
+ }
33
+ const key = token.slice(2);
34
+ const next = argv[i + 1];
35
+ if (next && !next.startsWith('--')) {
36
+ args[key] = next;
37
+ i += 1;
38
+ } else {
39
+ args[key] = true;
40
+ }
41
+ }
42
+ return args;
43
+ }
44
+
45
+ function openDb() {
46
+ const dbPath = path.join(home(), 'doctor.db');
47
+ if (!fs.existsSync(dbPath)) {
48
+ console.error(`doctor.db not found: ${dbPath}`);
49
+ console.error('Run Phase 1 first: node ./bin/agent-skill-doctor.js scan');
50
+ process.exit(3);
51
+ }
52
+ const db = new DatabaseSync(dbPath);
53
+ db.exec('PRAGMA foreign_keys = ON;');
54
+ db.exec(`
55
+ CREATE TABLE IF NOT EXISTS duplicate_groups (
56
+ id TEXT PRIMARY KEY,
57
+ strategy TEXT NOT NULL,
58
+ confidence REAL NOT NULL,
59
+ canonical_skill_id TEXT,
60
+ created_at TEXT NOT NULL,
61
+ updated_at TEXT NOT NULL,
62
+ FOREIGN KEY (canonical_skill_id) REFERENCES skill_records(id) ON DELETE SET NULL
63
+ );
64
+ CREATE INDEX IF NOT EXISTS idx_duplicate_groups_strategy ON duplicate_groups(strategy);
65
+ CREATE INDEX IF NOT EXISTS idx_duplicate_groups_canonical_skill_id ON duplicate_groups(canonical_skill_id);
66
+
67
+ CREATE TABLE IF NOT EXISTS duplicate_group_members (
68
+ group_id TEXT NOT NULL,
69
+ skill_id TEXT NOT NULL,
70
+ role TEXT NOT NULL DEFAULT 'candidate',
71
+ confidence REAL NOT NULL DEFAULT 1.0,
72
+ added_at TEXT NOT NULL,
73
+ PRIMARY KEY (group_id, skill_id),
74
+ FOREIGN KEY (group_id) REFERENCES duplicate_groups(id) ON DELETE CASCADE,
75
+ FOREIGN KEY (skill_id) REFERENCES skill_records(id) ON DELETE CASCADE
76
+ );
77
+ CREATE INDEX IF NOT EXISTS idx_duplicate_group_members_skill_id ON duplicate_group_members(skill_id);
78
+ CREATE INDEX IF NOT EXISTS idx_duplicate_group_members_added_at ON duplicate_group_members(added_at);
79
+ `);
80
+ return db;
81
+ }
82
+
83
+ function rowToSkill(row) {
84
+ const raw = row.raw_json ? JSON.parse(row.raw_json) : {};
85
+ return {
86
+ ...raw,
87
+ id: row.id,
88
+ upstreamSkillId: row.upstream_skill_id,
89
+ name: row.name,
90
+ slug: row.slug,
91
+ description: row.description,
92
+ source: raw.source || {
93
+ type: row.source_type,
94
+ url: row.source_url,
95
+ ref: row.source_ref,
96
+ commit: row.source_commit,
97
+ },
98
+ location: raw.location || {
99
+ path: row.local_path,
100
+ root: path.dirname(row.local_path),
101
+ rootType: row.root_type,
102
+ agent: row.agent,
103
+ },
104
+ hashes: raw.hashes || {
105
+ contentSha256: row.content_hash,
106
+ normalizedTextSha256: row.normalized_hash,
107
+ },
108
+ sourceRef: row.source_ref,
109
+ sourceCommit: row.source_commit,
110
+ modifiedAt: row.modified_at,
111
+ };
112
+ }
113
+
114
+ function listSkillObjects(db) {
115
+ return db.prepare('SELECT * FROM skill_records ORDER BY slug ASC').all().map(rowToSkill);
116
+ }
117
+
118
+ function upsertFinding(db, finding, links) {
119
+ const existing = db.prepare('SELECT id FROM findings WHERE id = ?').get(finding.id);
120
+ if (existing) {
121
+ db.prepare('UPDATE findings SET type=?, severity=?, detector_id=?, rule_id=?, title=?, description=?, signature=?, evidence_json=?, recommendation=?, updated_at=? WHERE id=?')
122
+ .run(finding.type, finding.severity, finding.detectorId, finding.ruleId || null, finding.title, finding.description, finding.signature, JSON.stringify(finding.evidence || []), finding.recommendation || null, finding.updatedAt, finding.id);
123
+ } else {
124
+ db.prepare('INSERT INTO findings (id, type, severity, detector_id, rule_id, title, description, signature, evidence_json, recommendation, ignored, ignored_reason, ignored_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, NULL, ?, ?)')
125
+ .run(finding.id, finding.type, finding.severity, finding.detectorId, finding.ruleId || null, finding.title, finding.description, finding.signature, JSON.stringify(finding.evidence || []), finding.recommendation || null, finding.createdAt, finding.updatedAt);
126
+ }
127
+ const stmt = db.prepare('INSERT OR IGNORE INTO finding_skills (finding_id, skill_id, role, added_at) VALUES (?, ?, ?, ?)');
128
+ for (const link of links) stmt.run(finding.id, link.skillId, link.role || 'related', nowIso());
129
+ }
130
+
131
+ function upsertDuplicateGroup(db, group) {
132
+ const at = nowIso();
133
+ db.prepare('INSERT INTO duplicate_groups (id, strategy, confidence, canonical_skill_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET strategy=excluded.strategy, confidence=excluded.confidence, canonical_skill_id=excluded.canonical_skill_id, updated_at=excluded.updated_at')
134
+ .run(group.id, group.strategy, group.confidence, group.canonicalSkillId || null, at, at);
135
+ const stmt = db.prepare('INSERT OR REPLACE INTO duplicate_group_members (group_id, skill_id, role, confidence, added_at) VALUES (?, ?, ?, ?, ?)');
136
+ for (const member of group.members) stmt.run(group.id, member.skillId, member.role, member.confidence, at);
137
+ }
138
+
139
+ function duplicateFindingFromGroup(group) {
140
+ const at = nowIso();
141
+ const signature = group.id;
142
+ return {
143
+ finding: {
144
+ id: `dup-${group.id}`,
145
+ type: 'duplicate',
146
+ severity: group.strategy === 'exact_duplicate' ? 'medium' : 'low',
147
+ detectorId: 'duplicate-detector',
148
+ ruleId: group.strategy,
149
+ title: `${group.strategy.replaceAll('_', ' ')} detected`,
150
+ description: `Detected ${group.skills.length} related skills. Canonical suggestion: ${group.canonicalSkillId}.`,
151
+ signature,
152
+ evidence: [{ file: 'multiple-skills', text: group.skills.map(s => `${s.slug}: ${s.location?.path || s.local_path}`).join('\n'), anchor: group.strategy }],
153
+ recommendation: 'Review the group and keep one canonical skill before disabling duplicates.',
154
+ createdAt: at,
155
+ updatedAt: at,
156
+ },
157
+ links: group.members.map(m => ({ skillId: m.skillId, role: m.role })),
158
+ };
159
+ }
160
+
161
+ function driftFindingToDbFinding(drift) {
162
+ const at = nowIso();
163
+ return {
164
+ finding: {
165
+ id: drift.id,
166
+ type: 'version_drift',
167
+ severity: drift.severity,
168
+ detectorId: drift.detectorId,
169
+ ruleId: drift.ruleId,
170
+ title: drift.title,
171
+ description: drift.description,
172
+ signature: drift.id,
173
+ evidence: [{ file: 'multiple-skills', text: JSON.stringify(drift.evidence, null, 2), anchor: drift.id }],
174
+ recommendation: drift.recommendation,
175
+ createdAt: at,
176
+ updatedAt: at,
177
+ },
178
+ links: drift.links,
179
+ };
180
+ }
181
+
182
+ function runAnalyze(args) {
183
+ const db = openDb();
184
+ const skills = listSkillObjects(db);
185
+ const groups = phase2.detectDuplicateGroups(skills);
186
+ const drifts = phase2.detectVersionDrift(skills);
187
+
188
+ for (const group of groups) {
189
+ upsertDuplicateGroup(db, group);
190
+ const made = duplicateFindingFromGroup(group);
191
+ upsertFinding(db, made.finding, made.links);
192
+ }
193
+ for (const drift of drifts) {
194
+ const made = driftFindingToDbFinding(drift);
195
+ upsertFinding(db, made.finding, made.links);
196
+ }
197
+
198
+ const summary = { skills: skills.length, duplicateGroups: groups.length, versionDriftFindings: drifts.length };
199
+ if (args.json) console.log(JSON.stringify({ summary, duplicateGroups: groups, versionDriftFindings: drifts }, null, 2));
200
+ else console.log(`Analyzed ${skills.length} skills. Duplicate groups: ${groups.length}. Version drift findings: ${drifts.length}.`);
201
+ }
202
+
203
+ function runDuplicates(args) {
204
+ const db = openDb();
205
+ const rows = db.prepare('SELECT * FROM duplicate_groups ORDER BY confidence DESC, strategy ASC').all();
206
+ const groups = rows.map(group => ({
207
+ ...group,
208
+ members: db.prepare('SELECT dgm.*, sr.slug, sr.name, sr.local_path FROM duplicate_group_members dgm JOIN skill_records sr ON sr.id = dgm.skill_id WHERE dgm.group_id = ? ORDER BY role ASC, sr.slug ASC').all(group.id),
209
+ }));
210
+ if (args.json) return console.log(JSON.stringify(groups, null, 2));
211
+ if (!groups.length) return console.log('No duplicate groups. Run: node ./bin/agent-skill-doctor-phase2.js analyze');
212
+ for (const group of groups) {
213
+ console.log(`${group.id} ${group.strategy} confidence=${group.confidence}`);
214
+ for (const member of group.members) console.log(` - ${member.role}: ${member.slug} ${member.local_path}`);
215
+ }
216
+ }
217
+
218
+ function runDrift(args) {
219
+ const db = openDb();
220
+ const rows = db.prepare("SELECT * FROM findings WHERE type = 'version_drift' ORDER BY updated_at DESC").all();
221
+ const findings = rows.map(row => ({
222
+ id: row.id,
223
+ severity: row.severity,
224
+ title: row.title,
225
+ description: row.description,
226
+ evidence: JSON.parse(row.evidence_json || '[]'),
227
+ skills: db.prepare('SELECT fs.*, sr.slug, sr.name, sr.local_path FROM finding_skills fs JOIN skill_records sr ON sr.id = fs.skill_id WHERE fs.finding_id = ? ORDER BY fs.role ASC, sr.slug ASC').all(row.id),
228
+ }));
229
+ if (args.json) return console.log(JSON.stringify(findings, null, 2));
230
+ if (!findings.length) return console.log('No version drift findings. Run: node ./bin/agent-skill-doctor-phase2.js analyze');
231
+ for (const finding of findings) {
232
+ console.log(`${finding.id} ${finding.title}`);
233
+ for (const skill of finding.skills) console.log(` - ${skill.role}: ${skill.slug} ${skill.local_path}`);
234
+ }
235
+ }
236
+
237
+ function usage() {
238
+ console.log(`agent-skill-doctor Phase 2 overlay\n\nCommands:\n analyze [--json]\n duplicates [--json]\n drift [--json]\n`);
239
+ }
240
+
241
+ function main() {
242
+ const args = parseArgs(process.argv.slice(2));
243
+ const command = args._[0] || 'help';
244
+ if (command === 'analyze') return runAnalyze(args);
245
+ if (command === 'duplicates') return runDuplicates(args);
246
+ if (command === 'drift') return runDrift(args);
247
+ usage();
248
+ }
249
+
250
+ main();
@@ -0,0 +1,215 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('node:fs');
5
+ const os = require('node:os');
6
+ const path = require('node:path');
7
+ const { DatabaseSync } = require('node:sqlite');
8
+ const { detectConflicts } = require('../src/doctor/conflict');
9
+ const { detectZombies } = require('../src/doctor/zombie');
10
+ const { DEFAULT_CONFLICT_RULES } = require('../src/doctor/rules');
11
+
12
+ function expandHome(p) {
13
+ if (!p) return p;
14
+ if (p === '~') return os.homedir();
15
+ if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
16
+ return p;
17
+ }
18
+
19
+ function home() {
20
+ return expandHome(process.env.AGENT_SKILL_DOCTOR_HOME || '~/.agent-skill-doctor');
21
+ }
22
+
23
+ function nowIso() {
24
+ return new Date().toISOString();
25
+ }
26
+
27
+ function parseArgs(argv) {
28
+ const args = { _: [] };
29
+ for (let i = 0; i < argv.length; i++) {
30
+ const token = argv[i];
31
+ if (!token.startsWith('--')) {
32
+ args._.push(token);
33
+ continue;
34
+ }
35
+ const key = token.slice(2);
36
+ const next = argv[i + 1];
37
+ if (next && !next.startsWith('--')) {
38
+ args[key] = next;
39
+ i += 1;
40
+ } else {
41
+ args[key] = true;
42
+ }
43
+ }
44
+ return args;
45
+ }
46
+
47
+ function openDb() {
48
+ const dbPath = path.join(home(), 'doctor.db');
49
+ if (!fs.existsSync(dbPath)) {
50
+ console.error(`doctor.db not found: ${dbPath}`);
51
+ console.error('Run Phase 1 first: node ./bin/agent-skill-doctor.js scan');
52
+ process.exit(3);
53
+ }
54
+ const db = new DatabaseSync(dbPath);
55
+ db.exec('PRAGMA foreign_keys = ON;');
56
+ return db;
57
+ }
58
+
59
+ function rowToSkill(row) {
60
+ const raw = row.raw_json ? JSON.parse(row.raw_json) : {};
61
+ return {
62
+ ...raw,
63
+ id: row.id,
64
+ upstreamSkillId: row.upstream_skill_id,
65
+ name: row.name,
66
+ slug: row.slug,
67
+ description: row.description,
68
+ source: raw.source || { type: row.source_type, url: row.source_url, ref: row.source_ref, commit: row.source_commit },
69
+ location: raw.location || { path: row.local_path, root: path.dirname(row.local_path), rootType: row.root_type, agent: row.agent },
70
+ hashes: raw.hashes || { contentSha256: row.content_hash, normalizedTextSha256: row.normalized_hash },
71
+ files: raw.files || [],
72
+ tags: raw.tags || [],
73
+ usage: raw.usage || { installedInAgents: [], installedInProjects: [], presetCount: 0, hasRecentModification: false, manuallyPinned: false },
74
+ frontmatter: raw.frontmatter || {},
75
+ _scan: raw._scan || {},
76
+ };
77
+ }
78
+
79
+ function listSkillObjects(db) {
80
+ return db.prepare('SELECT * FROM skill_records ORDER BY slug ASC').all().map(rowToSkill);
81
+ }
82
+
83
+ function upsertFinding(db, finding, links) {
84
+ const existing = db.prepare('SELECT id FROM findings WHERE id = ?').get(finding.id);
85
+ const at = nowIso();
86
+ if (existing) {
87
+ db.prepare('UPDATE findings SET type=?, severity=?, detector_id=?, rule_id=?, title=?, description=?, signature=?, evidence_json=?, recommendation=?, updated_at=? WHERE id=?')
88
+ .run(finding.type, finding.severity, finding.detectorId, finding.ruleId || null, finding.title, finding.description, finding.signature, JSON.stringify(finding.evidence || []), finding.recommendation || null, at, finding.id);
89
+ } else {
90
+ db.prepare('INSERT INTO findings (id, type, severity, detector_id, rule_id, title, description, signature, evidence_json, recommendation, ignored, ignored_reason, ignored_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, NULL, ?, ?)')
91
+ .run(finding.id, finding.type, finding.severity, finding.detectorId, finding.ruleId || null, finding.title, finding.description, finding.signature, JSON.stringify(finding.evidence || []), finding.recommendation || null, at, at);
92
+ }
93
+ const stmt = db.prepare('INSERT OR IGNORE INTO finding_skills (finding_id, skill_id, role, added_at) VALUES (?, ?, ?, ?)');
94
+ for (const link of links) stmt.run(finding.id, link.skillId, link.role || 'related', at);
95
+ }
96
+
97
+ function severityRank(severity) {
98
+ return { info: 0, low: 1, medium: 2, high: 3, critical: 4 }[severity] || 0;
99
+ }
100
+
101
+ function exitCodeForFindings(findings, failOn) {
102
+ const threshold = severityRank(failOn || 'high');
103
+ let maxSeverity = 0;
104
+ for (const f of findings) {
105
+ if (f.ignored) continue;
106
+ const rank = severityRank(f.severity);
107
+ if (rank >= threshold) maxSeverity = Math.max(maxSeverity, rank);
108
+ }
109
+ if (maxSeverity >= 4) return 2; // critical
110
+ if (maxSeverity >= threshold) return 1; // at or above threshold
111
+ return 0;
112
+ }
113
+
114
+ // --- Conflict ---
115
+
116
+ function runConflicts(args) {
117
+ const db = openDb();
118
+ const skills = listSkillObjects(db);
119
+ const findings = detectConflicts(skills, DEFAULT_CONFLICT_RULES);
120
+
121
+ for (const finding of findings) {
122
+ upsertFinding(db, finding, finding.links);
123
+ }
124
+
125
+ const summary = { skills: skills.length, conflictFindings: findings.length, bySeverity: {} };
126
+ for (const f of findings) summary.bySeverity[f.severity] = (summary.bySeverity[f.severity] || 0) + 1;
127
+
128
+ if (args.json) console.log(JSON.stringify({ summary, findings }, null, 2));
129
+ else {
130
+ console.log(`Conflict scan complete. Skills: ${skills.length}. Findings: ${findings.length}.`);
131
+ for (const f of findings) console.log(` ${f.severity} ${f.title}`);
132
+ }
133
+
134
+ if (args.ci) process.exit(exitCodeForFindings(findings, args['fail-on'] || 'high'));
135
+ }
136
+
137
+ function runConflictList(args) {
138
+ const db = openDb();
139
+ const rows = db.prepare("SELECT * FROM findings WHERE type = 'conflict' AND ignored = 0 ORDER BY severity DESC, updated_at DESC").all();
140
+ const findings = rows.map(row => ({
141
+ ...row,
142
+ evidence: JSON.parse(row.evidence_json || '[]'),
143
+ skills: db.prepare('SELECT fs.*, sr.slug, sr.name, sr.local_path FROM finding_skills fs JOIN skill_records sr ON sr.id = fs.skill_id WHERE fs.finding_id = ? ORDER BY fs.role ASC, sr.slug ASC').all(row.id),
144
+ }));
145
+ if (args.json) return console.log(JSON.stringify(findings, null, 2));
146
+ if (!findings.length) return console.log('No conflict findings.');
147
+ for (const f of findings) {
148
+ console.log(`${f.id} ${f.severity} ${f.title}`);
149
+ for (const s of f.skills) console.log(` - ${s.role}: ${s.slug} ${s.local_path}`);
150
+ }
151
+ }
152
+
153
+ // --- Zombie ---
154
+
155
+ function runZombies(args) {
156
+ const db = openDb();
157
+ const skills = listSkillObjects(db);
158
+ const findings = detectZombies(skills);
159
+
160
+ for (const finding of findings) {
161
+ upsertFinding(db, finding, finding.links);
162
+ }
163
+
164
+ const summary = { skills: skills.length, zombieCandidates: findings.length, byLevel: {} };
165
+ for (const f of findings) summary.byLevel[f.level || f.ruleId] = (summary.byLevel[f.level || f.ruleId] || 0) + 1;
166
+
167
+ if (args.json) console.log(JSON.stringify({ summary, findings }, null, 2));
168
+ else {
169
+ console.log(`Zombie scan complete. Skills: ${skills.length}. Candidates: ${findings.length}.`);
170
+ for (const f of findings) console.log(` ${f.severity} score=${(f.score || 0).toFixed(2)} ${f.title}`);
171
+ }
172
+
173
+ if (args.ci) process.exit(exitCodeForFindings(findings, args['fail-on'] || 'high'));
174
+ }
175
+
176
+ function runZombieList(args) {
177
+ const db = openDb();
178
+ const rows = db.prepare("SELECT * FROM findings WHERE type = 'zombie' AND ignored = 0 ORDER BY updated_at DESC").all();
179
+ const findings = rows.map(row => ({
180
+ ...row,
181
+ evidence: JSON.parse(row.evidence_json || '[]'),
182
+ skills: db.prepare('SELECT fs.*, sr.slug, sr.name, sr.local_path FROM finding_skills fs JOIN skill_records sr ON sr.id = fs.skill_id WHERE fs.finding_id = ? ORDER BY fs.role ASC, sr.slug ASC').all(row.id),
183
+ }));
184
+ if (args.json) return console.log(JSON.stringify(findings, null, 2));
185
+ if (!findings.length) return console.log('No zombie candidates.');
186
+ for (const f of findings) {
187
+ console.log(`${f.id} ${f.severity} ${f.title}`);
188
+ for (const s of f.skills) console.log(` - ${s.slug} ${s.local_path}`);
189
+ }
190
+ }
191
+
192
+ // --- Main ---
193
+
194
+ function usage() {
195
+ console.log(`agent-skill-doctor Phase 3 CLI (conflict + zombie)
196
+
197
+ Commands:
198
+ conflicts [--json] [--ci] [--fail-on high|critical|medium]
199
+ conflict-list [--json]
200
+ zombies [--json] [--ci] [--fail-on high|critical|medium]
201
+ zombie-list [--json]
202
+ `);
203
+ }
204
+
205
+ function main() {
206
+ const args = parseArgs(process.argv.slice(2));
207
+ const command = args._[0] || 'help';
208
+ if (command === 'conflicts') return runConflicts(args);
209
+ if (command === 'conflict-list') return runConflictList(args);
210
+ if (command === 'zombies') return runZombies(args);
211
+ if (command === 'zombie-list') return runZombieList(args);
212
+ usage();
213
+ }
214
+
215
+ main();
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('node:fs');
5
+ const os = require('node:os');
6
+ const path = require('node:path');
7
+ const { DatabaseSync } = require('node:sqlite');
8
+ const { loadJsonRules, scanSkillForRisks } = require('../src/doctor/risk-lite');
9
+
10
+ function expandHome(p) {
11
+ if (!p) return p;
12
+ if (p === '~') return os.homedir();
13
+ if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
14
+ return p;
15
+ }
16
+
17
+ function home() {
18
+ return expandHome(process.env.AGENT_SKILL_DOCTOR_HOME || '~/.agent-skill-doctor');
19
+ }
20
+
21
+ function nowIso() {
22
+ return new Date().toISOString();
23
+ }
24
+
25
+ function parseArgs(argv) {
26
+ const args = { _: [] };
27
+ for (let i = 0; i < argv.length; i++) {
28
+ const token = argv[i];
29
+ if (!token.startsWith('--')) {
30
+ args._.push(token);
31
+ continue;
32
+ }
33
+ const key = token.slice(2);
34
+ const next = argv[i + 1];
35
+ if (next && !next.startsWith('--')) {
36
+ args[key] = next;
37
+ i += 1;
38
+ } else {
39
+ args[key] = true;
40
+ }
41
+ }
42
+ return args;
43
+ }
44
+
45
+ function openDb() {
46
+ const dbPath = path.join(home(), 'doctor.db');
47
+ if (!fs.existsSync(dbPath)) {
48
+ console.error(`doctor.db not found: ${dbPath}`);
49
+ console.error('Run first: node ./bin/agent-skill-doctor.js scan');
50
+ process.exit(3);
51
+ }
52
+ const db = new DatabaseSync(dbPath);
53
+ db.exec('PRAGMA foreign_keys = ON;');
54
+ return db;
55
+ }
56
+
57
+ function rowToSkill(row) {
58
+ const raw = row.raw_json ? JSON.parse(row.raw_json) : {};
59
+ return {
60
+ ...raw,
61
+ id: row.id,
62
+ upstreamSkillId: row.upstream_skill_id,
63
+ name: row.name,
64
+ slug: row.slug,
65
+ description: row.description,
66
+ source: raw.source || { type: row.source_type, url: row.source_url, ref: row.source_ref, commit: row.source_commit },
67
+ location: raw.location || { path: row.local_path, root: path.dirname(row.local_path), rootType: row.root_type, agent: row.agent },
68
+ hashes: raw.hashes || { contentSha256: row.content_hash, normalizedTextSha256: row.normalized_hash },
69
+ files: raw.files || [],
70
+ };
71
+ }
72
+
73
+ function listSkillObjects(db) {
74
+ return db.prepare('SELECT * FROM skill_records ORDER BY slug ASC').all().map(rowToSkill);
75
+ }
76
+
77
+ function upsertFinding(db, finding) {
78
+ const existing = db.prepare('SELECT id FROM findings WHERE id = ?').get(finding.id);
79
+ const at = nowIso();
80
+ if (existing) {
81
+ db.prepare('UPDATE findings SET type=?, severity=?, detector_id=?, rule_id=?, title=?, description=?, signature=?, evidence_json=?, recommendation=?, updated_at=? WHERE id=?')
82
+ .run(finding.type, finding.severity, finding.detectorId, finding.ruleId || null, finding.title, finding.description, finding.signature, JSON.stringify(finding.evidence || []), finding.recommendation || null, at, finding.id);
83
+ } else {
84
+ db.prepare('INSERT INTO findings (id, type, severity, detector_id, rule_id, title, description, signature, evidence_json, recommendation, ignored, ignored_reason, ignored_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, NULL, ?, ?)')
85
+ .run(finding.id, finding.type, finding.severity, finding.detectorId, finding.ruleId || null, finding.title, finding.description, finding.signature, JSON.stringify(finding.evidence || []), finding.recommendation || null, at, at);
86
+ }
87
+ db.prepare('INSERT OR IGNORE INTO finding_skills (finding_id, skill_id, role, added_at) VALUES (?, ?, ?, ?)')
88
+ .run(finding.id, finding.skillId, 'primary', at);
89
+ }
90
+
91
+ function severityRank(severity) {
92
+ return { info: 0, low: 1, medium: 2, high: 3, critical: 4 }[severity] || 0;
93
+ }
94
+
95
+ function exitCodeForFindings(findings, failOn) {
96
+ const threshold = severityRank(failOn || 'high');
97
+ let hasHigh = false;
98
+ let hasCritical = false;
99
+ for (const finding of findings) {
100
+ if (finding.severity === 'critical' && severityRank(finding.severity) >= threshold) hasCritical = true;
101
+ if (finding.severity === 'high' && severityRank(finding.severity) >= threshold) hasHigh = true;
102
+ if (threshold <= 2 && finding.severity === 'medium') hasHigh = true;
103
+ }
104
+ if (hasCritical) return 2;
105
+ if (hasHigh) return 1;
106
+ return 0;
107
+ }
108
+
109
+ function runScan(args) {
110
+ const db = openDb();
111
+ const rulesDir = path.resolve(args.rules ? expandHome(args.rules) : path.join(process.cwd(), 'rules/default'));
112
+ const rules = loadJsonRules(rulesDir);
113
+ const skills = listSkillObjects(db);
114
+ const findings = [];
115
+ for (const skill of skills) {
116
+ const skillFindings = scanSkillForRisks(skill, rules);
117
+ for (const finding of skillFindings) upsertFinding(db, finding);
118
+ findings.push(...skillFindings);
119
+ }
120
+ const summary = { skills: skills.length, rules: rules.length, riskFindings: findings.length, bySeverity: {} };
121
+ for (const finding of findings) summary.bySeverity[finding.severity] = (summary.bySeverity[finding.severity] || 0) + 1;
122
+ if (args.json) console.log(JSON.stringify({ summary, findings }, null, 2));
123
+ else console.log(`Risk scan complete. Skills: ${skills.length}. Findings: ${findings.length}.`);
124
+ if (args.ci) process.exit(exitCodeForFindings(findings, args['fail-on'] || 'high'));
125
+ }
126
+
127
+ function runList(args) {
128
+ const db = openDb();
129
+ const rows = db.prepare("SELECT * FROM findings WHERE type = 'risk' AND ignored = 0 ORDER BY severity DESC, updated_at DESC").all();
130
+ if (args.json) return console.log(JSON.stringify(rows, null, 2));
131
+ if (!rows.length) return console.log('No risk findings.');
132
+ for (const row of rows) console.log(`${row.id} ${row.severity} ${row.rule_id} ${row.title}`);
133
+ }
134
+
135
+ function usage() {
136
+ console.log(`agent-skill-doctor risk scanner\n\nCommands:\n scan [--rules <dir>] [--json] [--ci] [--fail-on high|critical|medium]\n list [--json]\n`);
137
+ }
138
+
139
+ function main() {
140
+ const args = parseArgs(process.argv.slice(2));
141
+ const command = args._[0] || 'help';
142
+ if (command === 'scan') return runScan(args);
143
+ if (command === 'list') return runList(args);
144
+ usage();
145
+ }
146
+
147
+ main();