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.
- package/CHANGELOG.md +33 -0
- package/LICENSE +21 -0
- package/NOTICE.md +9 -0
- package/README.en.md +275 -0
- package/README.md +275 -0
- package/bin/agent-skill-doctor-phase2.js +250 -0
- package/bin/agent-skill-doctor-phase3.js +215 -0
- package/bin/agent-skill-doctor-risk.js +147 -0
- package/bin/agent-skill-doctor.js +1834 -0
- package/examples/basic-usage.js +113 -0
- package/examples/readme-demo-skills/dangerous-deploy/SKILL.md +14 -0
- package/examples/readme-demo-skills/markdown-reporter-a/SKILL.md +8 -0
- package/examples/readme-demo-skills/markdown-reporter-b/SKILL.md +8 -0
- package/examples/readme-demo-skills/npm-installer/SKILL.md +10 -0
- package/examples/readme-demo-skills/pnpm-installer/SKILL.md +10 -0
- package/package.json +58 -0
- package/rules/default/credential-risk.json +19 -0
- package/rules/default/destructive-risk.json +17 -0
- package/rules/default/shell-network-risk.json +31 -0
- package/src/doctor/conflict.js +139 -0
- package/src/doctor/i18n.js +572 -0
- package/src/doctor/index.js +50 -0
- package/src/doctor/phase2.js +261 -0
- package/src/doctor/risk-lite.js +124 -0
- package/src/doctor/rules.js +57 -0
- package/src/doctor/zombie.js +178 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Basic usage example for agent-skill-doctor library
|
|
5
|
+
*
|
|
6
|
+
* This example demonstrates how to use the library programmatically
|
|
7
|
+
* to analyze agent skills.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const {
|
|
11
|
+
detectDuplicateGroups,
|
|
12
|
+
detectVersionDrift,
|
|
13
|
+
detectConflicts,
|
|
14
|
+
detectZombies,
|
|
15
|
+
scanSkillForRisks,
|
|
16
|
+
loadJsonRules,
|
|
17
|
+
sha256,
|
|
18
|
+
DEFAULT_CONFLICT_RULES
|
|
19
|
+
} = require('agent-skill-doctor');
|
|
20
|
+
|
|
21
|
+
// Example skill data structure
|
|
22
|
+
const exampleSkills = [
|
|
23
|
+
{
|
|
24
|
+
id: 'skill-1',
|
|
25
|
+
name: 'npm-installer',
|
|
26
|
+
description: 'Install npm packages',
|
|
27
|
+
content: 'npm install <package>',
|
|
28
|
+
source: 'https://github.com/example/npm-installer',
|
|
29
|
+
hash: sha256('npm install <package>'),
|
|
30
|
+
tags: ['npm', 'installer'],
|
|
31
|
+
lastUpdated: '2026-01-15'
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: 'skill-2',
|
|
35
|
+
name: 'npm-installer-v2',
|
|
36
|
+
description: 'Install npm packages (v2)',
|
|
37
|
+
content: 'npm install <package>',
|
|
38
|
+
source: 'https://github.com/example/npm-installer',
|
|
39
|
+
hash: sha256('npm install <package>'),
|
|
40
|
+
tags: ['npm', 'installer'],
|
|
41
|
+
lastUpdated: '2026-02-20'
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
id: 'skill-3',
|
|
45
|
+
name: 'pnpm-installer',
|
|
46
|
+
description: 'Install packages with pnpm',
|
|
47
|
+
content: 'pnpm install <package>',
|
|
48
|
+
source: 'https://github.com/example/pnpm-installer',
|
|
49
|
+
hash: sha256('pnpm install <package>'),
|
|
50
|
+
tags: ['pnpm', 'installer'],
|
|
51
|
+
lastUpdated: '2026-03-10'
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: 'skill-4',
|
|
55
|
+
name: 'unused-skill',
|
|
56
|
+
description: 'An old unused skill',
|
|
57
|
+
content: 'some old command',
|
|
58
|
+
source: 'https://github.com/example/unused',
|
|
59
|
+
hash: sha256('some old command'),
|
|
60
|
+
tags: ['deprecated'],
|
|
61
|
+
lastUpdated: '2024-01-01'
|
|
62
|
+
}
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
console.log('=== Agent Skill Doctor - Basic Usage Example ===\n');
|
|
66
|
+
|
|
67
|
+
// 1. Detect duplicates
|
|
68
|
+
console.log('1. Detecting duplicates...');
|
|
69
|
+
const duplicates = detectDuplicateGroups(exampleSkills);
|
|
70
|
+
console.log(` Found ${duplicates.length} duplicate groups`);
|
|
71
|
+
duplicates.forEach((group, i) => {
|
|
72
|
+
console.log(` Group ${i + 1}: ${group.type} - ${group.skills.map(s => s.name).join(', ')}`);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// 2. Detect version drift
|
|
76
|
+
console.log('\n2. Detecting version drift...');
|
|
77
|
+
const drift = detectVersionDrift(exampleSkills);
|
|
78
|
+
console.log(` Found ${drift.length} version drift issues`);
|
|
79
|
+
drift.forEach((d, i) => {
|
|
80
|
+
console.log(` Drift ${i + 1}: ${d.source || d.slug} has ${d.hashes.length} different versions`);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// 3. Detect conflicts
|
|
84
|
+
console.log('\n3. Detecting conflicts...');
|
|
85
|
+
const conflicts = detectConflicts(exampleSkills, DEFAULT_CONFLICT_RULES);
|
|
86
|
+
console.log(` Found ${conflicts.length} conflicts`);
|
|
87
|
+
conflicts.forEach((c, i) => {
|
|
88
|
+
console.log(` Conflict ${i + 1}: ${c.type} between ${c.skills.map(s => s.name).join(' and ')}`);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// 4. Detect zombies
|
|
92
|
+
console.log('\n4. Detecting zombies...');
|
|
93
|
+
const zombies = detectZombies(exampleSkills);
|
|
94
|
+
console.log(` Found ${zombies.length} zombie candidates`);
|
|
95
|
+
zombies.forEach((z, i) => {
|
|
96
|
+
console.log(` Zombie ${i + 1}: ${z.title} (score: ${z.score}, level: ${z.level})`);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// 5. Scan for risks
|
|
100
|
+
console.log('\n5. Scanning for risks...');
|
|
101
|
+
const rulesDir = './rules/default';
|
|
102
|
+
const rules = loadJsonRules(rulesDir);
|
|
103
|
+
console.log(` Loaded ${rules.length} risk rules`);
|
|
104
|
+
|
|
105
|
+
exampleSkills.forEach(skill => {
|
|
106
|
+
const risks = scanSkillForRisks(skill, rules);
|
|
107
|
+
if (risks.length > 0) {
|
|
108
|
+
console.log(` ${skill.name}: ${risks.length} risks found`);
|
|
109
|
+
risks.forEach(r => console.log(` - ${r.ruleId}: ${r.message}`));
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
console.log('\n=== Example completed ===');
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: Dangerous Deploy
|
|
3
|
+
description: Deploy
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Dangerous Deploy
|
|
7
|
+
|
|
8
|
+
This demo intentionally includes risky text so Agent Skill Doctor can flag it.
|
|
9
|
+
|
|
10
|
+
Commands mentioned by this skill:
|
|
11
|
+
|
|
12
|
+
- rm -rf ./dist
|
|
13
|
+
- curl https://example.invalid/install.sh
|
|
14
|
+
- read .env before deployment
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: Package Installer
|
|
3
|
+
description: Use this skill when installing JavaScript dependencies with npm and generate install commands for the project.
|
|
4
|
+
source: https://github.com/example/package-installer.git
|
|
5
|
+
ref: v1.0.0
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Package Installer
|
|
9
|
+
|
|
10
|
+
Use npm install for dependency setup.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: Package Installer
|
|
3
|
+
description: Use this skill when installing JavaScript dependencies with pnpm and generate install commands for the project.
|
|
4
|
+
source: https://github.com/example/package-installer.git
|
|
5
|
+
ref: v2.0.0
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Package Installer
|
|
9
|
+
|
|
10
|
+
Use pnpm install for dependency setup.
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "agent-skill-doctor",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Diagnostic and governance CLI for AI agent skills",
|
|
5
|
+
"type": "commonjs",
|
|
6
|
+
"main": "./src/doctor/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/doctor/index.js",
|
|
9
|
+
"./phase2": "./src/doctor/phase2.js",
|
|
10
|
+
"./conflict": "./src/doctor/conflict.js",
|
|
11
|
+
"./zombie": "./src/doctor/zombie.js",
|
|
12
|
+
"./risk": "./src/doctor/risk-lite.js",
|
|
13
|
+
"./rules": "./src/doctor/rules.js",
|
|
14
|
+
"./i18n": "./src/doctor/i18n.js"
|
|
15
|
+
},
|
|
16
|
+
"bin": {
|
|
17
|
+
"agent-skill-doctor": "./bin/agent-skill-doctor.js",
|
|
18
|
+
"skill-doctor": "./bin/agent-skill-doctor.js"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"bin/",
|
|
22
|
+
"src/",
|
|
23
|
+
"rules/",
|
|
24
|
+
"examples/",
|
|
25
|
+
"README.md",
|
|
26
|
+
"LICENSE",
|
|
27
|
+
"NOTICE.md",
|
|
28
|
+
"CHANGELOG.md"
|
|
29
|
+
],
|
|
30
|
+
"scripts": {
|
|
31
|
+
"start": "node ./bin/agent-skill-doctor.js",
|
|
32
|
+
"scan": "node ./bin/agent-skill-doctor.js scan --json",
|
|
33
|
+
"diagnose": "node ./bin/agent-skill-doctor.js diagnose",
|
|
34
|
+
"report": "node ./bin/agent-skill-doctor.js report --format md",
|
|
35
|
+
"phase2": "node ./bin/agent-skill-doctor-phase2.js analyze",
|
|
36
|
+
"phase3": "node ./bin/agent-skill-doctor-phase3.js conflicts",
|
|
37
|
+
"risk": "node ./bin/agent-skill-doctor-risk.js scan",
|
|
38
|
+
"test": "node --test",
|
|
39
|
+
"publish:npm": "npm publish"
|
|
40
|
+
},
|
|
41
|
+
"keywords": [
|
|
42
|
+
"ai",
|
|
43
|
+
"agent",
|
|
44
|
+
"skill",
|
|
45
|
+
"diagnostic",
|
|
46
|
+
"governance",
|
|
47
|
+
"cli",
|
|
48
|
+
"sqlite"
|
|
49
|
+
],
|
|
50
|
+
"repository": {
|
|
51
|
+
"type": "git",
|
|
52
|
+
"url": "https://github.com/sljdxde/agent-skill-doctor.git"
|
|
53
|
+
},
|
|
54
|
+
"engines": {
|
|
55
|
+
"node": ">=22.5.0"
|
|
56
|
+
},
|
|
57
|
+
"license": "MIT"
|
|
58
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"rules": [
|
|
4
|
+
{
|
|
5
|
+
"id": "credential-risk",
|
|
6
|
+
"title": "Possible credential access",
|
|
7
|
+
"severity": "high",
|
|
8
|
+
"category": "credential",
|
|
9
|
+
"patterns": [
|
|
10
|
+
{ "id": "env-file", "text": ".env" },
|
|
11
|
+
{ "id": "openai-api-key", "text": "OPENAI_API_KEY" },
|
|
12
|
+
{ "id": "anthropic-api-key", "text": "ANTHROPIC_API_KEY" },
|
|
13
|
+
{ "id": "github-token", "text": "GITHUB_TOKEN" },
|
|
14
|
+
{ "id": "ssh-dir", "text": "~/.ssh" }
|
|
15
|
+
],
|
|
16
|
+
"recommendation": "Require explicit user review before enabling skills that reference credential locations or secret names."
|
|
17
|
+
}
|
|
18
|
+
]
|
|
19
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"rules": [
|
|
4
|
+
{
|
|
5
|
+
"id": "destructive-action-risk",
|
|
6
|
+
"title": "Possible destructive filesystem operation",
|
|
7
|
+
"severity": "critical",
|
|
8
|
+
"category": "destructive_action",
|
|
9
|
+
"patterns": [
|
|
10
|
+
{ "id": "recursive-remove", "text": "rm -rf" },
|
|
11
|
+
{ "id": "recursive-delete", "text": "delete recursively" },
|
|
12
|
+
{ "id": "overwrite-file", "text": "overwrite" }
|
|
13
|
+
],
|
|
14
|
+
"recommendation": "Require manual review and an explicit confirmation step before destructive file operations are allowed."
|
|
15
|
+
}
|
|
16
|
+
]
|
|
17
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"rules": [
|
|
4
|
+
{
|
|
5
|
+
"id": "shell-execution-risk",
|
|
6
|
+
"title": "Possible shell execution",
|
|
7
|
+
"severity": "medium",
|
|
8
|
+
"category": "shell",
|
|
9
|
+
"patterns": [
|
|
10
|
+
{ "id": "subprocess", "text": "subprocess" },
|
|
11
|
+
{ "id": "child-process", "text": "child_process" },
|
|
12
|
+
{ "id": "os-system", "text": "os.system" },
|
|
13
|
+
{ "id": "powershell", "text": "powershell" }
|
|
14
|
+
],
|
|
15
|
+
"recommendation": "Review shell execution instructions and require user confirmation before running commands."
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"id": "network-download-risk",
|
|
19
|
+
"title": "Possible remote download or installer execution",
|
|
20
|
+
"severity": "high",
|
|
21
|
+
"category": "network",
|
|
22
|
+
"patterns": [
|
|
23
|
+
{ "id": "curl-command", "text": "curl" },
|
|
24
|
+
{ "id": "wget-command", "text": "wget" },
|
|
25
|
+
{ "id": "remote-script", "text": "remote script" },
|
|
26
|
+
{ "id": "download-execute", "text": "download and execute" }
|
|
27
|
+
],
|
|
28
|
+
"recommendation": "Verify source URLs and avoid automatic execution of remotely downloaded content."
|
|
29
|
+
}
|
|
30
|
+
]
|
|
31
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('node:crypto');
|
|
4
|
+
const { buildSkillIdentityKey, buildParticipantIdentityKey } = require('./phase2');
|
|
5
|
+
|
|
6
|
+
function sha256(input) {
|
|
7
|
+
return crypto.createHash('sha256').update(String(input)).digest('hex');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function normalizeText(text) {
|
|
11
|
+
return String(text || '').toLowerCase().replace(/\s+/g, ' ').trim();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function normalizeAnchor(text) {
|
|
15
|
+
return normalizeText(text).slice(0, 300);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Extract all text content from a skill for matching.
|
|
20
|
+
*/
|
|
21
|
+
function skillTextContent(skill) {
|
|
22
|
+
const parts = [];
|
|
23
|
+
if (skill.description) parts.push(skill.description);
|
|
24
|
+
if (skill._scan?.text) parts.push(skill._scan.text);
|
|
25
|
+
if (skill.frontmatter) {
|
|
26
|
+
for (const v of Object.values(skill.frontmatter)) {
|
|
27
|
+
if (typeof v === 'string') parts.push(v);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return normalizeText(parts.join(' '));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Check which alternative index a skill matches, or -1 if none.
|
|
35
|
+
* Uses the best match strategy: checks all alternatives and returns
|
|
36
|
+
* the one with the longest matching keyword to avoid substring false positives.
|
|
37
|
+
*/
|
|
38
|
+
function matchAlternative(skillText, alternatives) {
|
|
39
|
+
const lower = skillText;
|
|
40
|
+
let bestIdx = -1;
|
|
41
|
+
let bestLen = 0;
|
|
42
|
+
for (let i = 0; i < alternatives.length; i++) {
|
|
43
|
+
const keywords = alternatives[i];
|
|
44
|
+
for (const kw of keywords) {
|
|
45
|
+
const normalized = normalizeText(kw);
|
|
46
|
+
if (lower.includes(normalized) && normalized.length > bestLen) {
|
|
47
|
+
bestIdx = i;
|
|
48
|
+
bestLen = normalized.length;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return bestIdx;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Detect conflicts among skills based on conflict rules.
|
|
57
|
+
* Each rule has `alternatives` - when skills match different alternatives, that's a conflict.
|
|
58
|
+
*
|
|
59
|
+
* @param {Array} skills - skill records
|
|
60
|
+
* @param {Array} conflictRules - rules from DEFAULT_CONFLICT_RULES or loaded from files
|
|
61
|
+
* @returns {Array} conflict findings
|
|
62
|
+
*/
|
|
63
|
+
function detectConflicts(skills, conflictRules) {
|
|
64
|
+
const findings = [];
|
|
65
|
+
|
|
66
|
+
for (const rule of conflictRules) {
|
|
67
|
+
const alternatives = rule.alternatives || [];
|
|
68
|
+
if (alternatives.length < 2) continue;
|
|
69
|
+
|
|
70
|
+
// Map: alternativeIndex -> [skills]
|
|
71
|
+
const buckets = new Map();
|
|
72
|
+
for (const skill of skills) {
|
|
73
|
+
const text = skillTextContent(skill);
|
|
74
|
+
const idx = matchAlternative(text, alternatives);
|
|
75
|
+
if (idx < 0) continue;
|
|
76
|
+
if (!buckets.has(idx)) buckets.set(idx, []);
|
|
77
|
+
buckets.get(idx).push(skill);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Need at least 2 different alternatives matched
|
|
81
|
+
const matchedAlts = [...buckets.entries()].filter(([, s]) => s.length > 0);
|
|
82
|
+
if (matchedAlts.length < 2) continue;
|
|
83
|
+
|
|
84
|
+
// Sort by alternative index for deterministic role assignment
|
|
85
|
+
matchedAlts.sort((a, b) => a[0] - b[0]);
|
|
86
|
+
|
|
87
|
+
// Collect all participating skills
|
|
88
|
+
const allSkills = [];
|
|
89
|
+
for (const [, bucketSkills] of matchedAlts) allSkills.push(...bucketSkills);
|
|
90
|
+
const sorted = [...allSkills].sort((a, b) =>
|
|
91
|
+
buildSkillIdentityKey(a).localeCompare(buildSkillIdentityKey(b))
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const participantKey = buildParticipantIdentityKey(sorted);
|
|
95
|
+
const conflictType = rule.type || 'opposite_instruction';
|
|
96
|
+
const reason = matchedAlts
|
|
97
|
+
.map(([altIdx, bucketSkills]) => {
|
|
98
|
+
const sortedSlugs = bucketSkills.map(s => s.slug).sort().join(', ');
|
|
99
|
+
return `alternative[${altIdx}] (${alternatives[altIdx].join(', ')}): ${sortedSlugs}`;
|
|
100
|
+
})
|
|
101
|
+
.join(' vs ');
|
|
102
|
+
|
|
103
|
+
const signature = sha256(`${conflictType}:${rule.id}:${normalizeAnchor(reason)}`);
|
|
104
|
+
const id = sha256(`${participantKey}:conflict:conflict-detector:${rule.id}:${signature}`);
|
|
105
|
+
|
|
106
|
+
// Assign roles: first two buckets get source/target, rest get related
|
|
107
|
+
const links = [];
|
|
108
|
+
for (let bucketIdx = 0; bucketIdx < matchedAlts.length; bucketIdx++) {
|
|
109
|
+
const [, bucketSkills] = matchedAlts[bucketIdx];
|
|
110
|
+
const role = bucketIdx === 0 ? 'source' : bucketIdx === 1 ? 'target' : 'related';
|
|
111
|
+
for (const s of bucketSkills) {
|
|
112
|
+
links.push({ skillId: s.id, role });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
findings.push({
|
|
117
|
+
id,
|
|
118
|
+
type: 'conflict',
|
|
119
|
+
severity: rule.severity || 'medium',
|
|
120
|
+
detectorId: 'conflict-detector',
|
|
121
|
+
ruleId: rule.id,
|
|
122
|
+
title: rule.title || `Conflict: ${conflictType}`,
|
|
123
|
+
description: `Detected conflict among skills: ${reason}`,
|
|
124
|
+
signature,
|
|
125
|
+
evidence: sorted.map(s => ({
|
|
126
|
+
file: s.location?.path || s.local_path || '',
|
|
127
|
+
text: `${s.slug}: ${s.description || '(no description)'}`,
|
|
128
|
+
anchor: normalizeAnchor(`${s.slug} ${s.description || ''}`),
|
|
129
|
+
})),
|
|
130
|
+
recommendation: rule.recommendation || 'Review conflicting skills and keep only one convention per project.',
|
|
131
|
+
skills: sorted,
|
|
132
|
+
links,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return findings;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
module.exports = { detectConflicts, matchAlternative, skillTextContent };
|