skillsets 0.2.5 → 0.3.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/README.md +4 -2
- package/dist/commands/audit.d.ts +5 -1
- package/dist/commands/audit.js +51 -11
- package/dist/commands/init.js +9 -198
- package/dist/commands/install.d.ts +1 -0
- package/dist/commands/install.js +61 -1
- package/dist/index.js +4 -2
- package/dist/lib/validate-mcp.d.ts +9 -0
- package/dist/lib/validate-mcp.js +294 -0
- package/dist/types/index.d.ts +20 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -21,6 +21,7 @@ cli/
|
|
|
21
21
|
│ │ ├── constants.ts
|
|
22
22
|
│ │ ├── errors.ts
|
|
23
23
|
│ │ ├── filesystem.ts
|
|
24
|
+
│ │ ├── validate-mcp.ts
|
|
24
25
|
│ │ └── versions.ts
|
|
25
26
|
│ └── types/
|
|
26
27
|
│ └── index.ts
|
|
@@ -41,9 +42,9 @@ cli/
|
|
|
41
42
|
|------|---------|---------------|
|
|
42
43
|
| `list.ts` | Browse all skillsets with live stats | [Docs](./docs_cli/commands/list.md) |
|
|
43
44
|
| `search.ts` | Fuzzy search by name, description, tags | [Docs](./docs_cli/commands/search.md) |
|
|
44
|
-
| `install.ts` | Install skillset via degit + verify checksums | [Docs](./docs_cli/commands/install.md) |
|
|
45
|
+
| `install.ts` | Install skillset via degit + MCP warning + verify checksums | [Docs](./docs_cli/commands/install.md) |
|
|
45
46
|
| `init.ts` | Scaffold new skillset for contribution | [Docs](./docs_cli/commands/init.md) |
|
|
46
|
-
| `audit.ts` | Validate skillset before submission | [Docs](./docs_cli/commands/audit.md) |
|
|
47
|
+
| `audit.ts` | Validate skillset + MCP servers before submission | [Docs](./docs_cli/commands/audit.md) |
|
|
47
48
|
| `submit.ts` | Open PR to registry | [Docs](./docs_cli/commands/submit.md) |
|
|
48
49
|
|
|
49
50
|
### Lib
|
|
@@ -55,6 +56,7 @@ cli/
|
|
|
55
56
|
| `errors.ts` | Error types | [Docs](./docs_cli/lib/errors.md) |
|
|
56
57
|
| `filesystem.ts` | File utilities | [Docs](./docs_cli/lib/filesystem.md) |
|
|
57
58
|
| `versions.ts` | Semver comparison | [Docs](./docs_cli/lib/versions.md) |
|
|
59
|
+
| `validate-mcp.ts` | MCP server bidirectional validation | [Docs](./docs_cli/lib/validate-mcp.md) |
|
|
58
60
|
|
|
59
61
|
## Related Documentation
|
|
60
62
|
- [CLI Style Guide](../.claude/resources/cli_styleguide.md)
|
package/dist/commands/audit.d.ts
CHANGED
package/dist/commands/audit.js
CHANGED
|
@@ -4,6 +4,7 @@ import { existsSync, readFileSync, readdirSync, statSync, writeFileSync, openSyn
|
|
|
4
4
|
import { join, relative } from 'path';
|
|
5
5
|
import yaml from 'js-yaml';
|
|
6
6
|
import { fetchSkillsetMetadata } from '../lib/api.js';
|
|
7
|
+
import { validateMcpServers } from '../lib/validate-mcp.js';
|
|
7
8
|
import { compareVersions } from '../lib/versions.js';
|
|
8
9
|
const MAX_FILE_SIZE = 1048576; // 1MB
|
|
9
10
|
const TEXT_EXTENSIONS = new Set([
|
|
@@ -165,7 +166,7 @@ function formatSize(bytes) {
|
|
|
165
166
|
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
166
167
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
167
168
|
}
|
|
168
|
-
function generateReport(results, cwd) {
|
|
169
|
+
function generateReport(results, cwd, enforceMcp = false) {
|
|
169
170
|
const timestamp = new Date().toISOString();
|
|
170
171
|
const allPassed = results.manifest.status === 'PASS' &&
|
|
171
172
|
results.requiredFiles.status === 'PASS' &&
|
|
@@ -173,7 +174,8 @@ function generateReport(results, cwd) {
|
|
|
173
174
|
results.fileSize.status !== 'FAIL' &&
|
|
174
175
|
results.secrets.status === 'PASS' &&
|
|
175
176
|
results.readmeLinks.status === 'PASS' &&
|
|
176
|
-
results.versionCheck.status === 'PASS'
|
|
177
|
+
results.versionCheck.status === 'PASS' &&
|
|
178
|
+
(enforceMcp ? results.mcpServers.status === 'PASS' : true);
|
|
177
179
|
const submissionType = results.isUpdate
|
|
178
180
|
? `Update (${results.existingVersion} → ${results.skillsetVersion})`
|
|
179
181
|
: 'New submission';
|
|
@@ -205,6 +207,7 @@ function generateReport(results, cwd) {
|
|
|
205
207
|
| Secret Detection | ${statusIcon(results.secrets.status)} | ${results.secrets.details} |
|
|
206
208
|
| README Links | ${statusIcon(results.readmeLinks.status)} | ${results.readmeLinks.details} |
|
|
207
209
|
| Version Check | ${statusIcon(results.versionCheck.status)} | ${results.versionCheck.details} |
|
|
210
|
+
| MCP Servers | ${statusIcon(results.mcpServers.status)} | ${results.mcpServers.details} |
|
|
208
211
|
|
|
209
212
|
---
|
|
210
213
|
|
|
@@ -249,6 +252,10 @@ ${results.readmeLinks.findings || 'All links use valid GitHub URLs.'}
|
|
|
249
252
|
|
|
250
253
|
${results.relativeLinks.length > 0 ? '**Relative Links Found:**\n' + results.relativeLinks.map(l => `- Line ${l.line}: ${l.link}`).join('\n') : ''}
|
|
251
254
|
|
|
255
|
+
### 8. MCP Server Validation
|
|
256
|
+
|
|
257
|
+
${results.mcpServers.findings || 'MCP server declarations are consistent between content and manifest.'}
|
|
258
|
+
|
|
252
259
|
---
|
|
253
260
|
|
|
254
261
|
## File Inventory
|
|
@@ -287,7 +294,7 @@ ${allPassed
|
|
|
287
294
|
`;
|
|
288
295
|
return report;
|
|
289
296
|
}
|
|
290
|
-
export async function audit() {
|
|
297
|
+
export async function audit(options = {}) {
|
|
291
298
|
const spinner = ora('Auditing skillset...').start();
|
|
292
299
|
const cwd = process.cwd();
|
|
293
300
|
const results = {
|
|
@@ -299,6 +306,7 @@ export async function audit() {
|
|
|
299
306
|
secrets: { status: 'PASS', details: '' },
|
|
300
307
|
versionCheck: { status: 'PASS', details: '' },
|
|
301
308
|
readmeLinks: { status: 'PASS', details: '' },
|
|
309
|
+
mcpServers: { status: 'PASS', details: '' },
|
|
302
310
|
isUpdate: false,
|
|
303
311
|
files: [],
|
|
304
312
|
largeFiles: [],
|
|
@@ -458,11 +466,34 @@ export async function audit() {
|
|
|
458
466
|
else {
|
|
459
467
|
results.versionCheck = { status: 'PASS', details: 'Skipped (no manifest)' };
|
|
460
468
|
}
|
|
469
|
+
// 9. MCP server validation
|
|
470
|
+
spinner.text = 'Validating MCP servers...';
|
|
471
|
+
const mcpResult = validateMcpServers(cwd);
|
|
472
|
+
if (mcpResult.valid) {
|
|
473
|
+
results.mcpServers = { status: 'PASS', details: 'MCP declarations valid' };
|
|
474
|
+
}
|
|
475
|
+
else if (options.check) {
|
|
476
|
+
results.mcpServers = {
|
|
477
|
+
status: 'FAIL',
|
|
478
|
+
details: `${mcpResult.errors.length} MCP error(s)`,
|
|
479
|
+
findings: mcpResult.errors.map(e => `- ${e}`).join('\n'),
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
else {
|
|
483
|
+
results.mcpServers = {
|
|
484
|
+
status: 'WARNING',
|
|
485
|
+
details: 'Pending qualitative review',
|
|
486
|
+
findings: 'MCP servers detected in content. The `/audit-skill` will populate `skillset.yaml` and CI will re-validate.\n\n' +
|
|
487
|
+
mcpResult.errors.map(e => `- ${e}`).join('\n'),
|
|
488
|
+
};
|
|
489
|
+
}
|
|
461
490
|
// Generate report
|
|
462
491
|
spinner.text = 'Generating audit report...';
|
|
463
|
-
const report = generateReport(results, cwd);
|
|
464
|
-
|
|
465
|
-
|
|
492
|
+
const report = generateReport(results, cwd, options.check);
|
|
493
|
+
if (!options.check) {
|
|
494
|
+
writeFileSync(join(cwd, 'AUDIT_REPORT.md'), report);
|
|
495
|
+
}
|
|
496
|
+
spinner.succeed(options.check ? 'Validation complete' : 'Audit complete');
|
|
466
497
|
// Summary
|
|
467
498
|
const allPassed = results.manifest.status === 'PASS' &&
|
|
468
499
|
results.requiredFiles.status === 'PASS' &&
|
|
@@ -470,7 +501,8 @@ export async function audit() {
|
|
|
470
501
|
results.fileSize.status !== 'FAIL' &&
|
|
471
502
|
results.secrets.status === 'PASS' &&
|
|
472
503
|
results.readmeLinks.status === 'PASS' &&
|
|
473
|
-
results.versionCheck.status === 'PASS'
|
|
504
|
+
results.versionCheck.status === 'PASS' &&
|
|
505
|
+
(options.check ? results.mcpServers.status === 'PASS' : true);
|
|
474
506
|
console.log('\n' + chalk.bold('Audit Summary:'));
|
|
475
507
|
console.log('');
|
|
476
508
|
const icon = (status) => {
|
|
@@ -488,15 +520,23 @@ export async function audit() {
|
|
|
488
520
|
console.log(` ${icon(results.secrets.status)} Secrets: ${results.secrets.details}`);
|
|
489
521
|
console.log(` ${icon(results.readmeLinks.status)} README Links: ${results.readmeLinks.details}`);
|
|
490
522
|
console.log(` ${icon(results.versionCheck.status)} Version: ${results.versionCheck.details}`);
|
|
523
|
+
console.log(` ${icon(results.mcpServers.status)} MCP Servers: ${results.mcpServers.details}`);
|
|
491
524
|
console.log('');
|
|
492
525
|
if (allPassed) {
|
|
493
526
|
console.log(chalk.green('✓ READY FOR SUBMISSION'));
|
|
494
|
-
|
|
495
|
-
|
|
527
|
+
if (!options.check) {
|
|
528
|
+
console.log(chalk.gray('\nGenerated: AUDIT_REPORT.md'));
|
|
529
|
+
console.log(chalk.cyan('\nNext: npx skillsets submit'));
|
|
530
|
+
}
|
|
496
531
|
}
|
|
497
532
|
else {
|
|
498
533
|
console.log(chalk.red('✗ NOT READY - Fix issues above'));
|
|
499
|
-
|
|
500
|
-
|
|
534
|
+
if (!options.check) {
|
|
535
|
+
console.log(chalk.gray('\nGenerated: AUDIT_REPORT.md'));
|
|
536
|
+
console.log(chalk.cyan('\nRe-run after fixes: npx skillsets audit'));
|
|
537
|
+
}
|
|
538
|
+
if (options.check) {
|
|
539
|
+
process.exit(1);
|
|
540
|
+
}
|
|
501
541
|
}
|
|
502
542
|
}
|
package/dist/commands/init.js
CHANGED
|
@@ -3,6 +3,7 @@ import ora from 'ora';
|
|
|
3
3
|
import { input, confirm, checkbox } from '@inquirer/prompts';
|
|
4
4
|
import { existsSync, mkdirSync, copyFileSync, readdirSync, writeFileSync } from 'fs';
|
|
5
5
|
import { join } from 'path';
|
|
6
|
+
import degit from 'degit';
|
|
6
7
|
const SKILLSET_YAML_TEMPLATE = `schema_version: "1.0"
|
|
7
8
|
|
|
8
9
|
# Identity
|
|
@@ -76,200 +77,6 @@ This skillset has been verified in production.
|
|
|
76
77
|
|
|
77
78
|
[List projects or products built using this skillset]
|
|
78
79
|
`;
|
|
79
|
-
const AUDIT_SKILL_MD = `---
|
|
80
|
-
name: audit-skill
|
|
81
|
-
description: Qualitative review of skillset content against Claude Code best practices. Evaluates all primitives (skills, agents, hooks, MCP, CLAUDE.md) for proper frontmatter, descriptions, and structure. Appends analysis to AUDIT_REPORT.md.
|
|
82
|
-
---
|
|
83
|
-
|
|
84
|
-
# Skillset Qualitative Audit
|
|
85
|
-
|
|
86
|
-
## Task
|
|
87
|
-
|
|
88
|
-
1. Verify \`AUDIT_REPORT.md\` shows "READY FOR SUBMISSION"
|
|
89
|
-
2. Identify all primitives in \`content/\`:
|
|
90
|
-
- Skills: \`**/SKILL.md\`
|
|
91
|
-
- Agents: \`**/AGENT.md\` or \`**/*.agent.md\`
|
|
92
|
-
- Hooks: \`**/hooks.json\`
|
|
93
|
-
- MCP: \`**/.mcp.json\` or \`**/mcp.json\`
|
|
94
|
-
- CLAUDE.md: \`CLAUDE.md\` or \`.claude/settings.json\`
|
|
95
|
-
3. Evaluate each against [CRITERIA.md](CRITERIA.md)
|
|
96
|
-
4. Append findings to \`AUDIT_REPORT.md\`
|
|
97
|
-
|
|
98
|
-
## Per-Primitive Evaluation
|
|
99
|
-
|
|
100
|
-
### Skills
|
|
101
|
-
- Frontmatter has \`name\` and \`description\`
|
|
102
|
-
- Description includes trigger phrases ("Use when...")
|
|
103
|
-
- Body under 500 lines
|
|
104
|
-
- \`allowed-tools\` if restricting access
|
|
105
|
-
- \`disable-model-invocation\` for side-effect commands
|
|
106
|
-
|
|
107
|
-
### Agents
|
|
108
|
-
- Description has \`<example>\` blocks
|
|
109
|
-
- System prompt has role, responsibilities, process, output format
|
|
110
|
-
- \`tools\` array if restricting access
|
|
111
|
-
|
|
112
|
-
### Hooks
|
|
113
|
-
- Valid JSON structure
|
|
114
|
-
- Matchers are specific (not just \`.*\`)
|
|
115
|
-
- Reasonable timeouts
|
|
116
|
-
- Prompts are actionable
|
|
117
|
-
|
|
118
|
-
### MCP
|
|
119
|
-
- Uses \`\${CLAUDE_PLUGIN_ROOT}\` for paths
|
|
120
|
-
- Env vars use \`\${VAR}\` syntax
|
|
121
|
-
- No hardcoded secrets
|
|
122
|
-
|
|
123
|
-
### CLAUDE.md
|
|
124
|
-
- Under 300 lines (check line count)
|
|
125
|
-
- Has WHAT/WHY/HOW sections
|
|
126
|
-
- Uses \`file:line\` pointers, not code snippets
|
|
127
|
-
- Progressive disclosure for large content
|
|
128
|
-
|
|
129
|
-
## Output
|
|
130
|
-
|
|
131
|
-
Append to \`AUDIT_REPORT.md\`:
|
|
132
|
-
|
|
133
|
-
\`\`\`markdown
|
|
134
|
-
---
|
|
135
|
-
|
|
136
|
-
## Qualitative Review
|
|
137
|
-
|
|
138
|
-
**Reviewed by:** Claude (Opus)
|
|
139
|
-
**Date:** [ISO timestamp]
|
|
140
|
-
|
|
141
|
-
### Primitives Found
|
|
142
|
-
|
|
143
|
-
| Type | Count | Files |
|
|
144
|
-
|------|-------|-------|
|
|
145
|
-
| Skills | N | [list] |
|
|
146
|
-
| Agents | N | [list] |
|
|
147
|
-
| Hooks | N | [list] |
|
|
148
|
-
| MCP | N | [list] |
|
|
149
|
-
| CLAUDE.md | Y/N | [path] |
|
|
150
|
-
|
|
151
|
-
### Issues
|
|
152
|
-
|
|
153
|
-
[List each issue with file:line and specific fix needed]
|
|
154
|
-
|
|
155
|
-
### Verdict
|
|
156
|
-
|
|
157
|
-
**[APPROVED / NEEDS REVISION]**
|
|
158
|
-
|
|
159
|
-
[If needs revision: prioritized list of must-fix items]
|
|
160
|
-
\`\`\`
|
|
161
|
-
`;
|
|
162
|
-
const AUDIT_CRITERIA_MD = `# Evaluation Criteria
|
|
163
|
-
|
|
164
|
-
Rubric for qualitative skillset review. Each primitive type has specific requirements.
|
|
165
|
-
|
|
166
|
-
---
|
|
167
|
-
|
|
168
|
-
## Skills (SKILL.md)
|
|
169
|
-
|
|
170
|
-
Skills and slash commands are now unified. File at \`.claude/skills/[name]/SKILL.md\` creates \`/name\`.
|
|
171
|
-
|
|
172
|
-
### Frontmatter Requirements
|
|
173
|
-
|
|
174
|
-
| Field | Required | Notes |
|
|
175
|
-
|-------|----------|-------|
|
|
176
|
-
| \`name\` | Yes | Becomes the \`/slash-command\`, lowercase with hyphens |
|
|
177
|
-
| \`description\` | Yes | **Critical for discoverability** - Claude uses this to decide when to load |
|
|
178
|
-
| \`version\` | No | Semver for tracking |
|
|
179
|
-
| \`allowed-tools\` | No | Restricts tool access (e.g., \`Read, Write, Bash(git:*)\`) |
|
|
180
|
-
| \`model\` | No | \`sonnet\`, \`opus\`, or \`haiku\` |
|
|
181
|
-
| \`disable-model-invocation\` | No | \`true\` = only user can invoke (for side-effect commands) |
|
|
182
|
-
| \`user-invocable\` | No | \`false\` = only Claude can invoke (background knowledge) |
|
|
183
|
-
|
|
184
|
-
### Description Quality
|
|
185
|
-
|
|
186
|
-
**GOOD:** Includes trigger phrases ("Use when reviewing PRs, checking vulnerabilities...")
|
|
187
|
-
**POOR:** Vague ("Helps with code review")
|
|
188
|
-
|
|
189
|
-
---
|
|
190
|
-
|
|
191
|
-
## Agents (AGENT.md)
|
|
192
|
-
|
|
193
|
-
### Frontmatter Requirements
|
|
194
|
-
|
|
195
|
-
| Field | Required | Notes |
|
|
196
|
-
|-------|----------|-------|
|
|
197
|
-
| \`name\` | Yes | Agent identifier |
|
|
198
|
-
| \`description\` | Yes | **Must include \`<example>\` blocks** for reliable triggering |
|
|
199
|
-
| \`model\` | No | \`inherit\`, \`sonnet\`, \`opus\`, \`haiku\` |
|
|
200
|
-
| \`color\` | No | UI color hint |
|
|
201
|
-
| \`tools\` | No | Array of allowed tools |
|
|
202
|
-
|
|
203
|
-
### System Prompt (Body)
|
|
204
|
-
|
|
205
|
-
- Clear role definition ("You are...")
|
|
206
|
-
- Core responsibilities numbered
|
|
207
|
-
- Process/workflow steps
|
|
208
|
-
- Expected output format
|
|
209
|
-
|
|
210
|
-
---
|
|
211
|
-
|
|
212
|
-
## Hooks (hooks.json)
|
|
213
|
-
|
|
214
|
-
### Event Types
|
|
215
|
-
|
|
216
|
-
| Event | When | Use For |
|
|
217
|
-
|-------|------|---------|
|
|
218
|
-
| \`PreToolUse\` | Before tool executes | Validation, security checks |
|
|
219
|
-
| \`PostToolUse\` | After tool completes | Feedback, logging |
|
|
220
|
-
| \`Stop\` | Task completion | Quality gates, notifications |
|
|
221
|
-
| \`SessionStart\` | Session begins | Context loading, env setup |
|
|
222
|
-
|
|
223
|
-
### Quality Checks
|
|
224
|
-
|
|
225
|
-
- Matchers are specific (avoid \`.*\` unless intentional)
|
|
226
|
-
- Timeouts are reasonable
|
|
227
|
-
- Prompts are concise and actionable
|
|
228
|
-
|
|
229
|
-
---
|
|
230
|
-
|
|
231
|
-
## MCP Servers (.mcp.json)
|
|
232
|
-
|
|
233
|
-
### Quality Checks
|
|
234
|
-
|
|
235
|
-
- Uses \`\${CLAUDE_PLUGIN_ROOT}\` for paths
|
|
236
|
-
- Environment variables use \`\${VAR}\` syntax
|
|
237
|
-
- Sensitive values reference env vars, not hardcoded
|
|
238
|
-
|
|
239
|
-
---
|
|
240
|
-
|
|
241
|
-
## CLAUDE.md
|
|
242
|
-
|
|
243
|
-
### Critical Constraints
|
|
244
|
-
|
|
245
|
-
- **Under 300 lines** (ideally <60)
|
|
246
|
-
- LLMs follow ~150-200 instructions; Claude Code system prompt uses ~50
|
|
247
|
-
|
|
248
|
-
### Required Content (WHAT, WHY, HOW)
|
|
249
|
-
|
|
250
|
-
- **WHAT**: Tech stack, project structure, codebase map
|
|
251
|
-
- **WHY**: Project purpose, component functions
|
|
252
|
-
- **HOW**: Dev workflows, tools, testing, verification
|
|
253
|
-
|
|
254
|
-
### What to Avoid
|
|
255
|
-
|
|
256
|
-
- Task-specific instructions
|
|
257
|
-
- Code style rules (use linters + hooks)
|
|
258
|
-
- Code snippets (use \`file:line\` pointers)
|
|
259
|
-
- Hardcoded dates/versions
|
|
260
|
-
|
|
261
|
-
---
|
|
262
|
-
|
|
263
|
-
## Verdict Rules
|
|
264
|
-
|
|
265
|
-
- **APPROVED**: All primitives meet requirements, minor issues only
|
|
266
|
-
- **NEEDS REVISION**: Missing required fields, poor descriptions, oversized files
|
|
267
|
-
|
|
268
|
-
Priority:
|
|
269
|
-
1. Missing/poor descriptions (affects discoverability)
|
|
270
|
-
2. Oversized CLAUDE.md (degrades all instructions)
|
|
271
|
-
3. Missing agent examples (unreliable triggering)
|
|
272
|
-
`;
|
|
273
80
|
function copyDirRecursive(src, dest) {
|
|
274
81
|
if (!existsSync(dest)) {
|
|
275
82
|
mkdirSync(dest, { recursive: true });
|
|
@@ -420,11 +227,15 @@ export async function init(options) {
|
|
|
420
227
|
// Generate PROOF.md
|
|
421
228
|
const proof = PROOF_TEMPLATE.replace('{{PRODUCTION_URL}}', productionUrl);
|
|
422
229
|
writeFileSync(join(cwd, 'PROOF.md'), proof);
|
|
423
|
-
// Install audit-skill
|
|
230
|
+
// Install audit-skill from registry
|
|
231
|
+
spinner.text = 'Fetching audit-skill...';
|
|
424
232
|
const skillDir = join(cwd, '.claude', 'skills', 'audit-skill');
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
233
|
+
const emitter = degit('skillsets-cc/main/tools/audit-skill', {
|
|
234
|
+
cache: false,
|
|
235
|
+
force: true,
|
|
236
|
+
verbose: false,
|
|
237
|
+
});
|
|
238
|
+
await emitter.clone(skillDir);
|
|
428
239
|
spinner.succeed('Skillset structure created');
|
|
429
240
|
// Summary
|
|
430
241
|
console.log(chalk.green('\n✓ Initialized skillset submission:\n'));
|
package/dist/commands/install.js
CHANGED
|
@@ -1,9 +1,39 @@
|
|
|
1
1
|
import degit from 'degit';
|
|
2
2
|
import chalk from 'chalk';
|
|
3
3
|
import ora from 'ora';
|
|
4
|
+
import { confirm } from '@inquirer/prompts';
|
|
4
5
|
import { detectConflicts, backupFiles } from '../lib/filesystem.js';
|
|
5
6
|
import { verifyChecksums } from '../lib/checksum.js';
|
|
7
|
+
import { fetchSkillsetMetadata } from '../lib/api.js';
|
|
6
8
|
import { REGISTRY_REPO, DOWNLOADS_URL } from '../lib/constants.js';
|
|
9
|
+
function formatMcpWarning(mcpServers, skillsetId) {
|
|
10
|
+
let output = chalk.yellow('\n⚠ This skillset includes MCP servers:\n');
|
|
11
|
+
const nativeServers = mcpServers.filter(s => s.type !== 'docker');
|
|
12
|
+
const dockerServers = mcpServers.filter(s => s.type === 'docker');
|
|
13
|
+
if (nativeServers.length > 0) {
|
|
14
|
+
output += chalk.white('\n Claude Code managed:\n');
|
|
15
|
+
for (const server of nativeServers) {
|
|
16
|
+
const detail = server.type === 'stdio'
|
|
17
|
+
? `(${server.command} ${(server.args || []).join(' ')})`
|
|
18
|
+
: `(${server.url})`;
|
|
19
|
+
output += ` ${server.type}: ${server.name} ${detail}\n`;
|
|
20
|
+
output += chalk.gray(` Reputation: ${server.mcp_reputation} (as of ${server.researched_at})\n`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
if (dockerServers.length > 0) {
|
|
24
|
+
output += chalk.white('\n Docker hosted:\n');
|
|
25
|
+
for (const server of dockerServers) {
|
|
26
|
+
output += ` image: ${server.image}\n`;
|
|
27
|
+
output += chalk.gray(` Reputation: ${server.mcp_reputation} (as of ${server.researched_at})\n`);
|
|
28
|
+
if (server.servers && server.servers.length > 0) {
|
|
29
|
+
output += ` Runs: ${server.servers.map(s => s.name).join(', ')}\n`;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
output += chalk.gray('\n MCP packages are fetched at runtime and may have changed since audit.\n');
|
|
34
|
+
output += chalk.cyan(`\n Review before installing:\n https://github.com/skillsets-cc/main/tree/main/skillsets/${skillsetId}/content\n`);
|
|
35
|
+
return output;
|
|
36
|
+
}
|
|
7
37
|
export async function install(skillsetId, options) {
|
|
8
38
|
const spinner = ora(`Installing ${skillsetId}...`).start();
|
|
9
39
|
// Check for conflicts
|
|
@@ -22,10 +52,40 @@ export async function install(skillsetId, options) {
|
|
|
22
52
|
spinner.text = 'Backing up existing files...';
|
|
23
53
|
await backupFiles(conflicts, process.cwd());
|
|
24
54
|
}
|
|
55
|
+
// Fetch metadata and check for MCP servers BEFORE degit
|
|
56
|
+
spinner.text = 'Fetching skillset metadata...';
|
|
57
|
+
try {
|
|
58
|
+
const metadata = await fetchSkillsetMetadata(skillsetId);
|
|
59
|
+
if (metadata?.mcp_servers && metadata.mcp_servers.length > 0) {
|
|
60
|
+
spinner.stop();
|
|
61
|
+
// Non-interactive check
|
|
62
|
+
if (!process.stdin.isTTY && !options.acceptMcp) {
|
|
63
|
+
console.log(chalk.red('This skillset includes MCP servers. Use --accept-mcp to install in non-interactive environments.'));
|
|
64
|
+
process.exit(1);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (!options.acceptMcp) {
|
|
68
|
+
console.log(formatMcpWarning(metadata.mcp_servers, skillsetId));
|
|
69
|
+
const accepted = await confirm({
|
|
70
|
+
message: 'Install MCP servers?',
|
|
71
|
+
default: false,
|
|
72
|
+
});
|
|
73
|
+
if (!accepted) {
|
|
74
|
+
console.log(chalk.gray('\nInstallation cancelled.'));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
spinner.start('Downloading skillset...');
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// If metadata fetch fails, continue without MCP check
|
|
83
|
+
// (registry might be down, don't block install)
|
|
84
|
+
}
|
|
25
85
|
// Install using degit (extract content/ subdirectory)
|
|
26
86
|
spinner.text = 'Downloading skillset...';
|
|
27
87
|
const emitter = degit(`${REGISTRY_REPO}/skillsets/${skillsetId}/content`, {
|
|
28
|
-
cache:
|
|
88
|
+
cache: false,
|
|
29
89
|
force: true,
|
|
30
90
|
verbose: false,
|
|
31
91
|
});
|
package/dist/index.js
CHANGED
|
@@ -46,6 +46,7 @@ program
|
|
|
46
46
|
.argument('<skillsetId>', 'Skillset ID (e.g., @user/skillset-name)')
|
|
47
47
|
.option('-f, --force', 'Overwrite existing files')
|
|
48
48
|
.option('-b, --backup', 'Backup existing files before install')
|
|
49
|
+
.option('--accept-mcp', 'Accept MCP servers without prompting (required for non-interactive environments)')
|
|
49
50
|
.action(async (skillsetId, options) => {
|
|
50
51
|
try {
|
|
51
52
|
await install(skillsetId, options);
|
|
@@ -70,9 +71,10 @@ program
|
|
|
70
71
|
program
|
|
71
72
|
.command('audit')
|
|
72
73
|
.description('Validate skillset and generate audit report')
|
|
73
|
-
.
|
|
74
|
+
.option('--check', 'Validate without writing AUDIT_REPORT.md (used by CI)')
|
|
75
|
+
.action(async (options) => {
|
|
74
76
|
try {
|
|
75
|
-
await audit();
|
|
77
|
+
await audit(options);
|
|
76
78
|
}
|
|
77
79
|
catch (error) {
|
|
78
80
|
handleError(error);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface McpValidationResult {
|
|
2
|
+
valid: boolean;
|
|
3
|
+
errors: string[];
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Validates MCP server declarations between content files and skillset.yaml.
|
|
7
|
+
* Bidirectional: content→manifest and manifest→content.
|
|
8
|
+
*/
|
|
9
|
+
export declare function validateMcpServers(skillsetDir: string): McpValidationResult;
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import yaml from 'js-yaml';
|
|
4
|
+
/**
|
|
5
|
+
* Validates MCP server declarations between content files and skillset.yaml.
|
|
6
|
+
* Bidirectional: content→manifest and manifest→content.
|
|
7
|
+
*/
|
|
8
|
+
export function validateMcpServers(skillsetDir) {
|
|
9
|
+
const errors = [];
|
|
10
|
+
// 1. Collect from content
|
|
11
|
+
const contentServers = collectContentServers(skillsetDir, errors);
|
|
12
|
+
// 2. Collect from manifest
|
|
13
|
+
const manifestServers = collectManifestServers(skillsetDir, errors);
|
|
14
|
+
// If we hit parse errors, return early
|
|
15
|
+
if (errors.length > 0) {
|
|
16
|
+
return { valid: false, errors };
|
|
17
|
+
}
|
|
18
|
+
// 3. No MCPs anywhere = pass
|
|
19
|
+
if (contentServers.length === 0 && manifestServers.length === 0) {
|
|
20
|
+
return { valid: true, errors: [] };
|
|
21
|
+
}
|
|
22
|
+
// 4. Content→manifest check
|
|
23
|
+
for (const cs of contentServers) {
|
|
24
|
+
const match = findManifestMatch(cs, manifestServers);
|
|
25
|
+
if (!match) {
|
|
26
|
+
errors.push(`MCP server '${cs.name}' found in content but not declared in skillset.yaml mcp_servers`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
// 5. Manifest→content check
|
|
30
|
+
for (const ms of manifestServers) {
|
|
31
|
+
if (ms.type === 'docker') {
|
|
32
|
+
// Check Docker inner servers
|
|
33
|
+
for (const inner of ms.servers || []) {
|
|
34
|
+
const match = contentServers.find(cs => cs.source === 'docker' && cs.name === inner.name);
|
|
35
|
+
if (!match) {
|
|
36
|
+
errors.push(`Docker inner server '${inner.name}' declared in manifest but not found in content docker config`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
// Check Docker image exists in compose
|
|
40
|
+
validateDockerImage(skillsetDir, ms.image, errors);
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
const match = contentServers.find(cs => cs.source === 'native' && cs.name === ms.name);
|
|
44
|
+
if (!match) {
|
|
45
|
+
errors.push(`MCP server '${ms.name}' declared in skillset.yaml but not found in content`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return { valid: errors.length === 0, errors };
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Collect MCP servers from content files (.mcp.json, .claude/settings.json, docker configs)
|
|
53
|
+
*/
|
|
54
|
+
function collectContentServers(skillsetDir, errors) {
|
|
55
|
+
const servers = [];
|
|
56
|
+
const contentDir = join(skillsetDir, 'content');
|
|
57
|
+
if (!existsSync(contentDir)) {
|
|
58
|
+
return servers;
|
|
59
|
+
}
|
|
60
|
+
// 1. Check .mcp.json
|
|
61
|
+
const mcpJsonPath = join(contentDir, '.mcp.json');
|
|
62
|
+
if (existsSync(mcpJsonPath)) {
|
|
63
|
+
try {
|
|
64
|
+
const content = readFileSync(mcpJsonPath, 'utf-8');
|
|
65
|
+
const data = JSON.parse(content);
|
|
66
|
+
if (data.mcpServers && typeof data.mcpServers === 'object') {
|
|
67
|
+
for (const [name, config] of Object.entries(data.mcpServers)) {
|
|
68
|
+
servers.push({
|
|
69
|
+
name,
|
|
70
|
+
source: 'native',
|
|
71
|
+
command: config.command,
|
|
72
|
+
args: config.args,
|
|
73
|
+
url: config.url,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
errors.push(`Failed to parse .mcp.json: ${error.message}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// 2. Check .claude/settings.json
|
|
83
|
+
const settingsPath = join(contentDir, '.claude', 'settings.json');
|
|
84
|
+
if (existsSync(settingsPath)) {
|
|
85
|
+
try {
|
|
86
|
+
const content = readFileSync(settingsPath, 'utf-8');
|
|
87
|
+
const data = JSON.parse(content);
|
|
88
|
+
if (data.mcpServers && typeof data.mcpServers === 'object') {
|
|
89
|
+
for (const [name, config] of Object.entries(data.mcpServers)) {
|
|
90
|
+
// Avoid duplicates (same name might be in both files)
|
|
91
|
+
if (!servers.some(s => s.name === name && s.source === 'native')) {
|
|
92
|
+
servers.push({
|
|
93
|
+
name,
|
|
94
|
+
source: 'native',
|
|
95
|
+
command: config.command,
|
|
96
|
+
args: config.args,
|
|
97
|
+
url: config.url,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
errors.push(`Failed to parse .claude/settings.json: ${error.message}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// 3. Check .claude/settings.local.json
|
|
108
|
+
const settingsLocalPath = join(contentDir, '.claude', 'settings.local.json');
|
|
109
|
+
if (existsSync(settingsLocalPath)) {
|
|
110
|
+
try {
|
|
111
|
+
const content = readFileSync(settingsLocalPath, 'utf-8');
|
|
112
|
+
const data = JSON.parse(content);
|
|
113
|
+
if (data.mcpServers && typeof data.mcpServers === 'object') {
|
|
114
|
+
for (const [name, config] of Object.entries(data.mcpServers)) {
|
|
115
|
+
if (!servers.some(s => s.name === name && s.source === 'native')) {
|
|
116
|
+
servers.push({
|
|
117
|
+
name,
|
|
118
|
+
source: 'native',
|
|
119
|
+
command: config.command,
|
|
120
|
+
args: config.args,
|
|
121
|
+
url: config.url,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
errors.push(`Failed to parse .claude/settings.local.json: ${error.message}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
// 4. Check docker/**/*.yaml and docker/**/*.yml for mcp_servers key
|
|
132
|
+
const dockerDir = join(contentDir, 'docker');
|
|
133
|
+
if (existsSync(dockerDir)) {
|
|
134
|
+
const yamlFiles = findYamlFiles(dockerDir);
|
|
135
|
+
for (const yamlPath of yamlFiles) {
|
|
136
|
+
try {
|
|
137
|
+
const content = readFileSync(yamlPath, 'utf-8');
|
|
138
|
+
const data = yaml.load(content);
|
|
139
|
+
if (data.mcp_servers && typeof data.mcp_servers === 'object') {
|
|
140
|
+
for (const [name, config] of Object.entries(data.mcp_servers)) {
|
|
141
|
+
// Avoid duplicates across multiple yaml files
|
|
142
|
+
if (!servers.some(s => s.name === name && s.source === 'docker')) {
|
|
143
|
+
servers.push({
|
|
144
|
+
name,
|
|
145
|
+
source: 'docker',
|
|
146
|
+
command: config.command,
|
|
147
|
+
args: config.args,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
errors.push(`Failed to parse ${yamlPath}: ${error.message}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return servers;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Collect MCP servers from skillset.yaml manifest
|
|
162
|
+
*/
|
|
163
|
+
function collectManifestServers(skillsetDir, errors) {
|
|
164
|
+
const manifestPath = join(skillsetDir, 'skillset.yaml');
|
|
165
|
+
if (!existsSync(manifestPath)) {
|
|
166
|
+
return [];
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
const content = readFileSync(manifestPath, 'utf-8');
|
|
170
|
+
const data = yaml.load(content);
|
|
171
|
+
if (!data.mcp_servers || !Array.isArray(data.mcp_servers)) {
|
|
172
|
+
return [];
|
|
173
|
+
}
|
|
174
|
+
return data.mcp_servers;
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
errors.push(`Failed to parse skillset.yaml: ${error.message}`);
|
|
178
|
+
return [];
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Find a matching manifest entry for a content server
|
|
183
|
+
*/
|
|
184
|
+
function findManifestMatch(contentServer, manifestServers) {
|
|
185
|
+
for (const ms of manifestServers) {
|
|
186
|
+
if (contentServer.source === 'native') {
|
|
187
|
+
// Native server: match by name and verify command/url
|
|
188
|
+
if (ms.name !== contentServer.name || ms.type === 'docker') {
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
// For stdio type, check command and args
|
|
192
|
+
if (ms.type === 'stdio') {
|
|
193
|
+
if (ms.command === contentServer.command) {
|
|
194
|
+
// Check args match (both undefined or same array)
|
|
195
|
+
const msArgs = ms.args || [];
|
|
196
|
+
const csArgs = contentServer.args || [];
|
|
197
|
+
if (arraysEqual(msArgs, csArgs)) {
|
|
198
|
+
return ms;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// For http type, check url
|
|
203
|
+
if (ms.type === 'http' && ms.url === contentServer.url) {
|
|
204
|
+
return ms;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
else if (contentServer.source === 'docker') {
|
|
208
|
+
// Docker server: match within servers array
|
|
209
|
+
if (ms.type === 'docker' && ms.servers) {
|
|
210
|
+
for (const inner of ms.servers) {
|
|
211
|
+
if (inner.name === contentServer.name) {
|
|
212
|
+
// Check command and args match
|
|
213
|
+
if (inner.command === contentServer.command) {
|
|
214
|
+
const innerArgs = inner.args || [];
|
|
215
|
+
const csArgs = contentServer.args || [];
|
|
216
|
+
if (arraysEqual(innerArgs, csArgs)) {
|
|
217
|
+
return ms;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return undefined;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Validate that a Docker image exists in any YAML file under docker/ (compose or otherwise)
|
|
229
|
+
*/
|
|
230
|
+
function validateDockerImage(skillsetDir, image, errors) {
|
|
231
|
+
const contentDir = join(skillsetDir, 'content');
|
|
232
|
+
const dockerDir = join(contentDir, 'docker');
|
|
233
|
+
if (!existsSync(dockerDir)) {
|
|
234
|
+
errors.push(`Docker image '${image}' declared but no docker directory found`);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const yamlFiles = findYamlFiles(dockerDir);
|
|
238
|
+
let found = false;
|
|
239
|
+
for (const yamlPath of yamlFiles) {
|
|
240
|
+
try {
|
|
241
|
+
const content = readFileSync(yamlPath, 'utf-8');
|
|
242
|
+
const data = yaml.load(content);
|
|
243
|
+
// Check if any service uses the specified image
|
|
244
|
+
if (data.services && typeof data.services === 'object') {
|
|
245
|
+
for (const service of Object.values(data.services)) {
|
|
246
|
+
if (service.image === image) {
|
|
247
|
+
found = true;
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
// Parse errors for compose files are non-fatal here — already caught in content scanning
|
|
255
|
+
}
|
|
256
|
+
if (found)
|
|
257
|
+
break;
|
|
258
|
+
}
|
|
259
|
+
if (!found) {
|
|
260
|
+
errors.push(`Docker image '${image}' not found in any YAML file under docker/`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Recursively find all YAML files (.yaml, .yml) in a directory
|
|
265
|
+
*/
|
|
266
|
+
function findYamlFiles(dir) {
|
|
267
|
+
const results = [];
|
|
268
|
+
if (!existsSync(dir)) {
|
|
269
|
+
return results;
|
|
270
|
+
}
|
|
271
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
272
|
+
for (const entry of entries) {
|
|
273
|
+
const fullPath = join(dir, entry.name);
|
|
274
|
+
if (entry.isDirectory()) {
|
|
275
|
+
results.push(...findYamlFiles(fullPath));
|
|
276
|
+
}
|
|
277
|
+
else if (entry.name.endsWith('.yaml') || entry.name.endsWith('.yml')) {
|
|
278
|
+
results.push(fullPath);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return results;
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Compare two arrays for equality
|
|
285
|
+
*/
|
|
286
|
+
function arraysEqual(a, b) {
|
|
287
|
+
if (a.length !== b.length)
|
|
288
|
+
return false;
|
|
289
|
+
for (let i = 0; i < a.length; i++) {
|
|
290
|
+
if (a[i] !== b[i])
|
|
291
|
+
return false;
|
|
292
|
+
}
|
|
293
|
+
return true;
|
|
294
|
+
}
|
package/dist/types/index.d.ts
CHANGED
|
@@ -31,6 +31,7 @@ export interface SearchIndexEntry {
|
|
|
31
31
|
entry_point: string;
|
|
32
32
|
checksum: string;
|
|
33
33
|
files: Record<string, string>;
|
|
34
|
+
mcp_servers?: McpServer[];
|
|
34
35
|
}
|
|
35
36
|
export interface StatsResponse {
|
|
36
37
|
stars: Record<string, number>;
|
|
@@ -60,4 +61,23 @@ export interface Skillset {
|
|
|
60
61
|
};
|
|
61
62
|
status: 'active' | 'deprecated' | 'archived';
|
|
62
63
|
entry_point: string;
|
|
64
|
+
mcp_servers?: McpServer[];
|
|
65
|
+
}
|
|
66
|
+
export interface McpServerInner {
|
|
67
|
+
name: string;
|
|
68
|
+
command: string;
|
|
69
|
+
args?: string[];
|
|
70
|
+
mcp_reputation: string;
|
|
71
|
+
researched_at: string;
|
|
72
|
+
}
|
|
73
|
+
export interface McpServer {
|
|
74
|
+
name: string;
|
|
75
|
+
type: 'stdio' | 'http' | 'docker';
|
|
76
|
+
command?: string;
|
|
77
|
+
args?: string[];
|
|
78
|
+
url?: string;
|
|
79
|
+
image?: string;
|
|
80
|
+
servers?: McpServerInner[];
|
|
81
|
+
mcp_reputation: string;
|
|
82
|
+
researched_at: string;
|
|
63
83
|
}
|