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 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)
@@ -1 +1,5 @@
1
- export declare function audit(): Promise<void>;
1
+ interface AuditOptions {
2
+ check?: boolean;
3
+ }
4
+ export declare function audit(options?: AuditOptions): Promise<void>;
5
+ export {};
@@ -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
- writeFileSync(join(cwd, 'AUDIT_REPORT.md'), report);
465
- spinner.succeed('Audit complete');
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
- console.log(chalk.gray('\nGenerated: AUDIT_REPORT.md'));
495
- console.log(chalk.cyan('\nNext: npx skillsets submit'));
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
- console.log(chalk.gray('\nGenerated: AUDIT_REPORT.md'));
500
- console.log(chalk.cyan('\nRe-run after fixes: npx skillsets audit'));
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
  }
@@ -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 skill to .claude/skills/
230
+ // Install audit-skill from registry
231
+ spinner.text = 'Fetching audit-skill...';
424
232
  const skillDir = join(cwd, '.claude', 'skills', 'audit-skill');
425
- mkdirSync(skillDir, { recursive: true });
426
- writeFileSync(join(skillDir, 'SKILL.md'), AUDIT_SKILL_MD);
427
- writeFileSync(join(skillDir, 'CRITERIA.md'), AUDIT_CRITERIA_MD);
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'));
@@ -1,6 +1,7 @@
1
1
  interface InstallOptions {
2
2
  force?: boolean;
3
3
  backup?: boolean;
4
+ acceptMcp?: boolean;
4
5
  }
5
6
  export declare function install(skillsetId: string, options: InstallOptions): Promise<void>;
6
7
  export {};
@@ -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: true,
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
- .action(async () => {
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
+ }
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillsets",
3
- "version": "0.2.5",
3
+ "version": "0.3.0",
4
4
  "description": "CLI tool for discovering and installing verified skillsets",
5
5
  "type": "module",
6
6
  "bin": {