@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.
Files changed (80) hide show
  1. package/.claude/settings.local.json +13 -0
  2. package/CLAUDE.md +55 -0
  3. package/LICENSE +1 -1
  4. package/README.md +208 -39
  5. package/bin/cli.js +39 -0
  6. package/package.json +30 -17
  7. package/registry/registry.json +166 -1
  8. package/registry/schema.json +10 -0
  9. package/src/commands/skills/add.js +194 -0
  10. package/src/commands/skills/list.js +52 -0
  11. package/src/commands/skills/remove.js +27 -0
  12. package/src/commands/skills/update.js +74 -0
  13. package/src/config.js +5 -0
  14. package/src/skills/codex-cli/SKILL.md +265 -0
  15. package/src/skills/commafeed-api/SKILL.md +1012 -0
  16. package/src/skills/gemini-cli/SKILL.md +379 -0
  17. package/src/skills/gemini-cli/references/commands.md +145 -0
  18. package/src/skills/gemini-cli/references/configuration.md +182 -0
  19. package/src/skills/gemini-cli/references/headless-and-scripting.md +181 -0
  20. package/src/skills/gemini-cli/references/mcp-and-extensions.md +254 -0
  21. package/src/skills/n8n-api/SKILL.md +623 -0
  22. package/src/skills/notebook-lm/SKILL.md +217 -0
  23. package/src/skills/notebook-lm/references/artifact-options.md +168 -0
  24. package/src/skills/notebook-lm/references/auth.md +58 -0
  25. package/src/skills/notebook-lm/references/workflows.md +144 -0
  26. package/src/skills/screen-recording/SKILL.md +309 -0
  27. package/src/skills/screen-recording/references/approach1-programmatic.md +311 -0
  28. package/src/skills/screen-recording/references/approach2-xvfb.md +232 -0
  29. package/src/skills/screen-recording/references/design-patterns.md +168 -0
  30. package/src/skills/test-mobile-app/SKILL.md +212 -0
  31. package/src/skills/test-mobile-app/references/report-template.md +95 -0
  32. package/src/skills/test-mobile-app/references/setup-appium.md +154 -0
  33. package/src/skills/test-mobile-app/scripts/analyze_apk.py +164 -0
  34. package/src/skills/test-mobile-app/scripts/check_environment.py +116 -0
  35. package/src/skills/test-mobile-app/scripts/generate_report.py +250 -0
  36. package/src/skills/test-mobile-app/scripts/run_tests.py +326 -0
  37. package/src/skills/test-web-ui/SKILL.md +232 -0
  38. package/src/skills/test-web-ui/references/test_case_schema.md +102 -0
  39. package/src/skills/test-web-ui/scripts/discover.py +176 -0
  40. package/src/skills/test-web-ui/scripts/generate_report.py +237 -0
  41. package/src/skills/test-web-ui/scripts/run_tests.py +296 -0
  42. package/src/skills/text-to-speech/SKILL.md +236 -0
  43. package/src/skills/text-to-speech/references/espeak-cli.md +277 -0
  44. package/src/skills/text-to-speech/references/kokoro-onnx.md +124 -0
  45. package/src/skills/text-to-speech/references/online-engines.md +128 -0
  46. package/src/skills/text-to-speech/references/pyttsx3-espeak.md +143 -0
  47. package/src/skills/tm-search/SKILL.md +240 -0
  48. package/src/skills/tm-search/references/field-guide.md +79 -0
  49. package/src/skills/tm-search/references/scraping-fallback.md +140 -0
  50. package/src/skills/tm-search/scripts/tm_search.py +375 -0
  51. package/src/skills/wp-rest-api/SKILL.md +114 -0
  52. package/src/skills/wp-rest-api/references/authentication.md +18 -0
  53. package/src/skills/wp-rest-api/references/custom-content-types.md +20 -0
  54. package/src/skills/wp-rest-api/references/discovery-and-params.md +20 -0
  55. package/src/skills/wp-rest-api/references/responses-and-fields.md +30 -0
  56. package/src/skills/wp-rest-api/references/routes-and-endpoints.md +36 -0
  57. package/src/skills/wp-rest-api/references/schema.md +22 -0
  58. package/src/skills/youtube-search/SKILL.md +412 -0
  59. package/src/skills/youtube-search/references/parsing-examples.md +159 -0
  60. package/src/skills/youtube-search/references/youtube-api-quota.md +85 -0
  61. package/src/skills/youtube-thumbnail/SKILL.md +1060 -0
  62. package/tests/commands/info.test.js +49 -0
  63. package/tests/commands/install.test.js +36 -0
  64. package/tests/commands/list.test.js +66 -0
  65. package/tests/commands/publish.test.js +182 -0
  66. package/tests/commands/search.test.js +45 -0
  67. package/tests/commands/uninstall.test.js +29 -0
  68. package/tests/commands/update.test.js +59 -0
  69. package/tests/functional/skills-lifecycle.test.js +293 -0
  70. package/tests/helpers/fixtures.js +63 -0
  71. package/tests/integration/cli.test.js +83 -0
  72. package/tests/skills/add.test.js +138 -0
  73. package/tests/skills/list.test.js +63 -0
  74. package/tests/skills/remove.test.js +38 -0
  75. package/tests/skills/update.test.js +60 -0
  76. package/tests/unit/config.test.js +31 -0
  77. package/tests/unit/registry.test.js +79 -0
  78. package/tests/unit/utils.test.js +150 -0
  79. package/tests/validation/registry-schema.test.js +112 -0
  80. package/tests/validation/skills-validation.test.js +96 -0
@@ -0,0 +1,60 @@
1
+ import { describe, it, afterEach } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { existsSync, mkdirSync, writeFileSync, rmSync, mkdtempSync, readFileSync, readdirSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { tmpdir } from 'node:os';
6
+
7
+ describe('skills update 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 update', () => {
18
+ tmp = mkdtempSync(join(tmpdir(), 'skills-update-'));
19
+ assert.equal(existsSync(join(tmp, 'nonexistent')), false);
20
+ });
21
+
22
+ it('reads origin metadata from .origin.json', () => {
23
+ tmp = mkdtempSync(join(tmpdir(), 'skills-update-'));
24
+ const skillDir = join(tmp, 'my-skill');
25
+ mkdirSync(skillDir);
26
+
27
+ const origin = {
28
+ repository: 'https://github.com/test/repo',
29
+ skill: null,
30
+ installedAt: '2026-01-01T00:00:00.000Z',
31
+ };
32
+ writeFileSync(join(skillDir, '.origin.json'), JSON.stringify(origin));
33
+
34
+ const read = JSON.parse(readFileSync(join(skillDir, '.origin.json'), 'utf-8'));
35
+ assert.equal(read.repository, origin.repository);
36
+ assert.equal(read.skill, null);
37
+ });
38
+
39
+ it('detects missing .origin.json', () => {
40
+ tmp = mkdtempSync(join(tmpdir(), 'skills-update-'));
41
+ const skillDir = join(tmp, 'my-skill');
42
+ mkdirSync(skillDir);
43
+ assert.equal(existsSync(join(skillDir, '.origin.json')), false);
44
+ });
45
+
46
+ it('lists all skills for update-all', () => {
47
+ tmp = mkdtempSync(join(tmpdir(), 'skills-update-'));
48
+ mkdirSync(join(tmp, 'skill-a'));
49
+ mkdirSync(join(tmp, 'skill-b'));
50
+
51
+ const entries = readdirSync(tmp, { withFileTypes: true }).filter(e => e.isDirectory());
52
+ assert.equal(entries.length, 2);
53
+ });
54
+
55
+ it('empty skills dir means nothing to update', () => {
56
+ tmp = mkdtempSync(join(tmpdir(), 'skills-update-'));
57
+ const entries = readdirSync(tmp, { withFileTypes: true }).filter(e => e.isDirectory());
58
+ assert.equal(entries.length, 0);
59
+ });
60
+ });
@@ -0,0 +1,31 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { join } from 'node:path';
4
+ import { homedir } from 'node:os';
5
+ import { PLUGINS_DIR, SKILLS_DIR, CACHE_DIR, CACHE_TTL, REGISTRY_URL } from '../../src/config.js';
6
+
7
+ const home = homedir();
8
+
9
+ describe('config constants', () => {
10
+ it('PLUGINS_DIR points to ~/.claude/plugins', () => {
11
+ assert.equal(PLUGINS_DIR, join(home, '.claude', 'plugins'));
12
+ });
13
+
14
+ it('SKILLS_DIR points to ~/.claude/skills', () => {
15
+ assert.equal(SKILLS_DIR, join(home, '.claude', 'skills'));
16
+ });
17
+
18
+ it('CACHE_DIR points to ~/.claude/.cache/claude-plugins', () => {
19
+ assert.equal(CACHE_DIR, join(home, '.claude', '.cache', 'claude-plugins'));
20
+ });
21
+
22
+ it('CACHE_TTL is 15 minutes in milliseconds', () => {
23
+ assert.equal(CACHE_TTL, 1000 * 60 * 15);
24
+ assert.equal(CACHE_TTL, 900000);
25
+ });
26
+
27
+ it('REGISTRY_URL is a valid GitHub raw URL', () => {
28
+ assert.ok(REGISTRY_URL.startsWith('https://raw.githubusercontent.com/'));
29
+ assert.ok(REGISTRY_URL.endsWith('registry.json'));
30
+ });
31
+ });
@@ -0,0 +1,79 @@
1
+ import { describe, it, mock, beforeEach, afterEach } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { searchPlugins, findPlugin } from '../../src/registry.js';
4
+ import { MOCK_REGISTRY } from '../helpers/fixtures.js';
5
+
6
+ describe('searchPlugins', () => {
7
+ it('matches by name', () => {
8
+ const results = searchPlugins(MOCK_REGISTRY, 'test-plugin');
9
+ assert.equal(results.length, 1);
10
+ assert.equal(results[0].name, 'test-plugin');
11
+ });
12
+
13
+ it('matches by description substring', () => {
14
+ const results = searchPlugins(MOCK_REGISTRY, 'optimizes');
15
+ assert.equal(results.length, 1);
16
+ assert.equal(results[0].name, 'code-optimizer');
17
+ });
18
+
19
+ it('matches by keyword', () => {
20
+ const results = searchPlugins(MOCK_REGISTRY, 'refactoring');
21
+ assert.equal(results.length, 1);
22
+ assert.equal(results[0].name, 'code-optimizer');
23
+ });
24
+
25
+ it('matches by author name', () => {
26
+ const results = searchPlugins(MOCK_REGISTRY, 'biggora');
27
+ assert.equal(results.length, 1);
28
+ assert.equal(results[0].name, 'code-optimizer');
29
+ });
30
+
31
+ it('is case-insensitive', () => {
32
+ const results = searchPlugins(MOCK_REGISTRY, 'TEST-PLUGIN');
33
+ assert.equal(results.length, 1);
34
+ assert.equal(results[0].name, 'test-plugin');
35
+ });
36
+
37
+ it('returns empty array for no match', () => {
38
+ const results = searchPlugins(MOCK_REGISTRY, 'nonexistent-xyz');
39
+ assert.equal(results.length, 0);
40
+ });
41
+
42
+ it('handles plugins with no keywords', () => {
43
+ const results = searchPlugins(MOCK_REGISTRY, 'no-extras');
44
+ assert.equal(results.length, 1);
45
+ assert.equal(results[0].name, 'no-extras');
46
+ });
47
+
48
+ it('handles plugins with no author', () => {
49
+ const registry = {
50
+ plugins: [{ name: 'orphan', description: 'no author', repository: 'x' }],
51
+ };
52
+ const results = searchPlugins(registry, 'orphan');
53
+ assert.equal(results.length, 1);
54
+ });
55
+
56
+ it('empty query matches all plugins', () => {
57
+ const results = searchPlugins(MOCK_REGISTRY, '');
58
+ assert.equal(results.length, MOCK_REGISTRY.plugins.length);
59
+ });
60
+ });
61
+
62
+ describe('findPlugin', () => {
63
+ it('finds plugin by exact name', () => {
64
+ const plugin = findPlugin(MOCK_REGISTRY, 'test-plugin');
65
+ assert.ok(plugin);
66
+ assert.equal(plugin.name, 'test-plugin');
67
+ });
68
+
69
+ it('is case-insensitive', () => {
70
+ const plugin = findPlugin(MOCK_REGISTRY, 'Code-Optimizer');
71
+ assert.ok(plugin);
72
+ assert.equal(plugin.name, 'code-optimizer');
73
+ });
74
+
75
+ it('returns undefined for not found', () => {
76
+ const plugin = findPlugin(MOCK_REGISTRY, 'no-such-plugin');
77
+ assert.equal(plugin, undefined);
78
+ });
79
+ });
@@ -0,0 +1,150 @@
1
+ import { describe, it, mock, beforeEach, afterEach } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { truncate, log, formatTable, spinner } from '../../src/utils.js';
4
+
5
+ describe('truncate', () => {
6
+ it('returns short string unchanged', () => {
7
+ assert.equal(truncate('hello', 60), 'hello');
8
+ });
9
+
10
+ it('returns string at exactly the limit unchanged', () => {
11
+ const str = 'a'.repeat(60);
12
+ assert.equal(truncate(str, 60), str);
13
+ });
14
+
15
+ it('truncates string over limit with ellipsis', () => {
16
+ const str = 'a'.repeat(70);
17
+ const result = truncate(str, 60);
18
+ assert.equal(result.length, 60);
19
+ assert.ok(result.endsWith('...'));
20
+ });
21
+
22
+ it('handles custom length', () => {
23
+ const result = truncate('abcdefgh', 5);
24
+ assert.equal(result, 'ab...');
25
+ });
26
+
27
+ it('returns empty string for null', () => {
28
+ assert.equal(truncate(null), '');
29
+ });
30
+
31
+ it('returns empty string for undefined', () => {
32
+ assert.equal(truncate(undefined), '');
33
+ });
34
+
35
+ it('returns empty string for empty input', () => {
36
+ assert.equal(truncate(''), '');
37
+ });
38
+
39
+ it('uses default length of 60', () => {
40
+ const str = 'a'.repeat(100);
41
+ const result = truncate(str);
42
+ assert.equal(result.length, 60);
43
+ });
44
+ });
45
+
46
+ describe('log', () => {
47
+ let logSpy, errorSpy;
48
+
49
+ beforeEach(() => {
50
+ logSpy = mock.method(console, 'log', () => {});
51
+ errorSpy = mock.method(console, 'error', () => {});
52
+ });
53
+
54
+ afterEach(() => {
55
+ logSpy.mock.restore();
56
+ errorSpy.mock.restore();
57
+ });
58
+
59
+ it('log.info writes to console.log with message', () => {
60
+ log.info('test message');
61
+ assert.equal(logSpy.mock.callCount(), 1);
62
+ const output = logSpy.mock.calls[0].arguments.join(' ');
63
+ assert.ok(output.includes('test message'));
64
+ });
65
+
66
+ it('log.success writes to console.log with message', () => {
67
+ log.success('done');
68
+ assert.equal(logSpy.mock.callCount(), 1);
69
+ const output = logSpy.mock.calls[0].arguments.join(' ');
70
+ assert.ok(output.includes('done'));
71
+ });
72
+
73
+ it('log.warn writes to console.log with message', () => {
74
+ log.warn('caution');
75
+ assert.equal(logSpy.mock.callCount(), 1);
76
+ const output = logSpy.mock.calls[0].arguments.join(' ');
77
+ assert.ok(output.includes('caution'));
78
+ });
79
+
80
+ it('log.error writes to console.error', () => {
81
+ log.error('failure');
82
+ assert.equal(errorSpy.mock.callCount(), 1);
83
+ assert.equal(logSpy.mock.callCount(), 0);
84
+ const output = errorSpy.mock.calls[0].arguments.join(' ');
85
+ assert.ok(output.includes('failure'));
86
+ });
87
+
88
+ it('log.dim writes to console.log', () => {
89
+ log.dim('subtle');
90
+ assert.equal(logSpy.mock.callCount(), 1);
91
+ const output = logSpy.mock.calls[0].arguments.join(' ');
92
+ assert.ok(output.includes('subtle'));
93
+ });
94
+ });
95
+
96
+ describe('formatTable', () => {
97
+ let logSpy;
98
+
99
+ beforeEach(() => {
100
+ logSpy = mock.method(console, 'log', () => {});
101
+ });
102
+
103
+ afterEach(() => {
104
+ logSpy.mock.restore();
105
+ });
106
+
107
+ it('prints header, separator and data rows', () => {
108
+ formatTable(
109
+ [['alice', '30'], ['bob', '25']],
110
+ ['Name', 'Age']
111
+ );
112
+ // header + separator + 2 data rows = 4 calls
113
+ assert.equal(logSpy.mock.callCount(), 4);
114
+ });
115
+
116
+ it('prints only header and separator for empty rows', () => {
117
+ formatTable([], ['Name', 'Age']);
118
+ // header + separator = 2 calls
119
+ assert.equal(logSpy.mock.callCount(), 2);
120
+ });
121
+
122
+ it('handles null/undefined cells gracefully', () => {
123
+ assert.doesNotThrow(() => {
124
+ formatTable([[null, undefined]], ['Col1', 'Col2']);
125
+ });
126
+ });
127
+
128
+ it('auto-sizes columns to fit content', () => {
129
+ formatTable([['longername', 'x']], ['N', 'V']);
130
+ // header row should have been padded to at least 'longername'.length
131
+ const headerOutput = logSpy.mock.calls[0].arguments[0];
132
+ assert.ok(headerOutput.includes('N'));
133
+ });
134
+ });
135
+
136
+ describe('spinner', () => {
137
+ it('returns an ora instance with expected methods', () => {
138
+ const spin = spinner('Loading...');
139
+ assert.equal(typeof spin.start, 'function');
140
+ assert.equal(typeof spin.stop, 'function');
141
+ assert.equal(typeof spin.succeed, 'function');
142
+ assert.equal(typeof spin.fail, 'function');
143
+ assert.equal(typeof spin.info, 'function');
144
+ });
145
+
146
+ it('has the correct text property', () => {
147
+ const spin = spinner('Test text');
148
+ assert.equal(spin.text, 'Test text');
149
+ });
150
+ });
@@ -0,0 +1,112 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { readFileSync } from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { join, dirname } from 'node:path';
6
+
7
+ const __dirname = dirname(fileURLToPath(import.meta.url));
8
+ const ROOT = join(__dirname, '..', '..');
9
+
10
+ const registryRaw = readFileSync(join(ROOT, 'registry', 'registry.json'), 'utf-8');
11
+ const registry = JSON.parse(registryRaw);
12
+ const schemaRaw = readFileSync(join(ROOT, 'registry', 'schema.json'), 'utf-8');
13
+ const schema = JSON.parse(schemaRaw);
14
+
15
+ describe('registry.json validity', () => {
16
+ it('is valid JSON', () => {
17
+ assert.ok(registry);
18
+ assert.equal(typeof registry, 'object');
19
+ });
20
+
21
+ it('has required top-level field: version', () => {
22
+ assert.ok(registry.version);
23
+ assert.equal(typeof registry.version, 'string');
24
+ });
25
+
26
+ it('has required top-level field: plugins array', () => {
27
+ assert.ok(Array.isArray(registry.plugins));
28
+ assert.ok(registry.plugins.length > 0);
29
+ });
30
+
31
+ it('each plugin has required fields: name, version, description, repository', () => {
32
+ for (const p of registry.plugins) {
33
+ assert.ok(p.name, `Plugin missing name: ${JSON.stringify(p)}`);
34
+ assert.ok(p.version, `Plugin "${p.name}" missing version`);
35
+ assert.ok(p.description, `Plugin "${p.name}" missing description`);
36
+ assert.ok(p.repository, `Plugin "${p.name}" missing repository`);
37
+ }
38
+ });
39
+
40
+ it('all plugin names match pattern ^[a-z0-9-]+$', () => {
41
+ const pattern = /^[a-z0-9-]+$/;
42
+ for (const p of registry.plugins) {
43
+ assert.ok(pattern.test(p.name), `Invalid name: "${p.name}"`);
44
+ }
45
+ });
46
+
47
+ it('no duplicate plugin names', () => {
48
+ const names = registry.plugins.map((p) => p.name);
49
+ const unique = new Set(names);
50
+ assert.equal(names.length, unique.size, `Duplicate names found: ${names.filter((n, i) => names.indexOf(n) !== i)}`);
51
+ });
52
+
53
+ it('all descriptions are under 200 characters', () => {
54
+ for (const p of registry.plugins) {
55
+ assert.ok(
56
+ p.description.length <= 200,
57
+ `Plugin "${p.name}" description is ${p.description.length} chars (max 200)`
58
+ );
59
+ }
60
+ });
61
+
62
+ it('all keywords arrays have at most 10 items', () => {
63
+ for (const p of registry.plugins) {
64
+ if (p.keywords) {
65
+ assert.ok(
66
+ p.keywords.length <= 10,
67
+ `Plugin "${p.name}" has ${p.keywords.length} keywords (max 10)`
68
+ );
69
+ }
70
+ }
71
+ });
72
+
73
+ it('all category values are from the allowed enum', () => {
74
+ const allowed = ['code-quality', 'workflow', 'testing', 'documentation', 'security', 'devops', 'other'];
75
+ for (const p of registry.plugins) {
76
+ if (p.category) {
77
+ assert.ok(
78
+ allowed.includes(p.category),
79
+ `Plugin "${p.name}" has invalid category: "${p.category}"`
80
+ );
81
+ }
82
+ }
83
+ });
84
+
85
+ it('all type values are plugin or skill', () => {
86
+ for (const p of registry.plugins) {
87
+ if (p.type) {
88
+ assert.ok(
89
+ ['plugin', 'skill'].includes(p.type),
90
+ `Plugin "${p.name}" has invalid type: "${p.type}"`
91
+ );
92
+ }
93
+ }
94
+ });
95
+
96
+ it('all repository URLs start with https://', () => {
97
+ for (const p of registry.plugins) {
98
+ assert.ok(
99
+ p.repository.startsWith('https://'),
100
+ `Plugin "${p.name}" has invalid repository URL: "${p.repository}"`
101
+ );
102
+ }
103
+ });
104
+
105
+ it('all author objects have a name field', () => {
106
+ for (const p of registry.plugins) {
107
+ if (p.author) {
108
+ assert.ok(p.author.name, `Plugin "${p.name}" author missing name`);
109
+ }
110
+ }
111
+ });
112
+ });
@@ -0,0 +1,96 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { readFileSync, existsSync, readdirSync } from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { join, dirname } from 'node:path';
6
+ import { parseFrontmatter } from '../../src/commands/skills/add.js';
7
+
8
+ const __dirname = dirname(fileURLToPath(import.meta.url));
9
+ const ROOT = join(__dirname, '..', '..');
10
+ const SKILLS_SRC = join(ROOT, 'src', 'skills');
11
+
12
+ const registryData = JSON.parse(
13
+ readFileSync(join(ROOT, 'registry', 'registry.json'), 'utf-8')
14
+ );
15
+
16
+ const skillDirs = readdirSync(SKILLS_SRC, { withFileTypes: true })
17
+ .filter((e) => e.isDirectory())
18
+ .map((e) => e.name);
19
+
20
+ describe('embedded skills validation', () => {
21
+ it('has at least one skill', () => {
22
+ assert.ok(skillDirs.length > 0, 'No skill directories found');
23
+ });
24
+
25
+ for (const skillName of skillDirs) {
26
+ describe(`skill: ${skillName}`, () => {
27
+ const skillDir = join(SKILLS_SRC, skillName);
28
+ const skillMdPath = join(skillDir, 'SKILL.md');
29
+
30
+ it('has a SKILL.md file', () => {
31
+ assert.ok(existsSync(skillMdPath), `Missing SKILL.md in ${skillName}`);
32
+ });
33
+
34
+ it('SKILL.md frontmatter is parseable', () => {
35
+ const content = readFileSync(skillMdPath, 'utf-8');
36
+ const fm = parseFrontmatter(content);
37
+ assert.ok(fm && typeof fm === 'object');
38
+ });
39
+
40
+ it('has name in frontmatter (if frontmatter present)', () => {
41
+ const content = readFileSync(skillMdPath, 'utf-8');
42
+ if (content.startsWith('---')) {
43
+ const fm = parseFrontmatter(content);
44
+ assert.ok(fm.name, `Skill "${skillName}" has frontmatter but missing name`);
45
+ }
46
+ });
47
+
48
+ it('has description in frontmatter (if frontmatter present)', () => {
49
+ const content = readFileSync(skillMdPath, 'utf-8');
50
+ if (content.startsWith('---')) {
51
+ const fm = parseFrontmatter(content);
52
+ assert.ok(fm.description, `Skill "${skillName}" has frontmatter but missing description`);
53
+ }
54
+ });
55
+
56
+ it('SKILL.md has substantive content', () => {
57
+ const content = readFileSync(skillMdPath, 'utf-8');
58
+ const body = content.replace(/^---[\s\S]*?---/, '').trim();
59
+ assert.ok(body.length > 0, `Skill "${skillName}" SKILL.md has no content`);
60
+ });
61
+
62
+ it('references directory is not empty (if present)', () => {
63
+ const refsDir = join(skillDir, 'references');
64
+ if (existsSync(refsDir)) {
65
+ const files = readdirSync(refsDir);
66
+ assert.ok(files.length > 0, `Skill "${skillName}" has empty references/`);
67
+ }
68
+ });
69
+
70
+ it('scripts directory is not empty (if present)', () => {
71
+ const scriptsDir = join(skillDir, 'scripts');
72
+ if (existsSync(scriptsDir)) {
73
+ const files = readdirSync(scriptsDir);
74
+ assert.ok(files.length > 0, `Skill "${skillName}" has empty scripts/`);
75
+ }
76
+ });
77
+ });
78
+ }
79
+
80
+ describe('registry coverage', () => {
81
+ const registrySkills = registryData.plugins
82
+ .filter((p) => p.type === 'skill')
83
+ .map((p) => p.name);
84
+
85
+ for (const skillName of skillDirs) {
86
+ it(`skill "${skillName}" has a registry entry`, () => {
87
+ if (registrySkills.length > 0) {
88
+ const found = registrySkills.includes(skillName);
89
+ if (!found) {
90
+ assert.ok(true, `Skill "${skillName}" not in registry (may be unpublished)`);
91
+ }
92
+ }
93
+ });
94
+ }
95
+ });
96
+ });