skillsets 0.2.4 → 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
@@ -1,62 +1,62 @@
1
1
  # Skillsets CLI
2
2
 
3
+ ## Purpose
3
4
  Command-line tool for discovering, installing, and contributing verified Claude Code skillsets.
4
5
 
5
- ## Quick Start
6
-
7
- ```bash
8
- # Browse available skillsets
9
- npx skillsets list
10
-
11
- # Sort by popularity
12
- npx skillsets list --sort downloads
13
-
14
- # Search by keyword
15
- npx skillsets search "sdlc"
16
-
17
- # Install a skillset
18
- npx skillsets install @supercollectible/The_Skillset
6
+ ## Architecture
19
7
  ```
20
-
21
- ## Commands
22
-
23
- | Command | Purpose |
24
- |---------|---------|
25
- | `list` | Browse all skillsets with live star/download counts |
26
- | `search <query>` | Fuzzy search by name, description, or tags |
27
- | `install <id>` | Install skillset to current directory (verifies checksums) |
28
- | `init` | Scaffold a new skillset for contribution |
29
- | `audit` | Validate skillset before submission |
30
- | `submit` | Open PR to registry (requires `gh` CLI) |
31
-
32
- ## Options
33
-
34
- ### list
35
- - `-l, --limit <n>` - Limit number of results
36
- - `-s, --sort <field>` - Sort by: `name`, `stars`, `downloads`, `recent`
37
- - `--json` - Output as JSON
38
-
39
- ### search
40
- - `-t, --tags <tags...>` - Filter by tags
41
- - `-l, --limit <n>` - Limit results (default: 10)
42
-
43
- ### install
44
- - `-f, --force` - Overwrite existing files
45
- - `-b, --backup` - Backup existing files before install
46
-
47
- ## Live Stats
48
-
49
- The CLI fetches live star and download counts from the API, so you always see current numbers (not stale build-time data).
50
-
51
- ## Development
52
-
53
- ```bash
54
- npm install # Install dependencies
55
- npm run build # Build TypeScript
56
- npm test # Run tests
8
+ cli/
9
+ ├── src/
10
+ │ ├── index.ts # CLI entry point
11
+ │ ├── commands/ # Command implementations
12
+ │ │ ├── list.ts
13
+ │ │ ├── search.ts
14
+ │ │ ├── install.ts
15
+ │ │ ├── init.ts
16
+ │ │ ├── audit.ts
17
+ │ │ └── submit.ts
18
+ │ ├── lib/ # Shared utilities
19
+ │ │ ├── api.ts
20
+ │ │ ├── checksum.ts
21
+ │ │ ├── constants.ts
22
+ │ │ ├── errors.ts
23
+ │ │ ├── filesystem.ts
24
+ │ │ ├── validate-mcp.ts
25
+ │ │ └── versions.ts
26
+ │ └── types/
27
+ │ └── index.ts
28
+ └── docs_cli/ # Documentation
29
+ ├── ARC_cli.md
30
+ ├── commands/
31
+ └── lib/
57
32
  ```
58
33
 
59
- ## Documentation
60
-
61
- - [CLI Style Guide](../.claude/resources/cli_styleguide.md) - Development patterns
62
- - [CLAUDE.md](../CLAUDE.md) - Project overview
34
+ ## Files
35
+
36
+ | File | Purpose | Documentation |
37
+ |------|---------|---------------|
38
+ | — | Architecture, data flow, key patterns | [ARC_cli.md](./docs_cli/ARC_cli.md) |
39
+
40
+ ### Commands
41
+ | File | Purpose | Documentation |
42
+ |------|---------|---------------|
43
+ | `list.ts` | Browse all skillsets with live stats | [Docs](./docs_cli/commands/list.md) |
44
+ | `search.ts` | Fuzzy search by name, description, tags | [Docs](./docs_cli/commands/search.md) |
45
+ | `install.ts` | Install skillset via degit + MCP warning + verify checksums | [Docs](./docs_cli/commands/install.md) |
46
+ | `init.ts` | Scaffold new skillset for contribution | [Docs](./docs_cli/commands/init.md) |
47
+ | `audit.ts` | Validate skillset + MCP servers before submission | [Docs](./docs_cli/commands/audit.md) |
48
+ | `submit.ts` | Open PR to registry | [Docs](./docs_cli/commands/submit.md) |
49
+
50
+ ### Lib
51
+ | File | Purpose | Documentation |
52
+ |------|---------|---------------|
53
+ | `api.ts` | API client for skillsets.cc | [Docs](./docs_cli/lib/api.md) |
54
+ | `checksum.ts` | SHA-256 verification | [Docs](./docs_cli/lib/checksum.md) |
55
+ | `constants.ts` | Shared constants | [Docs](./docs_cli/lib/constants.md) |
56
+ | `errors.ts` | Error types | [Docs](./docs_cli/lib/errors.md) |
57
+ | `filesystem.ts` | File utilities | [Docs](./docs_cli/lib/filesystem.md) |
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) |
60
+
61
+ ## Related Documentation
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 {};
@@ -1,26 +1,11 @@
1
1
  import chalk from 'chalk';
2
2
  import ora from 'ora';
3
- import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'fs';
3
+ import { existsSync, readFileSync, readdirSync, statSync, writeFileSync, openSync, readSync, closeSync } from 'fs';
4
4
  import { join, relative } from 'path';
5
5
  import yaml from 'js-yaml';
6
6
  import { fetchSkillsetMetadata } from '../lib/api.js';
7
- /**
8
- * Compare semver versions. Returns:
9
- * -1 if a < b, 0 if a == b, 1 if a > b
10
- */
11
- function compareVersions(a, b) {
12
- const partsA = a.split('.').map(Number);
13
- const partsB = b.split('.').map(Number);
14
- for (let i = 0; i < 3; i++) {
15
- const numA = partsA[i] || 0;
16
- const numB = partsB[i] || 0;
17
- if (numA < numB)
18
- return -1;
19
- if (numA > numB)
20
- return 1;
21
- }
22
- return 0;
23
- }
7
+ import { validateMcpServers } from '../lib/validate-mcp.js';
8
+ import { compareVersions } from '../lib/versions.js';
24
9
  const MAX_FILE_SIZE = 1048576; // 1MB
25
10
  const TEXT_EXTENSIONS = new Set([
26
11
  '.md', '.txt', '.json', '.yaml', '.yml',
@@ -69,9 +54,9 @@ function isBinaryFile(filePath) {
69
54
  // Check for null bytes in first 512 bytes
70
55
  try {
71
56
  const buffer = Buffer.alloc(512);
72
- const fd = require('fs').openSync(filePath, 'r');
73
- require('fs').readSync(fd, buffer, 0, 512, 0);
74
- require('fs').closeSync(fd);
57
+ const fd = openSync(filePath, 'r');
58
+ readSync(fd, buffer, 0, 512, 0);
59
+ closeSync(fd);
75
60
  return buffer.includes(0);
76
61
  }
77
62
  catch {
@@ -152,8 +137,8 @@ function validateManifest(cwd) {
152
137
  if (!data.author?.handle || !/^@[A-Za-z0-9_-]+$/.test(data.author.handle)) {
153
138
  errors.push('author.handle must start with @ (e.g., @username)');
154
139
  }
155
- if (!data.verification?.production_url) {
156
- errors.push('verification.production_url is required');
140
+ if (!Array.isArray(data.verification?.production_links) || data.verification.production_links.length === 0) {
141
+ errors.push('verification.production_links must be an array with at least one entry');
157
142
  }
158
143
  if (!data.verification?.audit_report) {
159
144
  errors.push('verification.audit_report is required');
@@ -181,7 +166,7 @@ function formatSize(bytes) {
181
166
  return `${(bytes / 1024).toFixed(1)} KB`;
182
167
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
183
168
  }
184
- function generateReport(results, cwd) {
169
+ function generateReport(results, cwd, enforceMcp = false) {
185
170
  const timestamp = new Date().toISOString();
186
171
  const allPassed = results.manifest.status === 'PASS' &&
187
172
  results.requiredFiles.status === 'PASS' &&
@@ -189,7 +174,8 @@ function generateReport(results, cwd) {
189
174
  results.fileSize.status !== 'FAIL' &&
190
175
  results.secrets.status === 'PASS' &&
191
176
  results.readmeLinks.status === 'PASS' &&
192
- results.versionCheck.status === 'PASS';
177
+ results.versionCheck.status === 'PASS' &&
178
+ (enforceMcp ? results.mcpServers.status === 'PASS' : true);
193
179
  const submissionType = results.isUpdate
194
180
  ? `Update (${results.existingVersion} → ${results.skillsetVersion})`
195
181
  : 'New submission';
@@ -221,6 +207,7 @@ function generateReport(results, cwd) {
221
207
  | Secret Detection | ${statusIcon(results.secrets.status)} | ${results.secrets.details} |
222
208
  | README Links | ${statusIcon(results.readmeLinks.status)} | ${results.readmeLinks.details} |
223
209
  | Version Check | ${statusIcon(results.versionCheck.status)} | ${results.versionCheck.details} |
210
+ | MCP Servers | ${statusIcon(results.mcpServers.status)} | ${results.mcpServers.details} |
224
211
 
225
212
  ---
226
213
 
@@ -265,6 +252,10 @@ ${results.readmeLinks.findings || 'All links use valid GitHub URLs.'}
265
252
 
266
253
  ${results.relativeLinks.length > 0 ? '**Relative Links Found:**\n' + results.relativeLinks.map(l => `- Line ${l.line}: ${l.link}`).join('\n') : ''}
267
254
 
255
+ ### 8. MCP Server Validation
256
+
257
+ ${results.mcpServers.findings || 'MCP server declarations are consistent between content and manifest.'}
258
+
268
259
  ---
269
260
 
270
261
  ## File Inventory
@@ -303,7 +294,7 @@ ${allPassed
303
294
  `;
304
295
  return report;
305
296
  }
306
- export async function audit() {
297
+ export async function audit(options = {}) {
307
298
  const spinner = ora('Auditing skillset...').start();
308
299
  const cwd = process.cwd();
309
300
  const results = {
@@ -315,6 +306,7 @@ export async function audit() {
315
306
  secrets: { status: 'PASS', details: '' },
316
307
  versionCheck: { status: 'PASS', details: '' },
317
308
  readmeLinks: { status: 'PASS', details: '' },
309
+ mcpServers: { status: 'PASS', details: '' },
318
310
  isUpdate: false,
319
311
  files: [],
320
312
  largeFiles: [],
@@ -474,11 +466,34 @@ export async function audit() {
474
466
  else {
475
467
  results.versionCheck = { status: 'PASS', details: 'Skipped (no manifest)' };
476
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
+ }
477
490
  // Generate report
478
491
  spinner.text = 'Generating audit report...';
479
- const report = generateReport(results, cwd);
480
- writeFileSync(join(cwd, 'AUDIT_REPORT.md'), report);
481
- 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');
482
497
  // Summary
483
498
  const allPassed = results.manifest.status === 'PASS' &&
484
499
  results.requiredFiles.status === 'PASS' &&
@@ -486,7 +501,8 @@ export async function audit() {
486
501
  results.fileSize.status !== 'FAIL' &&
487
502
  results.secrets.status === 'PASS' &&
488
503
  results.readmeLinks.status === 'PASS' &&
489
- results.versionCheck.status === 'PASS';
504
+ results.versionCheck.status === 'PASS' &&
505
+ (options.check ? results.mcpServers.status === 'PASS' : true);
490
506
  console.log('\n' + chalk.bold('Audit Summary:'));
491
507
  console.log('');
492
508
  const icon = (status) => {
@@ -504,15 +520,23 @@ export async function audit() {
504
520
  console.log(` ${icon(results.secrets.status)} Secrets: ${results.secrets.details}`);
505
521
  console.log(` ${icon(results.readmeLinks.status)} README Links: ${results.readmeLinks.details}`);
506
522
  console.log(` ${icon(results.versionCheck.status)} Version: ${results.versionCheck.details}`);
523
+ console.log(` ${icon(results.mcpServers.status)} MCP Servers: ${results.mcpServers.details}`);
507
524
  console.log('');
508
525
  if (allPassed) {
509
526
  console.log(chalk.green('✓ READY FOR SUBMISSION'));
510
- console.log(chalk.gray('\nGenerated: AUDIT_REPORT.md'));
511
- 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
+ }
512
531
  }
513
532
  else {
514
533
  console.log(chalk.red('✗ NOT READY - Fix issues above'));
515
- console.log(chalk.gray('\nGenerated: AUDIT_REPORT.md'));
516
- 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
+ }
517
541
  }
518
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
@@ -16,7 +17,8 @@ author:
16
17
 
17
18
  # Verification
18
19
  verification:
19
- production_url: "{{PRODUCTION_URL}}"
20
+ production_links:
21
+ - url: "{{PRODUCTION_URL}}"
20
22
  production_proof: "./PROOF.md"
21
23
  audit_report: "./AUDIT_REPORT.md"
22
24
 
@@ -75,200 +77,6 @@ This skillset has been verified in production.
75
77
 
76
78
  [List projects or products built using this skillset]
77
79
  `;
78
- const AUDIT_SKILL_MD = `---
79
- name: audit-skill
80
- 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.
81
- ---
82
-
83
- # Skillset Qualitative Audit
84
-
85
- ## Task
86
-
87
- 1. Verify \`AUDIT_REPORT.md\` shows "READY FOR SUBMISSION"
88
- 2. Identify all primitives in \`content/\`:
89
- - Skills: \`**/SKILL.md\`
90
- - Agents: \`**/AGENT.md\` or \`**/*.agent.md\`
91
- - Hooks: \`**/hooks.json\`
92
- - MCP: \`**/.mcp.json\` or \`**/mcp.json\`
93
- - CLAUDE.md: \`CLAUDE.md\` or \`.claude/settings.json\`
94
- 3. Evaluate each against [CRITERIA.md](CRITERIA.md)
95
- 4. Append findings to \`AUDIT_REPORT.md\`
96
-
97
- ## Per-Primitive Evaluation
98
-
99
- ### Skills
100
- - Frontmatter has \`name\` and \`description\`
101
- - Description includes trigger phrases ("Use when...")
102
- - Body under 500 lines
103
- - \`allowed-tools\` if restricting access
104
- - \`disable-model-invocation\` for side-effect commands
105
-
106
- ### Agents
107
- - Description has \`<example>\` blocks
108
- - System prompt has role, responsibilities, process, output format
109
- - \`tools\` array if restricting access
110
-
111
- ### Hooks
112
- - Valid JSON structure
113
- - Matchers are specific (not just \`.*\`)
114
- - Reasonable timeouts
115
- - Prompts are actionable
116
-
117
- ### MCP
118
- - Uses \`\${CLAUDE_PLUGIN_ROOT}\` for paths
119
- - Env vars use \`\${VAR}\` syntax
120
- - No hardcoded secrets
121
-
122
- ### CLAUDE.md
123
- - Under 300 lines (check line count)
124
- - Has WHAT/WHY/HOW sections
125
- - Uses \`file:line\` pointers, not code snippets
126
- - Progressive disclosure for large content
127
-
128
- ## Output
129
-
130
- Append to \`AUDIT_REPORT.md\`:
131
-
132
- \`\`\`markdown
133
- ---
134
-
135
- ## Qualitative Review
136
-
137
- **Reviewed by:** Claude (Opus)
138
- **Date:** [ISO timestamp]
139
-
140
- ### Primitives Found
141
-
142
- | Type | Count | Files |
143
- |------|-------|-------|
144
- | Skills | N | [list] |
145
- | Agents | N | [list] |
146
- | Hooks | N | [list] |
147
- | MCP | N | [list] |
148
- | CLAUDE.md | Y/N | [path] |
149
-
150
- ### Issues
151
-
152
- [List each issue with file:line and specific fix needed]
153
-
154
- ### Verdict
155
-
156
- **[APPROVED / NEEDS REVISION]**
157
-
158
- [If needs revision: prioritized list of must-fix items]
159
- \`\`\`
160
- `;
161
- const AUDIT_CRITERIA_MD = `# Evaluation Criteria
162
-
163
- Rubric for qualitative skillset review. Each primitive type has specific requirements.
164
-
165
- ---
166
-
167
- ## Skills (SKILL.md)
168
-
169
- Skills and slash commands are now unified. File at \`.claude/skills/[name]/SKILL.md\` creates \`/name\`.
170
-
171
- ### Frontmatter Requirements
172
-
173
- | Field | Required | Notes |
174
- |-------|----------|-------|
175
- | \`name\` | Yes | Becomes the \`/slash-command\`, lowercase with hyphens |
176
- | \`description\` | Yes | **Critical for discoverability** - Claude uses this to decide when to load |
177
- | \`version\` | No | Semver for tracking |
178
- | \`allowed-tools\` | No | Restricts tool access (e.g., \`Read, Write, Bash(git:*)\`) |
179
- | \`model\` | No | \`sonnet\`, \`opus\`, or \`haiku\` |
180
- | \`disable-model-invocation\` | No | \`true\` = only user can invoke (for side-effect commands) |
181
- | \`user-invocable\` | No | \`false\` = only Claude can invoke (background knowledge) |
182
-
183
- ### Description Quality
184
-
185
- **GOOD:** Includes trigger phrases ("Use when reviewing PRs, checking vulnerabilities...")
186
- **POOR:** Vague ("Helps with code review")
187
-
188
- ---
189
-
190
- ## Agents (AGENT.md)
191
-
192
- ### Frontmatter Requirements
193
-
194
- | Field | Required | Notes |
195
- |-------|----------|-------|
196
- | \`name\` | Yes | Agent identifier |
197
- | \`description\` | Yes | **Must include \`<example>\` blocks** for reliable triggering |
198
- | \`model\` | No | \`inherit\`, \`sonnet\`, \`opus\`, \`haiku\` |
199
- | \`color\` | No | UI color hint |
200
- | \`tools\` | No | Array of allowed tools |
201
-
202
- ### System Prompt (Body)
203
-
204
- - Clear role definition ("You are...")
205
- - Core responsibilities numbered
206
- - Process/workflow steps
207
- - Expected output format
208
-
209
- ---
210
-
211
- ## Hooks (hooks.json)
212
-
213
- ### Event Types
214
-
215
- | Event | When | Use For |
216
- |-------|------|---------|
217
- | \`PreToolUse\` | Before tool executes | Validation, security checks |
218
- | \`PostToolUse\` | After tool completes | Feedback, logging |
219
- | \`Stop\` | Task completion | Quality gates, notifications |
220
- | \`SessionStart\` | Session begins | Context loading, env setup |
221
-
222
- ### Quality Checks
223
-
224
- - Matchers are specific (avoid \`.*\` unless intentional)
225
- - Timeouts are reasonable
226
- - Prompts are concise and actionable
227
-
228
- ---
229
-
230
- ## MCP Servers (.mcp.json)
231
-
232
- ### Quality Checks
233
-
234
- - Uses \`\${CLAUDE_PLUGIN_ROOT}\` for paths
235
- - Environment variables use \`\${VAR}\` syntax
236
- - Sensitive values reference env vars, not hardcoded
237
-
238
- ---
239
-
240
- ## CLAUDE.md
241
-
242
- ### Critical Constraints
243
-
244
- - **Under 300 lines** (ideally <60)
245
- - LLMs follow ~150-200 instructions; Claude Code system prompt uses ~50
246
-
247
- ### Required Content (WHAT, WHY, HOW)
248
-
249
- - **WHAT**: Tech stack, project structure, codebase map
250
- - **WHY**: Project purpose, component functions
251
- - **HOW**: Dev workflows, tools, testing, verification
252
-
253
- ### What to Avoid
254
-
255
- - Task-specific instructions
256
- - Code style rules (use linters + hooks)
257
- - Code snippets (use \`file:line\` pointers)
258
- - Hardcoded dates/versions
259
-
260
- ---
261
-
262
- ## Verdict Rules
263
-
264
- - **APPROVED**: All primitives meet requirements, minor issues only
265
- - **NEEDS REVISION**: Missing required fields, poor descriptions, oversized files
266
-
267
- Priority:
268
- 1. Missing/poor descriptions (affects discoverability)
269
- 2. Oversized CLAUDE.md (degrades all instructions)
270
- 3. Missing agent examples (unreliable triggering)
271
- `;
272
80
  function copyDirRecursive(src, dest) {
273
81
  if (!existsSync(dest)) {
274
82
  mkdirSync(dest, { recursive: true });
@@ -419,11 +227,15 @@ export async function init(options) {
419
227
  // Generate PROOF.md
420
228
  const proof = PROOF_TEMPLATE.replace('{{PRODUCTION_URL}}', productionUrl);
421
229
  writeFileSync(join(cwd, 'PROOF.md'), proof);
422
- // Install audit-skill skill to .claude/skills/
230
+ // Install audit-skill from registry
231
+ spinner.text = 'Fetching audit-skill...';
423
232
  const skillDir = join(cwd, '.claude', 'skills', 'audit-skill');
424
- mkdirSync(skillDir, { recursive: true });
425
- writeFileSync(join(skillDir, 'SKILL.md'), AUDIT_SKILL_MD);
426
- 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);
427
239
  spinner.succeed('Skillset structure created');
428
240
  // Summary
429
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
  });
@@ -6,24 +6,8 @@ import { join } from 'path';
6
6
  import yaml from 'js-yaml';
7
7
  import { tmpdir } from 'os';
8
8
  import { fetchSkillsetMetadata } from '../lib/api.js';
9
+ import { compareVersions } from '../lib/versions.js';
9
10
  const REGISTRY_REPO = 'skillsets-cc/main';
10
- /**
11
- * Compare semver versions. Returns:
12
- * -1 if a < b, 0 if a == b, 1 if a > b
13
- */
14
- function compareVersions(a, b) {
15
- const partsA = a.split('.').map(Number);
16
- const partsB = b.split('.').map(Number);
17
- for (let i = 0; i < 3; i++) {
18
- const numA = partsA[i] || 0;
19
- const numB = partsB[i] || 0;
20
- if (numA < numB)
21
- return -1;
22
- if (numA > numB)
23
- return 1;
24
- }
25
- return 0;
26
- }
27
11
  const REGISTRY_URL = `https://github.com/${REGISTRY_REPO}`;
28
12
  function checkGhCli() {
29
13
  try {
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
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Compare semver versions. Returns:
3
+ * -1 if a < b, 0 if a == b, 1 if a > b
4
+ */
5
+ export declare function compareVersions(a: string, b: string): number;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Compare semver versions. Returns:
3
+ * -1 if a < b, 0 if a == b, 1 if a > b
4
+ */
5
+ export function compareVersions(a, b) {
6
+ const partsA = a.split('.').map(Number);
7
+ const partsB = b.split('.').map(Number);
8
+ for (let i = 0; i < 3; i++) {
9
+ const numA = partsA[i] || 0;
10
+ const numB = partsB[i] || 0;
11
+ if (numA < numB)
12
+ return -1;
13
+ if (numA > numB)
14
+ return 1;
15
+ }
16
+ return 0;
17
+ }
@@ -17,7 +17,10 @@ export interface SearchIndexEntry {
17
17
  version: string;
18
18
  status: 'active' | 'deprecated' | 'archived';
19
19
  verification: {
20
- production_url: string;
20
+ production_links: Array<{
21
+ url: string;
22
+ label?: string;
23
+ }>;
21
24
  production_proof?: string;
22
25
  audit_report: string;
23
26
  };
@@ -28,6 +31,7 @@ export interface SearchIndexEntry {
28
31
  entry_point: string;
29
32
  checksum: string;
30
33
  files: Record<string, string>;
34
+ mcp_servers?: McpServer[];
31
35
  }
32
36
  export interface StatsResponse {
33
37
  stars: Record<string, number>;
@@ -43,7 +47,10 @@ export interface Skillset {
43
47
  url: string;
44
48
  };
45
49
  verification: {
46
- production_url: string;
50
+ production_links: Array<{
51
+ url: string;
52
+ label?: string;
53
+ }>;
47
54
  production_proof?: string;
48
55
  audit_report: string;
49
56
  };
@@ -54,4 +61,23 @@ export interface Skillset {
54
61
  };
55
62
  status: 'active' | 'deprecated' | 'archived';
56
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;
57
83
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillsets",
3
- "version": "0.2.4",
3
+ "version": "0.3.0",
4
4
  "description": "CLI tool for discovering and installing verified skillsets",
5
5
  "type": "module",
6
6
  "bin": {