@lifeaitools/rdc-skills 0.31.2 → 0.34.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 (73) hide show
  1. package/.claude-plugin/plugin.json +8 -8
  2. package/.github/workflows/publish.yml +35 -3
  3. package/.github/workflows/self-test.yml +34 -34
  4. package/CHANGELOG.md +348 -348
  5. package/MANIFEST.md +224 -224
  6. package/README.md +2 -2
  7. package/clauth-plugin.json +85 -0
  8. package/deploy/install-systemd.sh +0 -0
  9. package/ecosystem.local.config.cjs +18 -0
  10. package/guides/agent-bootstrap.md +195 -195
  11. package/guides/agents/backend.md +102 -102
  12. package/guides/agents/content.md +94 -94
  13. package/guides/agents/cs2.md +56 -56
  14. package/guides/agents/data.md +86 -86
  15. package/guides/agents/design.md +77 -77
  16. package/guides/agents/frontend.md +91 -91
  17. package/guides/agents/infrastructure.md +81 -81
  18. package/guides/agents/setup.md +272 -272
  19. package/guides/agents/verify.md +119 -119
  20. package/guides/agents/viz.md +106 -106
  21. package/guides/lessons-learned-spec.md +1 -1
  22. package/guides/orchestration-epic.md +17 -17
  23. package/package.json +3 -2
  24. package/scripts/clean-malformed-registrations.js +257 -0
  25. package/scripts/postinstall.js +142 -0
  26. package/scripts/self-test.mjs +1459 -1459
  27. package/skills/behavior-audit/SKILL.md +1 -1
  28. package/skills/behavior-audit/agents/openai.yaml +4 -4
  29. package/skills/brochure/SKILL.md +1 -1
  30. package/skills/{rdc-brochurify → brochurify}/SKILL.md +1 -1
  31. package/skills/build/SKILL.md +1 -1
  32. package/skills/channel-formatter/SKILL.md +1 -1
  33. package/skills/co-develop/SKILL.md +1 -1
  34. package/skills/collab/SKILL.md +1 -1
  35. package/skills/convert/SKILL.md +1 -1
  36. package/skills/deploy/SKILL.md +1 -1
  37. package/skills/design/SKILL.md +1 -1
  38. package/skills/edit/SKILL.md +1 -1
  39. package/skills/env/SKILL.md +1 -1
  40. package/skills/{rdc-extract-verifier-rules → extract-verifier-rules}/SKILL.md +1 -1
  41. package/skills/fixit/SKILL.md +1 -1
  42. package/skills/fs-mcp/SKILL.md +1 -1
  43. package/skills/handoff/SKILL.md +1 -1
  44. package/skills/help/SKILL.md +1 -1
  45. package/skills/housekeeping/SKILL.md +1 -1
  46. package/skills/onramp/SKILL.md +1 -1
  47. package/skills/overnight/SKILL.md +1 -1
  48. package/skills/plan/SKILL.md +1 -1
  49. package/skills/preplan/SKILL.md +1 -1
  50. package/skills/prototype/SKILL.md +1 -1
  51. package/skills/refactor/SKILL.md +1 -1
  52. package/skills/regen-media/SKILL.md +1 -1
  53. package/skills/release/SKILL.md +1 -1
  54. package/skills/report/SKILL.md +1 -1
  55. package/skills/review/SKILL.md +1 -1
  56. package/skills/self-test/SKILL.md +1 -1
  57. package/skills/status/SKILL.md +1 -1
  58. package/skills/terminal-config/SKILL.md +1 -1
  59. package/skills/tests/MATRIX.md +2 -2
  60. package/skills/tests/onramp.test.json +101 -101
  61. package/skills/tests/rdc-env.test.json +12 -12
  62. package/skills/tests/rdc-new-model.test.json +12 -12
  63. package/skills/tests/rdc-refactor.test.json +29 -29
  64. package/skills/tests/rdc-regen-media.test.json +29 -29
  65. package/skills/watch/SKILL.md +1 -1
  66. package/skills/workitems/SKILL.md +1 -1
  67. package/tests/channel-formatter.contract.test.mjs +2 -2
  68. package/tests/clauth-plugin-postinstall.test.mjs +256 -0
  69. package/tests/mcp.test.mjs +1 -1
  70. package/tests/plugin-cache-clean-install.test.mjs +76 -0
  71. package/tests/plugin-namespace-names.test.mjs +125 -0
  72. /package/skills/tests/{rdc-rdc-brochurify.test.json → rdc-brochurify.test.json} +0 -0
  73. /package/skills/tests/{rdc-rdc-extract-verifier-rules.test.json → rdc-extract-verifier-rules.test.json} +0 -0
@@ -1,29 +1,29 @@
1
- {
2
- "manifest_version": 1,
3
- "skill": "rdc:regen-media",
4
- "description": "Regen Media uses local keyless Codex gpt-image-2 as the primary image-generation path and avoids live generation under RDC_TEST.",
5
- "fixture": {
6
- "prompt": "rdc:regen-media generate a 2048x1152 homepage hero image",
7
- "env": { "RDC_TEST": "1" }
8
- },
9
- "assertions": {
10
- "exit_code": 0,
11
- "commits_made": { "min": 0 },
12
- "stdout_contains": ["image_gen", "gpt-image-2"]
13
- },
14
- "acceptance": {
15
- "output_contains": [
16
- "image_gen",
17
- "gpt-image-2",
18
- "--enable image_generation",
19
- "RDC_TEST"
20
- ],
21
- "output_not_contains": [
22
- "OPENAI_API_KEY=",
23
- "generate_gpt_image is the default",
24
- "upload completed",
25
- "raw MCP"
26
- ]
27
- },
28
- "teardown": { "reset_branch": true }
29
- }
1
+ {
2
+ "manifest_version": 1,
3
+ "skill": "rdc:regen-media",
4
+ "description": "Regen Media uses local keyless Codex gpt-image-2 as the primary image-generation path and avoids live generation under RDC_TEST.",
5
+ "fixture": {
6
+ "prompt": "rdc:regen-media generate a 2048x1152 homepage hero image",
7
+ "env": { "RDC_TEST": "1" }
8
+ },
9
+ "assertions": {
10
+ "exit_code": 0,
11
+ "commits_made": { "min": 0 },
12
+ "stdout_contains": ["image_gen", "gpt-image-2"]
13
+ },
14
+ "acceptance": {
15
+ "output_contains": [
16
+ "image_gen",
17
+ "gpt-image-2",
18
+ "--enable image_generation",
19
+ "RDC_TEST"
20
+ ],
21
+ "output_not_contains": [
22
+ "OPENAI_API_KEY=",
23
+ "generate_gpt_image is the default",
24
+ "upload completed",
25
+ "raw MCP"
26
+ ]
27
+ },
28
+ "teardown": { "reset_branch": true }
29
+ }
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: rdc:watch
2
+ name: watch
3
3
  description: "Usage `rdc:watch` — Initialize a live session-log viewer and report its path; open it only during attended, non-test use. Zero infrastructure — pure filesystem + static HTML."
4
4
  ---
5
5
 
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: rdc:workitems
2
+ name: workitems
3
3
  description: "Usage `rdc:workitems [add|done|status|list|query]` — Manual work item CRUD outside the normal build flow: add to backlog, mark done, update status, list open epics, query by label."
4
4
  ---
5
5
 
@@ -152,7 +152,7 @@ function inspectFormatterBody(body, prefix) {
152
152
  function unitChecks() {
153
153
  const names = listSkills().map((skill) => skill.name);
154
154
  check('unit: channel-formatter in local catalog', names.includes('channel-formatter'));
155
- for (const specialist of ['convert', 'brochure', 'rdc-brochurify', 'lifeai-brochure-author']) {
155
+ for (const specialist of ['convert', 'brochure', 'brochurify', 'lifeai-brochure-author']) {
156
156
  check(`unit: specialist present ${specialist}`, names.includes(specialist));
157
157
  }
158
158
  const results = searchSkills('turn this article into social posts');
@@ -181,7 +181,7 @@ async function endpointChecks(url, label) {
181
181
  const catalog = JSON.parse(list.text || '{"skills":[]}');
182
182
  const names = catalog.skills.map((skill) => skill.name);
183
183
  check(`${label}: list includes channel-formatter`, names.includes('channel-formatter'));
184
- for (const specialist of ['convert', 'brochure', 'rdc-brochurify', 'lifeai-brochure-author']) {
184
+ for (const specialist of ['convert', 'brochure', 'brochurify', 'lifeai-brochure-author']) {
185
185
  check(`${label}: list includes ${specialist}`, names.includes(specialist));
186
186
  }
187
187
 
@@ -0,0 +1,256 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `scripts/postinstall.js` self-registers `clauth-plugin.json` with the local
4
+ * clauth supervisor — and must NEVER be able to fail an install.
5
+ *
6
+ * The whole point of the file is its failure behaviour, so that is what is
7
+ * asserted: absent clauth, non-zero register, a spawn that throws, and a
8
+ * missing manifest all exit 0 with a printed hint. Also asserted is the path
9
+ * anchor — during `npm i -g` the cwd is not the package root, so a
10
+ * cwd-relative manifest path would resolve to nothing and silently no-op.
11
+ *
12
+ * Registration is STUBBED, never really invoked: the live managed-plugin root
13
+ * (%APPDATA%/clauth/supervisor/managed-plugins) holds real registered plugins
14
+ * that the running daemon reads, and a test has no business writing there.
15
+ */
16
+ import assert from 'node:assert/strict';
17
+ import { spawnSync } from 'node:child_process';
18
+ import { createRequire } from 'node:module';
19
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
20
+ import { tmpdir } from 'node:os';
21
+ import { dirname, join, resolve } from 'node:path';
22
+ import { fileURLToPath } from 'node:url';
23
+
24
+ const __dirname = dirname(fileURLToPath(import.meta.url));
25
+ const REPO_ROOT = resolve(__dirname, '..');
26
+ const script = join(REPO_ROOT, 'scripts', 'postinstall.js');
27
+ const require = createRequire(import.meta.url);
28
+
29
+ // ---------------------------------------------------------------- 0. wiring
30
+
31
+ const syntax = spawnSync(process.execPath, ['--check', script], { encoding: 'utf8' });
32
+ assert.equal(syntax.status, 0, syntax.stderr);
33
+
34
+ const packageJson = JSON.parse(readFileSync(join(REPO_ROOT, 'package.json'), 'utf8'));
35
+ assert.equal(
36
+ packageJson.scripts.postinstall,
37
+ 'node scripts/postinstall.js',
38
+ 'package.json must run the postinstall registrar on install',
39
+ );
40
+ assert.equal(
41
+ existsSync(join(REPO_ROOT, 'clauth-plugin.json')),
42
+ true,
43
+ 'the manifest the postinstall registers must exist in the repo',
44
+ );
45
+
46
+ const postinstall = require(script);
47
+
48
+ /** A spawnSync stub that records calls and replays canned results. */
49
+ function stubSpawn(results) {
50
+ const calls = [];
51
+ const queue = [...results];
52
+ const spawn = (command, args, options) => {
53
+ calls.push({ command, args, options });
54
+ const next = queue.shift();
55
+ if (typeof next === 'function') return next();
56
+ return next ?? { status: 0, stdout: '', stderr: '' };
57
+ };
58
+ return { spawn, calls };
59
+ }
60
+
61
+ function collectLog() {
62
+ const lines = [];
63
+ return { log: (line) => lines.push(String(line)), lines };
64
+ }
65
+
66
+ /**
67
+ * A child env whose ONLY PATH entry is `dir`, so nothing named `clauth` can
68
+ * resolve. Windows env vars are case-insensitive, so every existing spelling of
69
+ * PATH has to be dropped first or the original one wins non-deterministically.
70
+ */
71
+ function envWithOnlyPath(dir) {
72
+ const env = {};
73
+ for (const [key, value] of Object.entries(process.env)) {
74
+ if (/^path$/i.test(key) || key === postinstall.SKIP_ENV) continue;
75
+ env[key] = value;
76
+ }
77
+ env.PATH = dir;
78
+ return env;
79
+ }
80
+
81
+ // ------------------------------------------- 1. clauth absent -> exit 0 + hint
82
+ //
83
+ // Run the real script as a child with PATH stripped to an empty directory, so
84
+ // nothing named `clauth` can resolve. This is the end-to-end form of the
85
+ // contract: a box without clauth still gets a successful install.
86
+
87
+ const emptyPathDir = mkdtempSync(join(tmpdir(), 'rdc-postinstall-nopath-'));
88
+ try {
89
+ const run = spawnSync(process.execPath, [script], {
90
+ cwd: REPO_ROOT,
91
+ encoding: 'utf8',
92
+ env: envWithOnlyPath(emptyPathDir),
93
+ });
94
+ assert.equal(run.status, 0, `postinstall must exit 0 without clauth on PATH:\n${run.stdout}\n${run.stderr}`);
95
+ assert.match(
96
+ run.stdout,
97
+ /clauth not found/,
98
+ `expected a "clauth not found" hint, got:\n${run.stdout}`,
99
+ );
100
+ assert.match(
101
+ run.stdout,
102
+ /clauth plugin register/,
103
+ 'the hint must carry the manual command so a human can finish registration',
104
+ );
105
+ } finally {
106
+ rmSync(emptyPathDir, { recursive: true, force: true });
107
+ }
108
+
109
+ // --------------------------------- 2. clauth present -> register invoked right
110
+
111
+ {
112
+ const { spawn, calls } = stubSpawn([
113
+ { status: 0, stdout: '0.33.0\n', stderr: '' }, // --version probe
114
+ { status: 0, stdout: 'registered\n', stderr: '' }, // plugin register
115
+ ]);
116
+ const { log, lines } = collectLog();
117
+ const result = postinstall.register({ spawn, log });
118
+
119
+ assert.equal(result.outcome, 'registered');
120
+ assert.equal(calls.length, 2, 'expected a --version probe followed by the register call');
121
+ assert.equal(calls[0].command, 'clauth');
122
+ assert.deepEqual(calls[0].args.map(unquote), ['--version']);
123
+
124
+ assert.equal(calls[1].command, 'clauth');
125
+ assert.deepEqual(
126
+ calls[1].args.map(unquote),
127
+ ['plugin', 'register', join(REPO_ROOT, 'clauth-plugin.json')],
128
+ 'register must be called as `clauth plugin register <package-root>/clauth-plugin.json`',
129
+ );
130
+ assert.ok(calls[1].options.timeout > 0, 'the register call must be time-bounded');
131
+ assert.match(lines.join('\n'), /registered/);
132
+ }
133
+
134
+ // ------------------------- 3. non-zero register / throwing spawn -> exit 0 + hint
135
+
136
+ {
137
+ const { spawn } = stubSpawn([
138
+ { status: 0, stdout: '0.33.0\n', stderr: '' },
139
+ { status: 1, stdout: '', stderr: 'manifest rejected: bad schema\n' },
140
+ ]);
141
+ const { log, lines } = collectLog();
142
+ const result = postinstall.register({ spawn, log });
143
+
144
+ assert.equal(result.outcome, 'register-failed', 'a non-zero register must not throw');
145
+ assert.match(lines.join('\n'), /clauth plugin register failed/);
146
+ assert.match(lines.join('\n'), /manifest rejected: bad schema/, 'the hint should surface why');
147
+ assert.match(lines.join('\n'), /retry with: clauth plugin register/);
148
+ }
149
+
150
+ {
151
+ // spawn itself blowing up (EPERM, locked binary, AV interference) is still exit 0.
152
+ const { spawn } = stubSpawn([
153
+ { status: 0, stdout: '0.33.0\n', stderr: '' },
154
+ () => {
155
+ throw new Error('EPERM: operation not permitted');
156
+ },
157
+ ]);
158
+ const { log, lines } = collectLog();
159
+ const result = postinstall.register({ spawn, log });
160
+
161
+ assert.equal(result.outcome, 'spawn-failed');
162
+ assert.match(lines.join('\n'), /EPERM/);
163
+ }
164
+
165
+ {
166
+ // clauth on PATH but broken (`--version` non-zero) reads as "not available".
167
+ const { spawn, calls } = stubSpawn([{ status: 9009, stdout: '', stderr: 'not recognized' }]);
168
+ const { log, lines } = collectLog();
169
+ const result = postinstall.register({ spawn, log });
170
+
171
+ assert.equal(result.outcome, 'no-clauth');
172
+ assert.equal(calls.length, 1, 'a failed probe must not go on to attempt registration');
173
+ assert.match(lines.join('\n'), /clauth not found/);
174
+ }
175
+
176
+ {
177
+ // A package root with no manifest is a hint, not a crash.
178
+ const bare = mkdtempSync(join(tmpdir(), 'rdc-postinstall-bare-'));
179
+ try {
180
+ const { spawn, calls } = stubSpawn([]);
181
+ const { log, lines } = collectLog();
182
+ const result = postinstall.register({ packageRoot: bare, spawn, log });
183
+
184
+ assert.equal(result.outcome, 'no-manifest');
185
+ assert.equal(calls.length, 0, 'a missing manifest must not spawn anything');
186
+ assert.match(lines.join('\n'), /not found/);
187
+ } finally {
188
+ rmSync(bare, { recursive: true, force: true });
189
+ }
190
+ }
191
+
192
+ // ----------------------------------- 4. the manifest path is package-anchored
193
+ //
194
+ // `npm i -g` runs the postinstall from an arbitrary cwd. Anything cwd-relative
195
+ // would resolve to a path that does not exist and register nothing at all.
196
+
197
+ {
198
+ assert.equal(
199
+ postinstall.manifestPath(),
200
+ join(REPO_ROOT, 'clauth-plugin.json'),
201
+ 'the default manifest path must be the package root, not the cwd',
202
+ );
203
+
204
+ const foreignCwd = mkdtempSync(join(tmpdir(), 'rdc-postinstall-cwd-'));
205
+ try {
206
+ const run = spawnSync(process.execPath, [script], {
207
+ cwd: foreignCwd, // deliberately NOT the package root
208
+ encoding: 'utf8',
209
+ env: envWithOnlyPath(foreignCwd),
210
+ });
211
+ assert.equal(run.status, 0, `${run.stdout}\n${run.stderr}`);
212
+ assert.ok(
213
+ run.stdout.includes(join(REPO_ROOT, 'clauth-plugin.json')),
214
+ `hint must name the package-root manifest, not a cwd-relative one:\n${run.stdout}`,
215
+ );
216
+ assert.ok(
217
+ !run.stdout.includes(join(foreignCwd, 'clauth-plugin.json')),
218
+ 'the manifest path must never be resolved against the caller cwd',
219
+ );
220
+ } finally {
221
+ rmSync(foreignCwd, { recursive: true, force: true });
222
+ }
223
+ }
224
+
225
+ // --------------------------------- 5. the manifest actually ships in the tarball
226
+ //
227
+ // Self-registration is worthless if `clauth-plugin.json` is not in the package
228
+ // the user installed.
229
+
230
+ {
231
+ const pack = spawnSync('npm', ['pack', '--dry-run'], {
232
+ cwd: REPO_ROOT,
233
+ encoding: 'utf8',
234
+ shell: process.platform === 'win32',
235
+ });
236
+ assert.equal(pack.status, 0, `npm pack --dry-run failed:\n${pack.stdout}\n${pack.stderr}`);
237
+ const packOutput = `${pack.stdout}\n${pack.stderr}`;
238
+ assert.match(
239
+ packOutput,
240
+ /clauth-plugin\.json/,
241
+ 'clauth-plugin.json must be present in the published tarball',
242
+ );
243
+ assert.match(
244
+ packOutput,
245
+ /scripts\/postinstall\.js/,
246
+ 'scripts/postinstall.js must be present in the published tarball',
247
+ );
248
+ }
249
+
250
+ /** Strip the Windows shell quoting the script applies to spawn args. */
251
+ function unquote(arg) {
252
+ const s = String(arg);
253
+ return s.startsWith('"') && s.endsWith('"') ? s.slice(1, -1).replace(/\\"/g, '"') : s;
254
+ }
255
+
256
+ console.log('clauth plugin postinstall tests — PASS');
@@ -115,7 +115,7 @@ function unitTests() {
115
115
  check('unit: resolves bare skill name', resolveSkillName('build') === 'build');
116
116
  check('unit: resolves rdc slash skill name', resolveSkillName('rdc:build') === 'build');
117
117
  check('unit: resolves leading slash command form', resolveSkillName('/rdc:build') === 'build');
118
- check('unit: resolves specialist slash alias', resolveSkillName('rdc:brochurify') === 'rdc-brochurify');
118
+ check('unit: resolves specialist slash alias', resolveSkillName('rdc:brochurify') === 'brochurify');
119
119
  check('unit: resolves non-rdc specialist name', resolveSkillName('lifeai-brochure-author') === 'lifeai-brochure-author');
120
120
 
121
121
  // Searchability gate — the dimension the first "100% coverage" missed. Every
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Installing REBUILDS the plugin cache from scratch — a skill removed or renamed
3
+ * upstream must not survive in it.
4
+ *
5
+ * Claude Code registers whatever sits in the plugin cache, so anything left behind
6
+ * there keeps registering. A rename like `skills/rdc-brochurify/` ->
7
+ * `skills/brochurify/` would otherwise leave the old folder in place and the box
8
+ * would register BOTH `rdc:brochurify` and the double-namespaced
9
+ * `rdc:rdc-brochurify` — the shape of the malformed registrations this branch fixes.
10
+ *
11
+ * The installer already gets this right: both call sites `fs.rmSync(latestDir, ...)`
12
+ * immediately before `buildPluginCache(latestDir, ...)`. This test exists to keep it
13
+ * that way, because the guarantee lives in two separate places and neither states
14
+ * that it is load-bearing.
15
+ *
16
+ * WORTH RECORDING, because it nearly went the other way: the first version of this
17
+ * file shipped alongside an "obvious" fix to buildPluginCache — clear each payload
18
+ * directory before copying into it — on the theory that copy-over-merge was leaving
19
+ * the stale folders. Reverting that fix and re-running left the test GREEN, which is
20
+ * what exposed the diagnosis as wrong: the wipe already happens one level up, at the
21
+ * caller. Without that control run, a redundant change and a test that could not
22
+ * detect its own subject would both have shipped, and the real source of the stale
23
+ * registrations would have stayed hidden behind an apparent fix.
24
+ */
25
+ import { test } from 'node:test';
26
+ import assert from 'node:assert/strict';
27
+ import { execFileSync } from 'node:child_process';
28
+ import fs from 'node:fs';
29
+ import os from 'node:os';
30
+ import path from 'node:path';
31
+ import { fileURLToPath } from 'node:url';
32
+
33
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
34
+ const INSTALLER = path.join(ROOT, 'scripts', 'install-rdc-skills.js');
35
+
36
+ function runInstaller(claudeHome) {
37
+ try {
38
+ return execFileSync(process.execPath, [INSTALLER, '--claude-home', claudeHome, '--skip-hooks'], {
39
+ encoding: 'utf8', timeout: 300_000, cwd: ROOT,
40
+ });
41
+ } catch (err) {
42
+ return `${err.stdout || ''}${err.stderr || ''}`;
43
+ }
44
+ }
45
+
46
+ test('a stale skill directory in the plugin cache does not survive an install', () => {
47
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'rdcskills-home-'));
48
+
49
+ const first = runInstaller(home);
50
+ // The cache carries a version segment: cache/<marketplace>/<plugin>/<version>/skills
51
+ const cache = path.join(home, 'plugins', 'cache', 'rdc-skills', 'rdc-skills', 'latest', 'skills');
52
+ assert.ok(fs.existsSync(cache), `expected a plugin skills cache — installer said:\n${first.slice(-800)}`);
53
+
54
+ // Plant exactly the shape the defect produces: a skill folder that no longer exists
55
+ // upstream, carrying the double-namespaced name.
56
+ const stale = path.join(cache, 'rdc-brochurify');
57
+ fs.mkdirSync(stale, { recursive: true });
58
+ fs.writeFileSync(path.join(stale, 'SKILL.md'), '---\nname: rdc-brochurify\n---\nstale\n');
59
+ assert.ok(fs.existsSync(stale), 'precondition: decoy planted');
60
+
61
+ runInstaller(home);
62
+ assert.ok(!fs.existsSync(stale), 'a skill removed upstream must not survive in the cache');
63
+ assert.ok(
64
+ fs.readdirSync(cache).length > 0,
65
+ 'control: the cache must still hold real skills — an empty cache would satisfy the assertion above for the wrong reason',
66
+ );
67
+ });
68
+
69
+ test('no rdc-prefixed skill directory ships in the repo', () => {
70
+ // The plugin is already named `rdc`, so a directory named `rdc-x` registers as
71
+ // `rdc:rdc-x`. This is the source-side half of the same defect, and it is the half
72
+ // that actually produced the malformed names.
73
+ const skills = path.join(ROOT, 'skills');
74
+ const bad = fs.readdirSync(skills).filter((d) => /^rdc-/.test(d));
75
+ assert.deepEqual(bad, [], `these register as rdc:rdc-*: ${bad.join(', ')}`);
76
+ });
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Regression guard: the plugin namespace is applied ONCE.
4
+ *
5
+ * `.claude-plugin/plugin.json` declares `"name": "rdc"`. Claude Code registers a
6
+ * plugin component as `<plugin>:<declared frontmatter name>`. So any component
7
+ * that ALSO spells the namespace into its own `name:` gets it twice, and the
8
+ * menu renders `/rdc:rdc:plan` beside `/rdc:plan` — one entry per source.
9
+ *
10
+ * That defect was fixed for `commands/` in 643e198 ("the plugin namespace
11
+ * already says rdc — stop saying it twice") and `skills/` was never migrated,
12
+ * so 31 skills kept declaring `name: rdc:<x>` and two more carried the
13
+ * namespace in their DIRECTORY name (`skills/rdc-brochurify/`), composing to
14
+ * `rdc:rdc-brochurify`.
15
+ *
16
+ * Why the existing guards missed it: `tests/manifest-contract-fields.test.mjs`
17
+ * and `tests/mcp.test.mjs` both assert `!/^rdc:rdc-/` against
18
+ * `plugin.json.skills_meta[].slash` — a HAND-AUTHORED field that was already
19
+ * spelled correctly (`rdc:brochurify`). Nothing asserted anything about the
20
+ * frontmatter `name:` or the directory name, which is what the loader actually
21
+ * composes from. This test checks the registering artifact, not the aspiration.
22
+ */
23
+ import assert from 'node:assert/strict';
24
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
25
+ import { dirname, join, resolve } from 'node:path';
26
+ import { fileURLToPath } from 'node:url';
27
+
28
+ const __dirname = dirname(fileURLToPath(import.meta.url));
29
+ const root = resolve(__dirname, '..');
30
+
31
+ const plugin = JSON.parse(readFileSync(join(root, '.claude-plugin', 'plugin.json'), 'utf8'));
32
+ const PLUGIN = plugin.name;
33
+ assert.ok(PLUGIN, '.claude-plugin/plugin.json must declare a plugin name');
34
+
35
+ /** Declared `name:` from a markdown frontmatter block, or null when absent. */
36
+ function declaredName(file) {
37
+ const raw = readFileSync(file, 'utf8').replace(/\r\n/g, '\n');
38
+ const block = raw.match(/^---\n([\s\S]*?)\n---/);
39
+ if (!block) return null;
40
+ const line = block[1].split('\n').find((l) => /^name:\s*/.test(l));
41
+ return line ? line.replace(/^name:\s*/, '').trim().replace(/^["']|["']$/g, '') : null;
42
+ }
43
+
44
+ /** Every registering component: its file, the id it composes to, and its source. */
45
+ function components() {
46
+ const out = [];
47
+
48
+ const cmdDir = join(root, 'commands');
49
+ if (existsSync(cmdDir)) {
50
+ for (const f of readdirSync(cmdDir).filter((f) => f.endsWith('.md')).sort()) {
51
+ const file = join(cmdDir, f);
52
+ out.push({
53
+ kind: 'command',
54
+ file: `commands/${f}`,
55
+ dir: f.replace(/\.md$/, ''),
56
+ declared: declaredName(file),
57
+ });
58
+ }
59
+ }
60
+
61
+ const skillsDir = join(root, 'skills');
62
+ for (const entry of readdirSync(skillsDir, { withFileTypes: true })) {
63
+ if (!entry.isDirectory() || entry.name === 'tests') continue;
64
+ const file = join(skillsDir, entry.name, 'SKILL.md');
65
+ if (!existsSync(file)) continue;
66
+ out.push({
67
+ kind: 'skill',
68
+ file: `skills/${entry.name}/SKILL.md`,
69
+ dir: entry.name,
70
+ declared: declaredName(file),
71
+ });
72
+ }
73
+
74
+ return out;
75
+ }
76
+
77
+ const all = components();
78
+ assert.ok(all.length > 0, 'expected at least one plugin component to check');
79
+
80
+ const offenders = { doublePrefixed: [], dirPrefixed: [] };
81
+
82
+ for (const c of all) {
83
+ // The loader falls back to the directory / file stem when `name:` is absent.
84
+ const effective = c.declared ?? c.dir;
85
+
86
+ // 1. The declared name must not repeat the plugin namespace.
87
+ // `name: rdc:plan` under plugin `rdc` composes to `rdc:rdc:plan`.
88
+ if (effective === PLUGIN || effective.startsWith(`${PLUGIN}:`)) {
89
+ offenders.doublePrefixed.push(`${c.file} name: ${effective} -> ${PLUGIN}:${effective}`);
90
+ }
91
+
92
+ // 2. Same defect, hyphen spelling: a directory named `rdc-brochurify`
93
+ // composes to `rdc:rdc-brochurify`.
94
+ if (effective.startsWith(`${PLUGIN}-`)) {
95
+ offenders.dirPrefixed.push(`${c.file} name: ${effective} -> ${PLUGIN}:${effective}`);
96
+ }
97
+ }
98
+
99
+ assert.deepEqual(
100
+ offenders.doublePrefixed,
101
+ [],
102
+ `these components spell the "${PLUGIN}" namespace twice — the plugin supplies it:\n ` +
103
+ offenders.doublePrefixed.join('\n '),
104
+ );
105
+
106
+ assert.deepEqual(
107
+ offenders.dirPrefixed,
108
+ [],
109
+ `these components carry the "${PLUGIN}-" namespace in their own name — the plugin supplies it:\n ` +
110
+ offenders.dirPrefixed.join('\n '),
111
+ );
112
+
113
+ // 3. No two components of the same kind may compose to the same registered id.
114
+ for (const kind of ['command', 'skill']) {
115
+ const seen = new Map();
116
+ for (const c of all.filter((c) => c.kind === kind)) {
117
+ const id = `${PLUGIN}:${c.declared ?? c.dir}`;
118
+ if (seen.has(id)) {
119
+ assert.fail(`duplicate ${kind} registration ${id}: ${seen.get(id)} and ${c.file}`);
120
+ }
121
+ seen.set(id, c.file);
122
+ }
123
+ }
124
+
125
+ console.log(`plugin namespace name tests — PASS (${all.length} components, namespace "${PLUGIN}")`);