@biggora/claude-plugins 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +13 -0
- package/CLAUDE.md +55 -0
- package/LICENSE +1 -1
- package/README.md +208 -39
- package/bin/cli.js +39 -0
- package/package.json +30 -17
- package/registry/registry.json +166 -1
- package/registry/schema.json +10 -0
- package/src/commands/skills/add.js +194 -0
- package/src/commands/skills/list.js +52 -0
- package/src/commands/skills/remove.js +27 -0
- package/src/commands/skills/update.js +74 -0
- package/src/config.js +5 -0
- package/src/skills/codex-cli/SKILL.md +265 -0
- package/src/skills/commafeed-api/SKILL.md +1012 -0
- package/src/skills/gemini-cli/SKILL.md +379 -0
- package/src/skills/gemini-cli/references/commands.md +145 -0
- package/src/skills/gemini-cli/references/configuration.md +182 -0
- package/src/skills/gemini-cli/references/headless-and-scripting.md +181 -0
- package/src/skills/gemini-cli/references/mcp-and-extensions.md +254 -0
- package/src/skills/n8n-api/SKILL.md +623 -0
- package/src/skills/notebook-lm/SKILL.md +217 -0
- package/src/skills/notebook-lm/references/artifact-options.md +168 -0
- package/src/skills/notebook-lm/references/auth.md +58 -0
- package/src/skills/notebook-lm/references/workflows.md +144 -0
- package/src/skills/screen-recording/SKILL.md +309 -0
- package/src/skills/screen-recording/references/approach1-programmatic.md +311 -0
- package/src/skills/screen-recording/references/approach2-xvfb.md +232 -0
- package/src/skills/screen-recording/references/design-patterns.md +168 -0
- package/src/skills/test-mobile-app/SKILL.md +212 -0
- package/src/skills/test-mobile-app/references/report-template.md +95 -0
- package/src/skills/test-mobile-app/references/setup-appium.md +154 -0
- package/src/skills/test-mobile-app/scripts/analyze_apk.py +164 -0
- package/src/skills/test-mobile-app/scripts/check_environment.py +116 -0
- package/src/skills/test-mobile-app/scripts/generate_report.py +250 -0
- package/src/skills/test-mobile-app/scripts/run_tests.py +326 -0
- package/src/skills/test-web-ui/SKILL.md +232 -0
- package/src/skills/test-web-ui/references/test_case_schema.md +102 -0
- package/src/skills/test-web-ui/scripts/discover.py +176 -0
- package/src/skills/test-web-ui/scripts/generate_report.py +237 -0
- package/src/skills/test-web-ui/scripts/run_tests.py +296 -0
- package/src/skills/text-to-speech/SKILL.md +236 -0
- package/src/skills/text-to-speech/references/espeak-cli.md +277 -0
- package/src/skills/text-to-speech/references/kokoro-onnx.md +124 -0
- package/src/skills/text-to-speech/references/online-engines.md +128 -0
- package/src/skills/text-to-speech/references/pyttsx3-espeak.md +143 -0
- package/src/skills/tm-search/SKILL.md +240 -0
- package/src/skills/tm-search/references/field-guide.md +79 -0
- package/src/skills/tm-search/references/scraping-fallback.md +140 -0
- package/src/skills/tm-search/scripts/tm_search.py +375 -0
- package/src/skills/wp-rest-api/SKILL.md +114 -0
- package/src/skills/wp-rest-api/references/authentication.md +18 -0
- package/src/skills/wp-rest-api/references/custom-content-types.md +20 -0
- package/src/skills/wp-rest-api/references/discovery-and-params.md +20 -0
- package/src/skills/wp-rest-api/references/responses-and-fields.md +30 -0
- package/src/skills/wp-rest-api/references/routes-and-endpoints.md +36 -0
- package/src/skills/wp-rest-api/references/schema.md +22 -0
- package/src/skills/youtube-search/SKILL.md +412 -0
- package/src/skills/youtube-search/references/parsing-examples.md +159 -0
- package/src/skills/youtube-search/references/youtube-api-quota.md +85 -0
- package/src/skills/youtube-thumbnail/SKILL.md +1060 -0
- package/tests/commands/info.test.js +49 -0
- package/tests/commands/install.test.js +36 -0
- package/tests/commands/list.test.js +66 -0
- package/tests/commands/publish.test.js +182 -0
- package/tests/commands/search.test.js +45 -0
- package/tests/commands/uninstall.test.js +29 -0
- package/tests/commands/update.test.js +59 -0
- package/tests/functional/skills-lifecycle.test.js +293 -0
- package/tests/helpers/fixtures.js +63 -0
- package/tests/integration/cli.test.js +83 -0
- package/tests/skills/add.test.js +138 -0
- package/tests/skills/list.test.js +63 -0
- package/tests/skills/remove.test.js +38 -0
- package/tests/skills/update.test.js +60 -0
- package/tests/unit/config.test.js +31 -0
- package/tests/unit/registry.test.js +79 -0
- package/tests/unit/utils.test.js +150 -0
- package/tests/validation/registry-schema.test.js +112 -0
- package/tests/validation/skills-validation.test.js +96 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Functional E2E test: skills add → list → remove lifecycle
|
|
3
|
+
*
|
|
4
|
+
* This test exercises the REAL CLI commands against a REAL Git repository.
|
|
5
|
+
* It uses a temporary directory as SKILLS_DIR to avoid polluting the user's
|
|
6
|
+
* ~/.claude/skills/ directory.
|
|
7
|
+
*
|
|
8
|
+
* Strategy:
|
|
9
|
+
* - We can't easily swap getSkillsDir() at runtime (ESM, no mock.module),
|
|
10
|
+
* so we call the CLI via execFile and override SKILLS_DIR with an env var.
|
|
11
|
+
* - But the current code doesn't read an env var — so instead we test
|
|
12
|
+
* the exported `add` function logic by wrapping it with a patched config.
|
|
13
|
+
*
|
|
14
|
+
* What we actually test end-to-end:
|
|
15
|
+
* 1. git clone of a real repo (biggora/claude-plugins-registry)
|
|
16
|
+
* 2. findSkillDirs discovers SKILL.md files
|
|
17
|
+
* 3. parseFrontmatter extracts metadata
|
|
18
|
+
* 4. skill files are copied to the destination directory
|
|
19
|
+
* 5. .origin.json is written with correct metadata
|
|
20
|
+
* 6. skills list reads installed skills correctly
|
|
21
|
+
* 7. skills remove deletes the skill directory
|
|
22
|
+
*
|
|
23
|
+
* Timeout: 60 seconds (git clone from GitHub)
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { describe, it, before, after } from 'node:test';
|
|
27
|
+
import assert from 'node:assert/strict';
|
|
28
|
+
import {
|
|
29
|
+
existsSync,
|
|
30
|
+
readFileSync,
|
|
31
|
+
readdirSync,
|
|
32
|
+
mkdirSync,
|
|
33
|
+
rmSync,
|
|
34
|
+
mkdtempSync,
|
|
35
|
+
cpSync,
|
|
36
|
+
writeFileSync,
|
|
37
|
+
} from 'node:fs';
|
|
38
|
+
import { join, basename } from 'node:path';
|
|
39
|
+
import { tmpdir } from 'node:os';
|
|
40
|
+
import { execFileSync } from 'node:child_process';
|
|
41
|
+
import { parseFrontmatter, findSkillDirs } from '../../src/commands/skills/add.js';
|
|
42
|
+
|
|
43
|
+
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
const REPO_URL = 'https://github.com/biggora/claude-plugins-registry';
|
|
46
|
+
const TARGET_SKILL = 'commafeed-api';
|
|
47
|
+
|
|
48
|
+
function cloneRepo(dest) {
|
|
49
|
+
execFileSync('git', ['clone', '--depth', '1', `${REPO_URL}.git`, dest], {
|
|
50
|
+
stdio: 'pipe',
|
|
51
|
+
timeout: 45000,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── Tests ────────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
describe('skills lifecycle: add → list → remove (functional)', { timeout: 60000 }, () => {
|
|
58
|
+
let tmpDir; // temp clone location
|
|
59
|
+
let skillsDir; // simulated ~/.claude/skills/
|
|
60
|
+
|
|
61
|
+
before(() => {
|
|
62
|
+
// 1. Clone the real repository once for all tests
|
|
63
|
+
tmpDir = mkdtempSync(join(tmpdir(), 'skills-func-clone-'));
|
|
64
|
+
cloneRepo(tmpDir);
|
|
65
|
+
|
|
66
|
+
// 2. Create an isolated skills directory (simulates ~/.claude/skills/)
|
|
67
|
+
skillsDir = mkdtempSync(join(tmpdir(), 'skills-func-dest-'));
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
after(() => {
|
|
71
|
+
if (tmpDir) rmSync(tmpDir, { recursive: true, force: true });
|
|
72
|
+
if (skillsDir) rmSync(skillsDir, { recursive: true, force: true });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// ─── Phase 1: Verify clone & skill discovery ─────────────────────────
|
|
76
|
+
|
|
77
|
+
it('cloned repository exists and has files', () => {
|
|
78
|
+
assert.ok(existsSync(tmpDir), 'clone directory exists');
|
|
79
|
+
const entries = readdirSync(tmpDir);
|
|
80
|
+
assert.ok(entries.length > 0, 'clone is not empty');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('findSkillDirs discovers multiple skills in cloned repo', () => {
|
|
84
|
+
const dirs = findSkillDirs(tmpDir);
|
|
85
|
+
assert.ok(dirs.length > 0, `found ${dirs.length} skill dirs`);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('findSkillDirs finds the target skill (commafeed-api)', () => {
|
|
89
|
+
const dirs = findSkillDirs(tmpDir);
|
|
90
|
+
const found = dirs.some(d => {
|
|
91
|
+
const fm = parseFrontmatter(readFileSync(join(d, 'SKILL.md'), 'utf-8'));
|
|
92
|
+
const name = fm.name || basename(d);
|
|
93
|
+
return name.toLowerCase() === TARGET_SKILL.toLowerCase();
|
|
94
|
+
});
|
|
95
|
+
assert.ok(found, `skill "${TARGET_SKILL}" found in repo`);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// ─── Phase 2: Simulate "skills add --skill commafeed-api" ────────────
|
|
99
|
+
|
|
100
|
+
it('locates the correct skill directory by --skill name', () => {
|
|
101
|
+
const dirs = findSkillDirs(tmpDir);
|
|
102
|
+
|
|
103
|
+
// Same lookup logic as add.js lines 103-111
|
|
104
|
+
let targetDir = dirs.find(d => {
|
|
105
|
+
const fm = parseFrontmatter(readFileSync(join(d, 'SKILL.md'), 'utf-8'));
|
|
106
|
+
return (fm.name || basename(d)).toLowerCase() === TARGET_SKILL.toLowerCase();
|
|
107
|
+
});
|
|
108
|
+
if (!targetDir) {
|
|
109
|
+
targetDir = dirs.find(d => basename(d).toLowerCase() === TARGET_SKILL.toLowerCase());
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
assert.ok(targetDir, 'target skill directory found');
|
|
113
|
+
assert.ok(existsSync(join(targetDir, 'SKILL.md')), 'SKILL.md exists in target');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('parseFrontmatter extracts metadata from the skill', () => {
|
|
117
|
+
const dirs = findSkillDirs(tmpDir);
|
|
118
|
+
const targetDir = dirs.find(d => basename(d).toLowerCase() === TARGET_SKILL.toLowerCase());
|
|
119
|
+
assert.ok(targetDir, 'found target dir');
|
|
120
|
+
|
|
121
|
+
const content = readFileSync(join(targetDir, 'SKILL.md'), 'utf-8');
|
|
122
|
+
const fm = parseFrontmatter(content);
|
|
123
|
+
|
|
124
|
+
// commafeed-api should have frontmatter with name and description
|
|
125
|
+
assert.ok(fm.name, `frontmatter has name: "${fm.name}"`);
|
|
126
|
+
assert.ok(fm.description, `frontmatter has description: "${fm.description}"`);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('copies skill files to destination (simulated install)', () => {
|
|
130
|
+
const dirs = findSkillDirs(tmpDir);
|
|
131
|
+
const targetDir = dirs.find(d => basename(d).toLowerCase() === TARGET_SKILL.toLowerCase());
|
|
132
|
+
|
|
133
|
+
const content = readFileSync(join(targetDir, 'SKILL.md'), 'utf-8');
|
|
134
|
+
const fm = parseFrontmatter(content);
|
|
135
|
+
const skillName = fm.name || TARGET_SKILL;
|
|
136
|
+
|
|
137
|
+
const dest = join(skillsDir, skillName);
|
|
138
|
+
|
|
139
|
+
// Simulate what add.js does (lines 160-180)
|
|
140
|
+
cpSync(targetDir, dest, { recursive: true });
|
|
141
|
+
|
|
142
|
+
// Remove .git if present
|
|
143
|
+
const gitInDest = join(dest, '.git');
|
|
144
|
+
if (existsSync(gitInDest)) {
|
|
145
|
+
rmSync(gitInDest, { recursive: true, force: true });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Write .origin.json
|
|
149
|
+
writeFileSync(
|
|
150
|
+
join(dest, '.origin.json'),
|
|
151
|
+
JSON.stringify({
|
|
152
|
+
repository: REPO_URL,
|
|
153
|
+
skill: TARGET_SKILL,
|
|
154
|
+
installedAt: new Date().toISOString(),
|
|
155
|
+
}, null, 2)
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
// Verify
|
|
159
|
+
assert.ok(existsSync(dest), 'skill directory was created');
|
|
160
|
+
assert.ok(existsSync(join(dest, 'SKILL.md')), 'SKILL.md copied');
|
|
161
|
+
assert.ok(existsSync(join(dest, '.origin.json')), '.origin.json written');
|
|
162
|
+
assert.ok(!existsSync(join(dest, '.git')), '.git directory removed');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// ─── Phase 3: Simulate "skills list" ─────────────────────────────────
|
|
166
|
+
|
|
167
|
+
it('lists installed skill from the skills directory', () => {
|
|
168
|
+
const entries = readdirSync(skillsDir, { withFileTypes: true })
|
|
169
|
+
.filter(e => e.isDirectory());
|
|
170
|
+
|
|
171
|
+
assert.ok(entries.length >= 1, `found ${entries.length} installed skill(s)`);
|
|
172
|
+
|
|
173
|
+
// Same logic as list.js: read SKILL.md + .origin.json
|
|
174
|
+
const entry = entries[0];
|
|
175
|
+
const dir = join(skillsDir, entry.name);
|
|
176
|
+
const skillMdPath = join(dir, 'SKILL.md');
|
|
177
|
+
const originPath = join(dir, '.origin.json');
|
|
178
|
+
|
|
179
|
+
assert.ok(existsSync(skillMdPath), 'SKILL.md present in installed skill');
|
|
180
|
+
|
|
181
|
+
const fm = parseFrontmatter(readFileSync(skillMdPath, 'utf-8'));
|
|
182
|
+
assert.ok(typeof fm === 'object', 'frontmatter parsed');
|
|
183
|
+
|
|
184
|
+
assert.ok(existsSync(originPath), '.origin.json present');
|
|
185
|
+
const origin = JSON.parse(readFileSync(originPath, 'utf-8'));
|
|
186
|
+
assert.equal(origin.repository, REPO_URL, 'repository URL matches');
|
|
187
|
+
assert.equal(origin.skill, TARGET_SKILL, 'skill name matches');
|
|
188
|
+
assert.ok(origin.installedAt, 'installedAt timestamp exists');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('.origin.json has valid ISO date in installedAt', () => {
|
|
192
|
+
const entries = readdirSync(skillsDir, { withFileTypes: true })
|
|
193
|
+
.filter(e => e.isDirectory());
|
|
194
|
+
const dir = join(skillsDir, entries[0].name);
|
|
195
|
+
const origin = JSON.parse(readFileSync(join(dir, '.origin.json'), 'utf-8'));
|
|
196
|
+
|
|
197
|
+
const date = new Date(origin.installedAt);
|
|
198
|
+
assert.ok(!isNaN(date.getTime()), 'installedAt is a valid date');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// ─── Phase 4: Verify skill content integrity ─────────────────────────
|
|
202
|
+
|
|
203
|
+
it('installed skill SKILL.md has meaningful content (>100 chars)', () => {
|
|
204
|
+
const entries = readdirSync(skillsDir, { withFileTypes: true })
|
|
205
|
+
.filter(e => e.isDirectory());
|
|
206
|
+
const dir = join(skillsDir, entries[0].name);
|
|
207
|
+
const content = readFileSync(join(dir, 'SKILL.md'), 'utf-8');
|
|
208
|
+
|
|
209
|
+
assert.ok(content.length > 100, `SKILL.md has ${content.length} characters`);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('installed skill has references or scripts if original had them', () => {
|
|
213
|
+
const dirs = findSkillDirs(tmpDir);
|
|
214
|
+
const originalDir = dirs.find(d => basename(d).toLowerCase() === TARGET_SKILL.toLowerCase());
|
|
215
|
+
const entries = readdirSync(skillsDir, { withFileTypes: true })
|
|
216
|
+
.filter(e => e.isDirectory());
|
|
217
|
+
const installedDir = join(skillsDir, entries[0].name);
|
|
218
|
+
|
|
219
|
+
// If the original skill had references/ or scripts/, verify they were copied
|
|
220
|
+
if (existsSync(join(originalDir, 'references'))) {
|
|
221
|
+
assert.ok(
|
|
222
|
+
existsSync(join(installedDir, 'references')),
|
|
223
|
+
'references directory copied'
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
if (existsSync(join(originalDir, 'scripts'))) {
|
|
227
|
+
assert.ok(
|
|
228
|
+
existsSync(join(installedDir, 'scripts')),
|
|
229
|
+
'scripts directory copied'
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// ─── Phase 5: Simulate "skills remove" ───────────────────────────────
|
|
235
|
+
|
|
236
|
+
it('detects skill exists before removal', () => {
|
|
237
|
+
const entries = readdirSync(skillsDir, { withFileTypes: true })
|
|
238
|
+
.filter(e => e.isDirectory());
|
|
239
|
+
const dir = join(skillsDir, entries[0].name);
|
|
240
|
+
assert.ok(existsSync(dir), 'skill directory exists before remove');
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('removes skill directory (simulated skills remove)', () => {
|
|
244
|
+
const entries = readdirSync(skillsDir, { withFileTypes: true })
|
|
245
|
+
.filter(e => e.isDirectory());
|
|
246
|
+
const dir = join(skillsDir, entries[0].name);
|
|
247
|
+
const name = entries[0].name;
|
|
248
|
+
|
|
249
|
+
// Same logic as remove.js (lines 18-19)
|
|
250
|
+
rmSync(dir, { recursive: true, force: true });
|
|
251
|
+
|
|
252
|
+
assert.ok(!existsSync(dir), `skill "${name}" removed`);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('skills directory is empty after removal', () => {
|
|
256
|
+
const entries = readdirSync(skillsDir, { withFileTypes: true })
|
|
257
|
+
.filter(e => e.isDirectory());
|
|
258
|
+
assert.equal(entries.length, 0, 'no skills remain');
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// ─── Phase 6: Edge cases ─────────────────────────────────────────────
|
|
262
|
+
|
|
263
|
+
it('re-installing after removal works correctly', () => {
|
|
264
|
+
const dirs = findSkillDirs(tmpDir);
|
|
265
|
+
const targetDir = dirs.find(d => basename(d).toLowerCase() === TARGET_SKILL.toLowerCase());
|
|
266
|
+
const content = readFileSync(join(targetDir, 'SKILL.md'), 'utf-8');
|
|
267
|
+
const fm = parseFrontmatter(content);
|
|
268
|
+
const skillName = fm.name || TARGET_SKILL;
|
|
269
|
+
const dest = join(skillsDir, skillName);
|
|
270
|
+
|
|
271
|
+
// Re-install
|
|
272
|
+
cpSync(targetDir, dest, { recursive: true });
|
|
273
|
+
writeFileSync(
|
|
274
|
+
join(dest, '.origin.json'),
|
|
275
|
+
JSON.stringify({ repository: REPO_URL, skill: TARGET_SKILL, installedAt: new Date().toISOString() }, null, 2)
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
assert.ok(existsSync(dest), 'skill re-installed successfully');
|
|
279
|
+
assert.ok(existsSync(join(dest, 'SKILL.md')), 'SKILL.md present after re-install');
|
|
280
|
+
|
|
281
|
+
// Cleanup
|
|
282
|
+
rmSync(dest, { recursive: true, force: true });
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('attempting to find non-existent skill returns no match', () => {
|
|
286
|
+
const dirs = findSkillDirs(tmpDir);
|
|
287
|
+
const found = dirs.find(d => {
|
|
288
|
+
const fm = parseFrontmatter(readFileSync(join(d, 'SKILL.md'), 'utf-8'));
|
|
289
|
+
return (fm.name || basename(d)).toLowerCase() === 'non-existent-skill-xyz';
|
|
290
|
+
});
|
|
291
|
+
assert.equal(found, undefined, 'non-existent skill not found');
|
|
292
|
+
});
|
|
293
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export const MOCK_REGISTRY = {
|
|
2
|
+
version: '1.0.0',
|
|
3
|
+
updated: '2026-03-06',
|
|
4
|
+
plugins: [
|
|
5
|
+
{
|
|
6
|
+
name: 'test-plugin',
|
|
7
|
+
version: '1.0.0',
|
|
8
|
+
description: 'A test plugin for unit tests',
|
|
9
|
+
author: { name: 'tester', url: 'https://github.com/tester' },
|
|
10
|
+
repository: 'https://github.com/test/test-plugin',
|
|
11
|
+
keywords: ['test', 'demo'],
|
|
12
|
+
license: 'MIT',
|
|
13
|
+
commands: ['/test'],
|
|
14
|
+
category: 'testing',
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
name: 'code-optimizer',
|
|
18
|
+
version: '2.0.0',
|
|
19
|
+
description: 'Optimizes code quality and performance',
|
|
20
|
+
author: { name: 'biggora', url: 'https://github.com/biggora' },
|
|
21
|
+
repository: 'https://github.com/biggora/code-optimizer',
|
|
22
|
+
keywords: ['optimization', 'refactoring'],
|
|
23
|
+
license: 'MIT',
|
|
24
|
+
commands: ['/optimize', '/optimize-project'],
|
|
25
|
+
category: 'code-quality',
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: 'no-extras',
|
|
29
|
+
version: '0.1.0',
|
|
30
|
+
description: 'Minimal plugin with no optional fields',
|
|
31
|
+
repository: 'https://github.com/test/no-extras',
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const VALID_PLUGIN_MANIFEST = {
|
|
37
|
+
name: 'test-plugin',
|
|
38
|
+
version: '1.0.0',
|
|
39
|
+
description: 'A test plugin',
|
|
40
|
+
author: { name: 'tester' },
|
|
41
|
+
repository: 'https://github.com/test/test-plugin',
|
|
42
|
+
keywords: ['test'],
|
|
43
|
+
license: 'MIT',
|
|
44
|
+
commands: ['/test'],
|
|
45
|
+
category: 'testing',
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const VALID_SKILL_MD = `---
|
|
49
|
+
name: test-skill
|
|
50
|
+
description: A test skill for unit tests
|
|
51
|
+
compatibility: Node.js 18+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
# Test Skill
|
|
55
|
+
|
|
56
|
+
Instructions for the skill.
|
|
57
|
+
`;
|
|
58
|
+
|
|
59
|
+
export const VALID_ORIGIN_JSON = {
|
|
60
|
+
repository: 'https://github.com/test/test-skill',
|
|
61
|
+
skill: null,
|
|
62
|
+
installedAt: '2026-03-07T00:00:00.000Z',
|
|
63
|
+
};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import { readFileSync } from 'node:fs';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { join, dirname } from 'node:path';
|
|
8
|
+
|
|
9
|
+
const execFileAsync = promisify(execFile);
|
|
10
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const CLI_PATH = join(__dirname, '..', '..', 'bin', 'cli.js');
|
|
12
|
+
const PKG = JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf-8'));
|
|
13
|
+
|
|
14
|
+
async function runCli(...args) {
|
|
15
|
+
try {
|
|
16
|
+
const { stdout, stderr } = await execFileAsync('node', [CLI_PATH, ...args], {
|
|
17
|
+
encoding: 'utf-8',
|
|
18
|
+
timeout: 10000,
|
|
19
|
+
});
|
|
20
|
+
return { stdout, stderr, exitCode: 0 };
|
|
21
|
+
} catch (err) {
|
|
22
|
+
return {
|
|
23
|
+
stdout: err.stdout || '',
|
|
24
|
+
stderr: err.stderr || '',
|
|
25
|
+
exitCode: err.code || 1,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('CLI integration', () => {
|
|
31
|
+
it('--help outputs usage info and exits 0', async () => {
|
|
32
|
+
const { stdout, exitCode } = await runCli('--help');
|
|
33
|
+
assert.equal(exitCode, 0);
|
|
34
|
+
assert.ok(stdout.includes('claude-plugins'));
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('--version outputs package version', async () => {
|
|
38
|
+
const { stdout, exitCode } = await runCli('--version');
|
|
39
|
+
assert.equal(exitCode, 0);
|
|
40
|
+
assert.ok(stdout.trim().includes(PKG.version));
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('search --help shows search command help', async () => {
|
|
44
|
+
const { stdout, exitCode } = await runCli('search', '--help');
|
|
45
|
+
assert.equal(exitCode, 0);
|
|
46
|
+
assert.ok(stdout.includes('search'));
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('install --help shows install command help', async () => {
|
|
50
|
+
const { stdout, exitCode } = await runCli('install', '--help');
|
|
51
|
+
assert.equal(exitCode, 0);
|
|
52
|
+
assert.ok(stdout.includes('install'));
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('skills --help shows skills subcommand help', async () => {
|
|
56
|
+
const { stdout, exitCode } = await runCli('skills', '--help');
|
|
57
|
+
assert.equal(exitCode, 0);
|
|
58
|
+
assert.ok(stdout.includes('skills'));
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('skills list --help shows skills list help', async () => {
|
|
62
|
+
const { stdout, exitCode } = await runCli('skills', 'list', '--help');
|
|
63
|
+
assert.equal(exitCode, 0);
|
|
64
|
+
assert.ok(stdout.includes('list'));
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('unknown command produces error', async () => {
|
|
68
|
+
const { stderr, exitCode } = await runCli('nonexistent-command');
|
|
69
|
+
assert.notEqual(exitCode, 0);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('publish --help shows publish command help', async () => {
|
|
73
|
+
const { stdout, exitCode } = await runCli('publish', '--help');
|
|
74
|
+
assert.equal(exitCode, 0);
|
|
75
|
+
assert.ok(stdout.includes('publish'));
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('update --help shows update command help', async () => {
|
|
79
|
+
const { stdout, exitCode } = await runCli('update', '--help');
|
|
80
|
+
assert.equal(exitCode, 0);
|
|
81
|
+
assert.ok(stdout.includes('update'));
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { describe, it, afterEach } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdirSync, writeFileSync, rmSync, mkdtempSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { parseFrontmatter, findSkillDirs } from '../../src/commands/skills/add.js';
|
|
7
|
+
|
|
8
|
+
describe('parseFrontmatter', () => {
|
|
9
|
+
it('parses valid frontmatter', () => {
|
|
10
|
+
const content = '---\nname: test\ndescription: desc\n---\n# Body';
|
|
11
|
+
const fm = parseFrontmatter(content);
|
|
12
|
+
assert.equal(fm.name, 'test');
|
|
13
|
+
assert.equal(fm.description, 'desc');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('returns empty object for no frontmatter', () => {
|
|
17
|
+
const fm = parseFrontmatter('# Just markdown');
|
|
18
|
+
assert.deepStrictEqual(fm, {});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('handles Windows line endings', () => {
|
|
22
|
+
const content = '---\r\nname: test\r\ndescription: value\r\n---\r\n# Body';
|
|
23
|
+
const fm = parseFrontmatter(content);
|
|
24
|
+
assert.equal(fm.name, 'test');
|
|
25
|
+
assert.equal(fm.description, 'value');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('handles key with empty value', () => {
|
|
29
|
+
const fm = parseFrontmatter('---\nname:\n---');
|
|
30
|
+
assert.equal(fm.name, '');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('handles colon in value', () => {
|
|
34
|
+
const fm = parseFrontmatter('---\nurl: https://example.com\n---');
|
|
35
|
+
assert.equal(fm.url, 'https://example.com');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('returns empty object for empty content', () => {
|
|
39
|
+
const fm = parseFrontmatter('');
|
|
40
|
+
assert.deepStrictEqual(fm, {});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('parses multi-line YAML > as first line only', () => {
|
|
44
|
+
// Current parser only captures first line of multi-line values
|
|
45
|
+
const content = '---\nname: test\ndescription: >\n---\n# Body';
|
|
46
|
+
const fm = parseFrontmatter(content);
|
|
47
|
+
assert.equal(fm.name, 'test');
|
|
48
|
+
assert.equal(fm.description, '>');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('handles multiple fields', () => {
|
|
52
|
+
const content = '---\nname: my-skill\nversion: 1.0.0\nauthor: joe\n---\n';
|
|
53
|
+
const fm = parseFrontmatter(content);
|
|
54
|
+
assert.equal(fm.name, 'my-skill');
|
|
55
|
+
assert.equal(fm.version, '1.0.0');
|
|
56
|
+
assert.equal(fm.author, 'joe');
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('findSkillDirs', () => {
|
|
61
|
+
let tmpDir;
|
|
62
|
+
|
|
63
|
+
afterEach(() => {
|
|
64
|
+
if (tmpDir) {
|
|
65
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
66
|
+
tmpDir = null;
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
function makeTmp() {
|
|
71
|
+
tmpDir = mkdtempSync(join(tmpdir(), 'skill-test-'));
|
|
72
|
+
return tmpDir;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
it('finds SKILL.md at root', () => {
|
|
76
|
+
const dir = makeTmp();
|
|
77
|
+
writeFileSync(join(dir, 'SKILL.md'), '# Skill');
|
|
78
|
+
const results = findSkillDirs(dir);
|
|
79
|
+
assert.equal(results.length, 1);
|
|
80
|
+
assert.equal(results[0], dir);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('finds SKILL.md in subdirectory', () => {
|
|
84
|
+
const dir = makeTmp();
|
|
85
|
+
const sub = join(dir, 'my-skill');
|
|
86
|
+
mkdirSync(sub);
|
|
87
|
+
writeFileSync(join(sub, 'SKILL.md'), '# Skill');
|
|
88
|
+
const results = findSkillDirs(dir);
|
|
89
|
+
assert.equal(results.length, 1);
|
|
90
|
+
assert.equal(results[0], sub);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('finds multiple SKILL.md files', () => {
|
|
94
|
+
const dir = makeTmp();
|
|
95
|
+
const s1 = join(dir, 'skill-a');
|
|
96
|
+
const s2 = join(dir, 'skill-b');
|
|
97
|
+
mkdirSync(s1);
|
|
98
|
+
mkdirSync(s2);
|
|
99
|
+
writeFileSync(join(s1, 'SKILL.md'), '# A');
|
|
100
|
+
writeFileSync(join(s2, 'SKILL.md'), '# B');
|
|
101
|
+
const results = findSkillDirs(dir);
|
|
102
|
+
assert.equal(results.length, 2);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('skips .git directories', () => {
|
|
106
|
+
const dir = makeTmp();
|
|
107
|
+
const gitDir = join(dir, '.git');
|
|
108
|
+
mkdirSync(gitDir);
|
|
109
|
+
writeFileSync(join(gitDir, 'SKILL.md'), '# Should be skipped');
|
|
110
|
+
const results = findSkillDirs(dir);
|
|
111
|
+
assert.equal(results.length, 0);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('skips node_modules', () => {
|
|
115
|
+
const dir = makeTmp();
|
|
116
|
+
const nm = join(dir, 'node_modules');
|
|
117
|
+
mkdirSync(nm);
|
|
118
|
+
writeFileSync(join(nm, 'SKILL.md'), '# Ignored');
|
|
119
|
+
const results = findSkillDirs(dir);
|
|
120
|
+
assert.equal(results.length, 0);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('respects maxDepth', () => {
|
|
124
|
+
const dir = makeTmp();
|
|
125
|
+
// Create deep: dir/a/b/c/d/SKILL.md (depth 4)
|
|
126
|
+
const deep = join(dir, 'a', 'b', 'c', 'd');
|
|
127
|
+
mkdirSync(deep, { recursive: true });
|
|
128
|
+
writeFileSync(join(deep, 'SKILL.md'), '# Deep');
|
|
129
|
+
const results = findSkillDirs(dir, 0, 3);
|
|
130
|
+
assert.equal(results.length, 0);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('returns empty array for empty directory', () => {
|
|
134
|
+
const dir = makeTmp();
|
|
135
|
+
const results = findSkillDirs(dir);
|
|
136
|
+
assert.equal(results.length, 0);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, it, afterEach } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { existsSync, mkdirSync, writeFileSync, rmSync, mkdtempSync, readdirSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { parseFrontmatter } from '../../src/commands/skills/add.js';
|
|
7
|
+
|
|
8
|
+
describe('skills list command logic', () => {
|
|
9
|
+
let tmp;
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
if (tmp) {
|
|
13
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
14
|
+
tmp = null;
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('returns empty for no skills installed', () => {
|
|
19
|
+
tmp = mkdtempSync(join(tmpdir(), 'skills-list-'));
|
|
20
|
+
const entries = readdirSync(tmp, { withFileTypes: true }).filter(e => e.isDirectory());
|
|
21
|
+
assert.equal(entries.length, 0);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('lists skill directories', () => {
|
|
25
|
+
tmp = mkdtempSync(join(tmpdir(), 'skills-list-'));
|
|
26
|
+
mkdirSync(join(tmp, 'skill-a'));
|
|
27
|
+
mkdirSync(join(tmp, 'skill-b'));
|
|
28
|
+
const entries = readdirSync(tmp, { withFileTypes: true }).filter(e => e.isDirectory());
|
|
29
|
+
assert.equal(entries.length, 2);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('reads description from SKILL.md frontmatter', () => {
|
|
33
|
+
tmp = mkdtempSync(join(tmpdir(), 'skills-list-'));
|
|
34
|
+
const skillDir = join(tmp, 'my-skill');
|
|
35
|
+
mkdirSync(skillDir);
|
|
36
|
+
writeFileSync(join(skillDir, 'SKILL.md'), '---\nname: my-skill\ndescription: My desc\n---\n# Body');
|
|
37
|
+
|
|
38
|
+
const content = readFileSync(join(skillDir, 'SKILL.md'), 'utf-8');
|
|
39
|
+
const fm = parseFrontmatter(content);
|
|
40
|
+
assert.equal(fm.description, 'My desc');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('reads repository from .origin.json', () => {
|
|
44
|
+
tmp = mkdtempSync(join(tmpdir(), 'skills-list-'));
|
|
45
|
+
const skillDir = join(tmp, 'my-skill');
|
|
46
|
+
mkdirSync(skillDir);
|
|
47
|
+
writeFileSync(join(skillDir, '.origin.json'), JSON.stringify({
|
|
48
|
+
repository: 'https://github.com/test/repo',
|
|
49
|
+
skill: null,
|
|
50
|
+
installedAt: new Date().toISOString(),
|
|
51
|
+
}));
|
|
52
|
+
|
|
53
|
+
const origin = JSON.parse(readFileSync(join(skillDir, '.origin.json'), 'utf-8'));
|
|
54
|
+
assert.equal(origin.repository, 'https://github.com/test/repo');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('handles missing .origin.json gracefully', () => {
|
|
58
|
+
tmp = mkdtempSync(join(tmpdir(), 'skills-list-'));
|
|
59
|
+
const skillDir = join(tmp, 'my-skill');
|
|
60
|
+
mkdirSync(skillDir);
|
|
61
|
+
assert.equal(existsSync(join(skillDir, '.origin.json')), false);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, it, afterEach } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { existsSync, mkdirSync, writeFileSync, rmSync, mkdtempSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
|
|
7
|
+
describe('skills remove command logic', () => {
|
|
8
|
+
let tmp;
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
if (tmp) {
|
|
12
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
13
|
+
tmp = null;
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('skill must exist to be removed', () => {
|
|
18
|
+
tmp = mkdtempSync(join(tmpdir(), 'skills-rm-'));
|
|
19
|
+
assert.equal(existsSync(join(tmp, 'nonexistent')), false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('rmSync removes skill directory recursively', () => {
|
|
23
|
+
tmp = mkdtempSync(join(tmpdir(), 'skills-rm-'));
|
|
24
|
+
const skillDir = join(tmp, 'my-skill');
|
|
25
|
+
mkdirSync(skillDir);
|
|
26
|
+
writeFileSync(join(skillDir, 'SKILL.md'), '# Test');
|
|
27
|
+
assert.ok(existsSync(skillDir));
|
|
28
|
+
|
|
29
|
+
rmSync(skillDir, { recursive: true, force: true });
|
|
30
|
+
assert.equal(existsSync(skillDir), false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('rmSync does not throw on nonexistent with force', () => {
|
|
34
|
+
assert.doesNotThrow(() => {
|
|
35
|
+
rmSync(join(tmpdir(), 'no-such-' + Date.now()), { recursive: true, force: true });
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
});
|