@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.
- package/.claude-plugin/plugin.json +8 -8
- package/.github/workflows/publish.yml +35 -3
- package/.github/workflows/self-test.yml +34 -34
- package/CHANGELOG.md +348 -348
- package/MANIFEST.md +224 -224
- package/README.md +2 -2
- package/clauth-plugin.json +85 -0
- package/deploy/install-systemd.sh +0 -0
- package/ecosystem.local.config.cjs +18 -0
- package/guides/agent-bootstrap.md +195 -195
- package/guides/agents/backend.md +102 -102
- package/guides/agents/content.md +94 -94
- package/guides/agents/cs2.md +56 -56
- package/guides/agents/data.md +86 -86
- package/guides/agents/design.md +77 -77
- package/guides/agents/frontend.md +91 -91
- package/guides/agents/infrastructure.md +81 -81
- package/guides/agents/setup.md +272 -272
- package/guides/agents/verify.md +119 -119
- package/guides/agents/viz.md +106 -106
- package/guides/lessons-learned-spec.md +1 -1
- package/guides/orchestration-epic.md +17 -17
- package/package.json +3 -2
- package/scripts/clean-malformed-registrations.js +257 -0
- package/scripts/postinstall.js +142 -0
- package/scripts/self-test.mjs +1459 -1459
- package/skills/behavior-audit/SKILL.md +1 -1
- package/skills/behavior-audit/agents/openai.yaml +4 -4
- package/skills/brochure/SKILL.md +1 -1
- package/skills/{rdc-brochurify → brochurify}/SKILL.md +1 -1
- package/skills/build/SKILL.md +1 -1
- package/skills/channel-formatter/SKILL.md +1 -1
- package/skills/co-develop/SKILL.md +1 -1
- package/skills/collab/SKILL.md +1 -1
- package/skills/convert/SKILL.md +1 -1
- package/skills/deploy/SKILL.md +1 -1
- package/skills/design/SKILL.md +1 -1
- package/skills/edit/SKILL.md +1 -1
- package/skills/env/SKILL.md +1 -1
- package/skills/{rdc-extract-verifier-rules → extract-verifier-rules}/SKILL.md +1 -1
- package/skills/fixit/SKILL.md +1 -1
- package/skills/fs-mcp/SKILL.md +1 -1
- package/skills/handoff/SKILL.md +1 -1
- package/skills/help/SKILL.md +1 -1
- package/skills/housekeeping/SKILL.md +1 -1
- package/skills/onramp/SKILL.md +1 -1
- package/skills/overnight/SKILL.md +1 -1
- package/skills/plan/SKILL.md +1 -1
- package/skills/preplan/SKILL.md +1 -1
- package/skills/prototype/SKILL.md +1 -1
- package/skills/refactor/SKILL.md +1 -1
- package/skills/regen-media/SKILL.md +1 -1
- package/skills/release/SKILL.md +1 -1
- package/skills/report/SKILL.md +1 -1
- package/skills/review/SKILL.md +1 -1
- package/skills/self-test/SKILL.md +1 -1
- package/skills/status/SKILL.md +1 -1
- package/skills/terminal-config/SKILL.md +1 -1
- package/skills/tests/MATRIX.md +2 -2
- package/skills/tests/onramp.test.json +101 -101
- package/skills/tests/rdc-env.test.json +12 -12
- package/skills/tests/rdc-new-model.test.json +12 -12
- package/skills/tests/rdc-refactor.test.json +29 -29
- package/skills/tests/rdc-regen-media.test.json +29 -29
- package/skills/watch/SKILL.md +1 -1
- package/skills/workitems/SKILL.md +1 -1
- package/tests/channel-formatter.contract.test.mjs +2 -2
- package/tests/clauth-plugin-postinstall.test.mjs +256 -0
- package/tests/mcp.test.mjs +1 -1
- package/tests/plugin-cache-clean-install.test.mjs +76 -0
- package/tests/plugin-namespace-names.test.mjs +125 -0
- /package/skills/tests/{rdc-rdc-brochurify.test.json → rdc-brochurify.test.json} +0 -0
- /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
|
+
}
|
package/skills/watch/SKILL.md
CHANGED
|
@@ -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', '
|
|
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', '
|
|
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');
|
package/tests/mcp.test.mjs
CHANGED
|
@@ -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') === '
|
|
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}")`);
|
|
File without changes
|
|
File without changes
|