@lifeaitools/rdc-skills 0.24.38 → 0.24.41

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 (196) hide show
  1. package/.claude/settings.json +15 -15
  2. package/.claude-plugin/marketplace.json +21 -21
  3. package/.claude-plugin/plugin.json +1371 -1371
  4. package/.github/workflows/publish.yml +34 -34
  5. package/.github/workflows/self-test.yml +58 -58
  6. package/CHANGELOG.md +310 -310
  7. package/LICENSE +21 -21
  8. package/MANIFEST.md +221 -221
  9. package/README.md +376 -375
  10. package/README.sandbox.md +3 -3
  11. package/RELEASE.md +42 -0
  12. package/assets/watcher/viewer.html +164 -164
  13. package/bin/rdc-skills-mcp.mjs +316 -316
  14. package/commands/build.md +183 -183
  15. package/commands/collab.md +180 -180
  16. package/commands/deploy.md +152 -152
  17. package/commands/design.md +31 -31
  18. package/commands/edit.md +28 -28
  19. package/commands/fixit.md +124 -124
  20. package/commands/handoff.md +173 -173
  21. package/commands/help.md +95 -95
  22. package/commands/overnight.md +220 -220
  23. package/commands/plan.md +158 -158
  24. package/commands/preplan.md +131 -131
  25. package/commands/prototype.md +145 -145
  26. package/commands/release.md +49 -49
  27. package/commands/report.md +99 -99
  28. package/commands/review.md +120 -120
  29. package/commands/self-test.md +113 -113
  30. package/commands/status.md +86 -86
  31. package/commands/watch.md +98 -98
  32. package/commands/workitems.md +137 -137
  33. package/git-sha.json +1 -1
  34. package/guides/agent-bootstrap.md +295 -295
  35. package/guides/agents/backend.md +104 -104
  36. package/guides/agents/content.md +94 -94
  37. package/guides/agents/cs2.md +56 -56
  38. package/guides/agents/data.md +87 -87
  39. package/guides/agents/design.md +77 -77
  40. package/guides/agents/frontend.md +92 -92
  41. package/guides/agents/infrastructure.md +81 -81
  42. package/guides/agents/setup.md +281 -281
  43. package/guides/agents/verify.md +151 -151
  44. package/guides/agents/viz.md +106 -106
  45. package/guides/backend.md +146 -146
  46. package/guides/content.md +147 -147
  47. package/guides/cs2.md +190 -190
  48. package/guides/data.md +123 -123
  49. package/guides/design.md +116 -116
  50. package/guides/engineering-behavior.md +43 -43
  51. package/guides/escalation-protocol.md +125 -125
  52. package/guides/frontend.md +151 -151
  53. package/guides/history-md-spec.md +297 -297
  54. package/guides/infrastructure.md +179 -179
  55. package/guides/lessons-learned-spec.md +151 -145
  56. package/guides/output-contract.md +108 -108
  57. package/guides/publish-md-spec.md +289 -289
  58. package/guides/rdc-skills-startup.md +30 -30
  59. package/guides/verify.md +11 -11
  60. package/hooks/check-cwd.js +31 -31
  61. package/hooks/check-rdc-environment.js +164 -164
  62. package/hooks/check-services.js +6 -6
  63. package/hooks/check-stale-work-items.js +19 -19
  64. package/hooks/foreground-process-gate.js +128 -128
  65. package/hooks/gate-watchdog-selfcheck.js +257 -257
  66. package/hooks/hook-logger.js +25 -25
  67. package/hooks/lib/run-evidence-gate.mjs +241 -241
  68. package/hooks/no-stop-open-epics.js +127 -127
  69. package/hooks/post-tool-batch-gate.js +203 -203
  70. package/hooks/post-work-check.js +21 -21
  71. package/hooks/postcompact-log.js +13 -13
  72. package/hooks/precompact-log.js +13 -13
  73. package/hooks/rate-limit-retry.js +46 -46
  74. package/hooks/rdc-invocation-marker.js +157 -157
  75. package/hooks/rdc-output-contract-gate.js +94 -94
  76. package/hooks/require-work-item-on-commit.js +294 -294
  77. package/hooks/restart-brief.js +19 -19
  78. package/hooks/run-hidden-hook.ps1 +47 -47
  79. package/hooks/task-completed-gate.js +274 -274
  80. package/hooks/work-item-exit-gate.js +944 -944
  81. package/lib/catalog.mjs +236 -236
  82. package/lib/cloud-rewrite.mjs +155 -155
  83. package/package.json +57 -56
  84. package/rules/work-items-rpc.md +520 -520
  85. package/scaffold/templates/HISTORY.md.template +39 -39
  86. package/scaffold/templates/PUBLISH.md.template +21 -21
  87. package/scaffold/templates/brochure-studio-default.html +70 -70
  88. package/scripts/acceptance.mjs +502 -502
  89. package/scripts/fixtures/guides/bad-guide.md +15 -15
  90. package/scripts/fixtures/guides-clean/good-guide.md +16 -16
  91. package/scripts/install-rdc-skills.js +1289 -1289
  92. package/scripts/install.ps1 +202 -202
  93. package/scripts/install.sh +132 -132
  94. package/scripts/lib/assertions.mjs +287 -287
  95. package/scripts/lib/manifest-schema.mjs +754 -754
  96. package/scripts/lib/runner.mjs +465 -465
  97. package/scripts/lib/sandbox.mjs +435 -435
  98. package/scripts/prepack.mjs +32 -32
  99. package/scripts/rdc-brochure.mjs +482 -464
  100. package/scripts/rdc-design-cli.mjs +134 -134
  101. package/scripts/rebuild-mcp.mjs +107 -107
  102. package/scripts/self-test.mjs +1460 -1460
  103. package/scripts/stamp-git-sha.mjs +29 -29
  104. package/scripts/test-guide-validator.mjs +196 -196
  105. package/scripts/test-rdc-hooks.mjs +145 -145
  106. package/scripts/uninstall.ps1 +77 -77
  107. package/scripts/uninstall.sh +69 -69
  108. package/scripts/update.ps1 +43 -43
  109. package/scripts/update.sh +43 -43
  110. package/scripts/validate-place-histories.js +461 -461
  111. package/scripts/validate-publish-manifests.js +424 -424
  112. package/scripts/watch-init.mjs +100 -100
  113. package/skills/brochure/SKILL.md +107 -107
  114. package/skills/build/SKILL.md +563 -563
  115. package/skills/channel-formatter/SKILL.md +533 -533
  116. package/skills/co-develop/SKILL.md +196 -196
  117. package/skills/collab/SKILL.md +239 -239
  118. package/skills/convert/SKILL.md +140 -140
  119. package/skills/deploy/SKILL.md +541 -541
  120. package/skills/design/SKILL.md +211 -211
  121. package/skills/design/reference/ownership.md +16 -16
  122. package/skills/design/reference/rampa.md +92 -92
  123. package/skills/design/reference/studio-model.md +153 -153
  124. package/skills/edit/SKILL.md +98 -98
  125. package/skills/fixit/SKILL.md +165 -165
  126. package/skills/fs-mcp/SKILL.md +148 -148
  127. package/skills/handoff/SKILL.md +236 -200
  128. package/skills/help/SKILL.md +143 -143
  129. package/skills/housekeeping/SKILL.md +219 -160
  130. package/skills/lifeai-brochure-author/SKILL.md +340 -340
  131. package/skills/overnight/SKILL.md +251 -251
  132. package/skills/plan/SKILL.md +345 -345
  133. package/skills/preplan/SKILL.md +90 -90
  134. package/skills/prototype/SKILL.md +150 -150
  135. package/skills/rdc-brochurify/SKILL.md +245 -245
  136. package/skills/rdc-extract-verifier-rules/SKILL.md +191 -191
  137. package/skills/release/SKILL.md +140 -140
  138. package/skills/report/SKILL.md +100 -100
  139. package/skills/review/SKILL.md +152 -152
  140. package/skills/rpms-filemap/SKILL.cloud.md +111 -111
  141. package/skills/rpms-filemap/SKILL.md +111 -111
  142. package/skills/self-test/SKILL.md +132 -132
  143. package/skills/status/SKILL.md +99 -99
  144. package/skills/terminal-config/SKILL.md +62 -62
  145. package/skills/tests/MATRIX.md +54 -54
  146. package/skills/tests/README.md +47 -47
  147. package/skills/tests/rdc-brochure.test.json +34 -34
  148. package/skills/tests/rdc-build.test.json +36 -36
  149. package/skills/tests/rdc-channel-formatter.test.json +45 -45
  150. package/skills/tests/rdc-co-develop.test.json +29 -29
  151. package/skills/tests/rdc-collab.test.json +29 -29
  152. package/skills/tests/rdc-convert.test.json +35 -35
  153. package/skills/tests/rdc-deploy.test.json +30 -30
  154. package/skills/tests/rdc-design.test.json +27 -27
  155. package/skills/tests/rdc-edit.test.json +29 -29
  156. package/skills/tests/rdc-fixit.test.json +36 -36
  157. package/skills/tests/rdc-fs-mcp.test.json +36 -36
  158. package/skills/tests/rdc-handoff.test.json +28 -28
  159. package/skills/tests/rdc-help.test.json +29 -29
  160. package/skills/tests/rdc-housekeeping.test.json +32 -28
  161. package/skills/tests/rdc-lifeai-brochure-author.test.json +35 -35
  162. package/skills/tests/rdc-overnight.test.json +37 -37
  163. package/skills/tests/rdc-plan.test.json +27 -27
  164. package/skills/tests/rdc-preplan.test.json +31 -31
  165. package/skills/tests/rdc-prototype.test.json +28 -28
  166. package/skills/tests/rdc-rdc-brochurify.test.json +23 -23
  167. package/skills/tests/rdc-rdc-extract-verifier-rules.test.json +34 -34
  168. package/skills/tests/rdc-release.test.json +29 -29
  169. package/skills/tests/rdc-report.test.json +28 -28
  170. package/skills/tests/rdc-review.test.json +29 -29
  171. package/skills/tests/rdc-rpms-filemap.test.json +28 -28
  172. package/skills/tests/rdc-self-test.test.json +24 -24
  173. package/skills/tests/rdc-status.test.json +29 -29
  174. package/skills/tests/rdc-terminal-config.test.json +29 -29
  175. package/skills/tests/rdc-watch.test.json +24 -24
  176. package/skills/tests/rdc-workitems.test.json +27 -27
  177. package/skills/watch/SKILL.md +97 -97
  178. package/skills/workitems/SKILL.md +151 -151
  179. package/tests/acceptance.test.mjs +59 -59
  180. package/tests/channel-formatter.contract.test.mjs +251 -251
  181. package/tests/curl-surface.test.mjs +289 -289
  182. package/tests/harness-gates.test.mjs +325 -325
  183. package/tests/help-surface.test.mjs +61 -61
  184. package/tests/housekeeping-lessons-triage.test.mjs +49 -0
  185. package/tests/install-rdc-skills.test.mjs +49 -49
  186. package/tests/lessons-pipeline-contract.test.mjs +26 -0
  187. package/tests/manifest-contract-fields.test.mjs +78 -78
  188. package/tests/mcp.test.mjs +271 -271
  189. package/tests/rdc-brochure.test.mjs +125 -0
  190. package/tests/release-contract.test.mjs +16 -0
  191. package/tests/require-work-item-on-commit.test.mjs +162 -162
  192. package/tests/run-evidence-gate.test.mjs +82 -82
  193. package/tests/skill-test-matrix.test.mjs +66 -66
  194. package/tests/validate-skills.js +27 -27
  195. package/tests/work-item-exit-gate-l2.test.mjs +368 -368
  196. package/tests/work-item-exit-gate-l3.test.mjs +197 -197
@@ -1,271 +1,271 @@
1
- #!/usr/bin/env node
2
- /**
3
- * tests/mcp.test.mjs — layered test for the rdc-skills MCP server.
4
- *
5
- * node tests/mcp.test.mjs # unit + integration (spawns the server)
6
- * REMOTE=1 node tests/mcp.test.mjs # ALSO sweep the live tunnel endpoint
7
- *
8
- * Coverage is 100% of skills: every skill in the catalog is fetched through the
9
- * MCP in BOTH variants and asserted against its on-disk source, and the cloud
10
- * contract (no `127.0.0.1:52437` daemon URL) is checked for EVERY skill.
11
- *
12
- * Layers:
13
- * 1. unit — catalog + cloud-rewrite pure functions.
14
- * 2. integration — spawn `bin/rdc-skills-mcp.mjs` on a test port; exercise
15
- * /health and the full MCP (initialize, tools/list, every
16
- * tool, every skill, error paths) over HTTP, comparing each
17
- * rendered body byte-for-byte to the library's own output.
18
- * 3. systems — (REMOTE=1) the same cloud-contract sweep against
19
- * https://rdc-skills.regendevcorp.com/mcp through Cloudflare.
20
- *
21
- * Dependency-free: node builtins + the project's own lib + global fetch.
22
- */
23
-
24
- import { spawn } from 'node:child_process';
25
- import path from 'node:path';
26
- import { fileURLToPath } from 'node:url';
27
-
28
- import {
29
- listSkills,
30
- getSkill,
31
- skillNames,
32
- getSkillBody,
33
- getCloudOverride,
34
- searchSkills,
35
- resolveSkillName,
36
- } from '../lib/catalog.mjs';
37
- import { toCloudBody } from '../lib/cloud-rewrite.mjs';
38
-
39
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
40
- const REPO_ROOT = path.resolve(__dirname, '..');
41
- const BIN = path.join(REPO_ROOT, 'bin', 'rdc-skills-mcp.mjs');
42
- const TEST_PORT = parseInt(process.env.TEST_PORT || '3197', 10);
43
- const LOCAL = `http://127.0.0.1:${TEST_PORT}`;
44
- const REMOTE_URL = 'https://rdc-skills.regendevcorp.com/mcp';
45
- const UA = 'Mozilla/5.0 (rdc-skills-test)';
46
- const DAEMON = '127.0.0.1:52437';
47
-
48
- // ── tiny assert harness ──────────────────────────────────────────────────────
49
- let pass = 0;
50
- let fail = 0;
51
- const failures = [];
52
- function check(name, cond, detail = '') {
53
- if (cond) { pass++; }
54
- else { fail++; failures.push(`${name}${detail ? ` — ${detail}` : ''}`); }
55
- }
56
- function strip(text) {
57
- // The server prepends `<!-- rdc-skills: ... -->\n\n` to a rendered body.
58
- return text.replace(/^<!--[^\n]*-->\n\n/, '');
59
- }
60
-
61
- // ── MCP-over-HTTP helper (stateless; SSE or JSON response) ───────────────────
62
- async function mcp(url, body) {
63
- const res = await fetch(url, {
64
- method: 'POST',
65
- headers: {
66
- 'Content-Type': 'application/json',
67
- Accept: 'application/json, text/event-stream',
68
- 'User-Agent': UA,
69
- },
70
- body: JSON.stringify(body),
71
- });
72
- const raw = await res.text();
73
- if (raw.includes('data:')) {
74
- let out = null;
75
- for (const line of raw.split('\n')) {
76
- if (line.startsWith('data:')) out = JSON.parse(line.slice(5).trim());
77
- }
78
- return { status: res.status, json: out };
79
- }
80
- return { status: res.status, json: raw ? JSON.parse(raw) : null };
81
- }
82
- function callText(json) {
83
- return json?.result?.content?.[0]?.text ?? '';
84
- }
85
-
86
- // ── 1. UNIT ──────────────────────────────────────────────────────────────────
87
- function unitTests() {
88
- const cat = listSkills();
89
- check('unit: catalog non-empty', cat.length >= 20, `got ${cat.length}`);
90
- check('unit: every entry has name+slash+category+summary field',
91
- cat.every((s) => s.name && s.slash && s.category && 'summary' in s));
92
- check('unit: every entry exposes caller metadata fields',
93
- cat.every((s) => Array.isArray(s.aliases)
94
- && Array.isArray(s.produces)
95
- && Array.isArray(s.follows)
96
- && Array.isArray(s.leads_to)
97
- && Array.isArray(s.variants)
98
- && 'default_model' in s
99
- && 'sandbox_aware' in s
100
- && 'output_contract' in s
101
- && 'enabled_default' in s
102
- && 'codeflow_required' in s));
103
- check('unit: every entry exposes cli/cloud variants',
104
- cat.every((s) => JSON.stringify(s.variants) === JSON.stringify(['cli', 'cloud'])));
105
- check('unit: every rdc slash entry includes leading slash alias',
106
- cat.every((s) => !s.slash.startsWith('rdc:') || s.aliases.includes(`/${s.slash}`)));
107
- check('unit: every slash is explicit caller-facing shape',
108
- cat.every((s) => /^rdc:/.test(s.slash) || s.slash === s.name));
109
- check('unit: no synthesized duplicate rdc prefixes',
110
- cat.every((s) => !/^rdc:rdc-/.test(s.slash) && !/^rdc:lifeai-/.test(s.slash)));
111
- check('unit: getSkill(deploy) resolves', !!getSkill('deploy'));
112
- check('unit: getSkill(nonexistent) is null', getSkill('___nope___') === null);
113
- check('unit: search ranks exact name first', searchSkills('deploy')[0]?.name === 'deploy');
114
- check('unit: empty search returns []', searchSkills('').length === 0);
115
- check('unit: resolves bare skill name', resolveSkillName('build') === 'build');
116
- check('unit: resolves rdc slash skill name', resolveSkillName('rdc:build') === 'build');
117
- check('unit: resolves leading slash command form', resolveSkillName('/rdc:build') === 'build');
118
- check('unit: resolves specialist slash alias', resolveSkillName('rdc:brochurify') === 'rdc-brochurify');
119
- check('unit: resolves non-rdc specialist name', resolveSkillName('lifeai-brochure-author') === 'lifeai-brochure-author');
120
-
121
- // Searchability gate — the dimension the first "100% coverage" missed. Every
122
- // catalog entry MUST carry triggers + usage (frontmatter or skills_meta) so
123
- // rdc_skill_search can route to it. Caught by an audit: brochurify-suite skills
124
- // had frontmatter triggers the loader ignored → invisible to search.
125
- check('unit: every skill has non-empty triggers (searchable)',
126
- cat.every((s) => Array.isArray(s.when_to_use) && s.when_to_use.length > 0),
127
- cat.filter((s) => !s.when_to_use?.length).map((s) => s.name).join(',') || 'all good');
128
- check('unit: every skill has a non-empty usage string',
129
- cat.every((s) => s.usage && s.usage.length > 0),
130
- cat.filter((s) => !s.usage).map((s) => s.name).join(',') || 'all good');
131
-
132
- // cloud-rewrite contract
133
- const sample = '```bash\n_T=$(curl -s http://127.0.0.1:52437/v/coolify-api)\npm2 restart app\n```';
134
- const rw = toCloudBody(sample);
135
- check('unit: rewrite strips daemon URL', !rw.includes(DAEMON));
136
- check('unit: rewrite mentions clauth MCP', rw.includes('clauth MCP'));
137
- check('unit: rewrite flags CLI-only pm2', rw.includes('CLI-only'));
138
- check('unit: rewrite guards empty input', toCloudBody('') === '');
139
- }
140
-
141
- // ── 2 & 3. shared per-skill sweep over an MCP endpoint ───────────────────────
142
- async function sweep(url, label, { compareSource }) {
143
- // initialize
144
- const init = await mcp(url, {
145
- jsonrpc: '2.0', id: 1, method: 'initialize',
146
- params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'rdc-skills-test', version: '1' } },
147
- });
148
- check(`${label}: initialize 200`, init.status === 200, `status ${init.status}`);
149
- check(`${label}: serverInfo name`, init.json?.result?.serverInfo?.name === 'rdc-skills');
150
-
151
- // tools/list
152
- const tl = await mcp(url, { jsonrpc: '2.0', id: 2, method: 'tools/list' });
153
- const toolNames = (tl.json?.result?.tools || []).map((t) => t.name).sort();
154
- check(`${label}: exactly 3 expected tools`,
155
- JSON.stringify(toolNames) === JSON.stringify(['rdc_skill_get', 'rdc_skill_list', 'rdc_skill_search']),
156
- toolNames.join(','));
157
-
158
- // rdc_skill_list
159
- const sl = await mcp(url, { jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'rdc_skill_list', arguments: {} } });
160
- let listed;
161
- try { listed = JSON.parse(callText(sl.json)); } catch { listed = { count: 0, skills: [] }; }
162
- const names = (listed.skills || []).map((s) => s.name);
163
- check(`${label}: rdc_skill_list count matches`, listed.count === names.length && names.length >= 20, `count ${listed.count}`);
164
- if (compareSource) {
165
- check(`${label}: list names == local catalog`, JSON.stringify([...names].sort()) === JSON.stringify(skillNames().sort()));
166
- }
167
-
168
- // EVERY skill: fetch both variants, assert correctness + cloud contract.
169
- let rewrittenCount = 0;
170
- for (const name of names) {
171
- const cloud = await mcp(url, { jsonrpc: '2.0', id: 10, method: 'tools/call', params: { name: 'rdc_skill_get', arguments: { name, variant: 'cloud' } } });
172
- const cloudBody = strip(callText(cloud.json));
173
- const cli = await mcp(url, { jsonrpc: '2.0', id: 11, method: 'tools/call', params: { name: 'rdc_skill_get', arguments: { name, variant: 'cli' } } });
174
- const cliBody = strip(callText(cli.json));
175
-
176
- check(`${label}: [${name}] cloud body non-empty`, cloudBody.length > 0);
177
- check(`${label}: [${name}] cli body non-empty`, cliBody.length > 0);
178
- // CLOUD CONTRACT — no daemon URL, for EVERY skill.
179
- check(`${label}: [${name}] cloud has NO daemon URL`, !cloudBody.includes(DAEMON),
180
- cloudBody.includes(DAEMON) ? 'leaked 127.0.0.1:52437' : '');
181
-
182
- if (compareSource) {
183
- // Correctness: the rendered bodies must equal the library's own output.
184
- const expectedCli = getSkillBody(name);
185
- const expectedCloud = getCloudOverride(name) ?? toCloudBody(expectedCli);
186
- check(`${label}: [${name}] cli body == on-disk source`, cliBody === expectedCli);
187
- check(`${label}: [${name}] cloud body == toCloudBody(source)/override`, cloudBody === expectedCloud);
188
- if (expectedCli && expectedCli.includes(DAEMON)) rewrittenCount++;
189
- }
190
- }
191
- if (compareSource) {
192
- check(`${label}: at least one skill actually had a daemon URL rewritten`, rewrittenCount > 0, `rewritten ${rewrittenCount}`);
193
- }
194
-
195
- // error path
196
- const unk = await mcp(url, { jsonrpc: '2.0', id: 20, method: 'tools/call', params: { name: 'rdc_skill_get', arguments: { name: '___nope___' } } });
197
- check(`${label}: unknown skill returns helpful error`, /unknown skill/i.test(callText(unk.json)));
198
- const unkJson = await mcp(url, { jsonrpc: '2.0', id: 24, method: 'tools/call', params: { name: 'rdc_skill_get', arguments: { name: '___nope___', format: 'json' } } });
199
- let unkJsonBody; try { unkJsonBody = JSON.parse(callText(unkJson.json)); } catch { unkJsonBody = {}; }
200
- check(`${label}: unknown skill format=json returns machine-readable error`, unkJsonBody.error === 'unknown_skill');
201
- check(`${label}: unknown skill format=json includes valid alternatives`, unkJsonBody.valid_count === names.length && Array.isArray(unkJsonBody.valid) && unkJsonBody.valid.some((s) => s.slash === 'rdc:help'));
202
-
203
- // Alias path: callers naturally copy the slash form from rdc_skill_list.
204
- const aliasGet = await mcp(url, { jsonrpc: '2.0', id: 22, method: 'tools/call', params: { name: 'rdc_skill_get', arguments: { name: 'rdc:brochurify', variant: 'cli' } } });
205
- const aliasBody = strip(callText(aliasGet.json));
206
- check(`${label}: rdc_skill_get accepts slash aliases`, /rdc:brochurify Orchestrator/.test(aliasBody));
207
-
208
- const jsonGet = await mcp(url, { jsonrpc: '2.0', id: 23, method: 'tools/call', params: { name: 'rdc_skill_get', arguments: { name: 'rdc:build', variant: 'cli', format: 'json' } } });
209
- let jsonBody; try { jsonBody = JSON.parse(callText(jsonGet.json)); } catch { jsonBody = {}; }
210
- check(`${label}: rdc_skill_get format=json returns resolved name`, jsonBody.resolved_name === 'build');
211
- check(`${label}: rdc_skill_get format=json returns metadata`, jsonBody.skill?.slash === 'rdc:build' && jsonBody.skill?.codeflow_required === true);
212
- check(`${label}: rdc_skill_get format=json returns rendered body`, typeof jsonBody.body === 'string' && jsonBody.body.includes('rdc:build'));
213
-
214
- // search
215
- const se = await mcp(url, { jsonrpc: '2.0', id: 21, method: 'tools/call', params: { name: 'rdc_skill_search', arguments: { query: 'deploy' } } });
216
- let sr; try { sr = JSON.parse(callText(se.json)); } catch { sr = { results: [] }; }
217
- check(`${label}: search returns ranked results`, (sr.results || []).length > 0 && sr.results[0].name);
218
- check(`${label}: search result includes caller metadata`,
219
- Boolean(sr.results?.[0]?.usage && sr.results?.[0]?.category && Array.isArray(sr.results?.[0]?.variants)));
220
-
221
- return names.length;
222
- }
223
-
224
- // ── server lifecycle for integration ─────────────────────────────────────────
225
- async function waitHealth(url, tries = 40) {
226
- for (let i = 0; i < tries; i++) {
227
- try {
228
- const r = await fetch(`${url}/health`);
229
- if (r.status === 200) return await r.json();
230
- } catch { /* not up yet */ }
231
- await new Promise((r) => setTimeout(r, 150));
232
- }
233
- throw new Error('server did not become healthy');
234
- }
235
-
236
- async function main() {
237
- console.log('── unit ──');
238
- unitTests();
239
-
240
- console.log('── integration (spawned server) ──');
241
- const proc = spawn('node', [BIN], { env: { ...process.env, PORT: String(TEST_PORT) }, stdio: 'ignore' });
242
- let swept = 0;
243
- try {
244
- const health = await waitHealth(LOCAL);
245
- check('integration: /health status ok', health.status === 'ok');
246
- check('integration: /health skill count == catalog', health.skills === listSkills().length, `health ${health.skills} vs ${listSkills().length}`);
247
- swept = await sweep(`${LOCAL}/mcp`, 'integration', { compareSource: true });
248
- console.log(` swept ${swept} skills (both variants)`);
249
- } finally {
250
- proc.kill();
251
- }
252
-
253
- if (process.env.REMOTE || process.argv.includes('--remote')) {
254
- console.log('── systems (live tunnel) ──');
255
- const n = await sweep(REMOTE_URL, 'systems', { compareSource: false });
256
- console.log(` swept ${n} skills over ${REMOTE_URL}`);
257
- } else {
258
- console.log('── systems skipped (set REMOTE=1 to sweep the live endpoint) ──');
259
- }
260
-
261
- console.log(`\nRESULTS: ${pass} passed, ${fail} failed`);
262
- if (fail) {
263
- console.log('FAILURES:');
264
- for (const f of failures.slice(0, 40)) console.log(` ✗ ${f}`);
265
- process.exit(1);
266
- }
267
- console.log('✓ all green');
268
- process.exit(0);
269
- }
270
-
271
- main().catch((e) => { console.error('test harness error:', e); process.exit(1); });
1
+ #!/usr/bin/env node
2
+ /**
3
+ * tests/mcp.test.mjs — layered test for the rdc-skills MCP server.
4
+ *
5
+ * node tests/mcp.test.mjs # unit + integration (spawns the server)
6
+ * REMOTE=1 node tests/mcp.test.mjs # ALSO sweep the live tunnel endpoint
7
+ *
8
+ * Coverage is 100% of skills: every skill in the catalog is fetched through the
9
+ * MCP in BOTH variants and asserted against its on-disk source, and the cloud
10
+ * contract (no `127.0.0.1:52437` daemon URL) is checked for EVERY skill.
11
+ *
12
+ * Layers:
13
+ * 1. unit — catalog + cloud-rewrite pure functions.
14
+ * 2. integration — spawn `bin/rdc-skills-mcp.mjs` on a test port; exercise
15
+ * /health and the full MCP (initialize, tools/list, every
16
+ * tool, every skill, error paths) over HTTP, comparing each
17
+ * rendered body byte-for-byte to the library's own output.
18
+ * 3. systems — (REMOTE=1) the same cloud-contract sweep against
19
+ * https://rdc-skills.regendevcorp.com/mcp through Cloudflare.
20
+ *
21
+ * Dependency-free: node builtins + the project's own lib + global fetch.
22
+ */
23
+
24
+ import { spawn } from 'node:child_process';
25
+ import path from 'node:path';
26
+ import { fileURLToPath } from 'node:url';
27
+
28
+ import {
29
+ listSkills,
30
+ getSkill,
31
+ skillNames,
32
+ getSkillBody,
33
+ getCloudOverride,
34
+ searchSkills,
35
+ resolveSkillName,
36
+ } from '../lib/catalog.mjs';
37
+ import { toCloudBody } from '../lib/cloud-rewrite.mjs';
38
+
39
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
40
+ const REPO_ROOT = path.resolve(__dirname, '..');
41
+ const BIN = path.join(REPO_ROOT, 'bin', 'rdc-skills-mcp.mjs');
42
+ const TEST_PORT = parseInt(process.env.TEST_PORT || '3197', 10);
43
+ const LOCAL = `http://127.0.0.1:${TEST_PORT}`;
44
+ const REMOTE_URL = 'https://rdc-skills.regendevcorp.com/mcp';
45
+ const UA = 'Mozilla/5.0 (rdc-skills-test)';
46
+ const DAEMON = '127.0.0.1:52437';
47
+
48
+ // ── tiny assert harness ──────────────────────────────────────────────────────
49
+ let pass = 0;
50
+ let fail = 0;
51
+ const failures = [];
52
+ function check(name, cond, detail = '') {
53
+ if (cond) { pass++; }
54
+ else { fail++; failures.push(`${name}${detail ? ` — ${detail}` : ''}`); }
55
+ }
56
+ function strip(text) {
57
+ // The server prepends `<!-- rdc-skills: ... -->\n\n` to a rendered body.
58
+ return text.replace(/^<!--[^\n]*-->\n\n/, '');
59
+ }
60
+
61
+ // ── MCP-over-HTTP helper (stateless; SSE or JSON response) ───────────────────
62
+ async function mcp(url, body) {
63
+ const res = await fetch(url, {
64
+ method: 'POST',
65
+ headers: {
66
+ 'Content-Type': 'application/json',
67
+ Accept: 'application/json, text/event-stream',
68
+ 'User-Agent': UA,
69
+ },
70
+ body: JSON.stringify(body),
71
+ });
72
+ const raw = await res.text();
73
+ if (raw.includes('data:')) {
74
+ let out = null;
75
+ for (const line of raw.split('\n')) {
76
+ if (line.startsWith('data:')) out = JSON.parse(line.slice(5).trim());
77
+ }
78
+ return { status: res.status, json: out };
79
+ }
80
+ return { status: res.status, json: raw ? JSON.parse(raw) : null };
81
+ }
82
+ function callText(json) {
83
+ return json?.result?.content?.[0]?.text ?? '';
84
+ }
85
+
86
+ // ── 1. UNIT ──────────────────────────────────────────────────────────────────
87
+ function unitTests() {
88
+ const cat = listSkills();
89
+ check('unit: catalog non-empty', cat.length >= 20, `got ${cat.length}`);
90
+ check('unit: every entry has name+slash+category+summary field',
91
+ cat.every((s) => s.name && s.slash && s.category && 'summary' in s));
92
+ check('unit: every entry exposes caller metadata fields',
93
+ cat.every((s) => Array.isArray(s.aliases)
94
+ && Array.isArray(s.produces)
95
+ && Array.isArray(s.follows)
96
+ && Array.isArray(s.leads_to)
97
+ && Array.isArray(s.variants)
98
+ && 'default_model' in s
99
+ && 'sandbox_aware' in s
100
+ && 'output_contract' in s
101
+ && 'enabled_default' in s
102
+ && 'codeflow_required' in s));
103
+ check('unit: every entry exposes cli/cloud variants',
104
+ cat.every((s) => JSON.stringify(s.variants) === JSON.stringify(['cli', 'cloud'])));
105
+ check('unit: every rdc slash entry includes leading slash alias',
106
+ cat.every((s) => !s.slash.startsWith('rdc:') || s.aliases.includes(`/${s.slash}`)));
107
+ check('unit: every slash is explicit caller-facing shape',
108
+ cat.every((s) => /^rdc:/.test(s.slash) || s.slash === s.name));
109
+ check('unit: no synthesized duplicate rdc prefixes',
110
+ cat.every((s) => !/^rdc:rdc-/.test(s.slash) && !/^rdc:lifeai-/.test(s.slash)));
111
+ check('unit: getSkill(deploy) resolves', !!getSkill('deploy'));
112
+ check('unit: getSkill(nonexistent) is null', getSkill('___nope___') === null);
113
+ check('unit: search ranks exact name first', searchSkills('deploy')[0]?.name === 'deploy');
114
+ check('unit: empty search returns []', searchSkills('').length === 0);
115
+ check('unit: resolves bare skill name', resolveSkillName('build') === 'build');
116
+ check('unit: resolves rdc slash skill name', resolveSkillName('rdc:build') === 'build');
117
+ check('unit: resolves leading slash command form', resolveSkillName('/rdc:build') === 'build');
118
+ check('unit: resolves specialist slash alias', resolveSkillName('rdc:brochurify') === 'rdc-brochurify');
119
+ check('unit: resolves non-rdc specialist name', resolveSkillName('lifeai-brochure-author') === 'lifeai-brochure-author');
120
+
121
+ // Searchability gate — the dimension the first "100% coverage" missed. Every
122
+ // catalog entry MUST carry triggers + usage (frontmatter or skills_meta) so
123
+ // rdc_skill_search can route to it. Caught by an audit: brochurify-suite skills
124
+ // had frontmatter triggers the loader ignored → invisible to search.
125
+ check('unit: every skill has non-empty triggers (searchable)',
126
+ cat.every((s) => Array.isArray(s.when_to_use) && s.when_to_use.length > 0),
127
+ cat.filter((s) => !s.when_to_use?.length).map((s) => s.name).join(',') || 'all good');
128
+ check('unit: every skill has a non-empty usage string',
129
+ cat.every((s) => s.usage && s.usage.length > 0),
130
+ cat.filter((s) => !s.usage).map((s) => s.name).join(',') || 'all good');
131
+
132
+ // cloud-rewrite contract
133
+ const sample = '```bash\n_T=$(curl -s http://127.0.0.1:52437/v/coolify-api)\npm2 restart app\n```';
134
+ const rw = toCloudBody(sample);
135
+ check('unit: rewrite strips daemon URL', !rw.includes(DAEMON));
136
+ check('unit: rewrite mentions clauth MCP', rw.includes('clauth MCP'));
137
+ check('unit: rewrite flags CLI-only pm2', rw.includes('CLI-only'));
138
+ check('unit: rewrite guards empty input', toCloudBody('') === '');
139
+ }
140
+
141
+ // ── 2 & 3. shared per-skill sweep over an MCP endpoint ───────────────────────
142
+ async function sweep(url, label, { compareSource }) {
143
+ // initialize
144
+ const init = await mcp(url, {
145
+ jsonrpc: '2.0', id: 1, method: 'initialize',
146
+ params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'rdc-skills-test', version: '1' } },
147
+ });
148
+ check(`${label}: initialize 200`, init.status === 200, `status ${init.status}`);
149
+ check(`${label}: serverInfo name`, init.json?.result?.serverInfo?.name === 'rdc-skills');
150
+
151
+ // tools/list
152
+ const tl = await mcp(url, { jsonrpc: '2.0', id: 2, method: 'tools/list' });
153
+ const toolNames = (tl.json?.result?.tools || []).map((t) => t.name).sort();
154
+ check(`${label}: exactly 3 expected tools`,
155
+ JSON.stringify(toolNames) === JSON.stringify(['rdc_skill_get', 'rdc_skill_list', 'rdc_skill_search']),
156
+ toolNames.join(','));
157
+
158
+ // rdc_skill_list
159
+ const sl = await mcp(url, { jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'rdc_skill_list', arguments: {} } });
160
+ let listed;
161
+ try { listed = JSON.parse(callText(sl.json)); } catch { listed = { count: 0, skills: [] }; }
162
+ const names = (listed.skills || []).map((s) => s.name);
163
+ check(`${label}: rdc_skill_list count matches`, listed.count === names.length && names.length >= 20, `count ${listed.count}`);
164
+ if (compareSource) {
165
+ check(`${label}: list names == local catalog`, JSON.stringify([...names].sort()) === JSON.stringify(skillNames().sort()));
166
+ }
167
+
168
+ // EVERY skill: fetch both variants, assert correctness + cloud contract.
169
+ let rewrittenCount = 0;
170
+ for (const name of names) {
171
+ const cloud = await mcp(url, { jsonrpc: '2.0', id: 10, method: 'tools/call', params: { name: 'rdc_skill_get', arguments: { name, variant: 'cloud' } } });
172
+ const cloudBody = strip(callText(cloud.json));
173
+ const cli = await mcp(url, { jsonrpc: '2.0', id: 11, method: 'tools/call', params: { name: 'rdc_skill_get', arguments: { name, variant: 'cli' } } });
174
+ const cliBody = strip(callText(cli.json));
175
+
176
+ check(`${label}: [${name}] cloud body non-empty`, cloudBody.length > 0);
177
+ check(`${label}: [${name}] cli body non-empty`, cliBody.length > 0);
178
+ // CLOUD CONTRACT — no daemon URL, for EVERY skill.
179
+ check(`${label}: [${name}] cloud has NO daemon URL`, !cloudBody.includes(DAEMON),
180
+ cloudBody.includes(DAEMON) ? 'leaked 127.0.0.1:52437' : '');
181
+
182
+ if (compareSource) {
183
+ // Correctness: the rendered bodies must equal the library's own output.
184
+ const expectedCli = getSkillBody(name);
185
+ const expectedCloud = getCloudOverride(name) ?? toCloudBody(expectedCli);
186
+ check(`${label}: [${name}] cli body == on-disk source`, cliBody === expectedCli);
187
+ check(`${label}: [${name}] cloud body == toCloudBody(source)/override`, cloudBody === expectedCloud);
188
+ if (expectedCli && expectedCli.includes(DAEMON)) rewrittenCount++;
189
+ }
190
+ }
191
+ if (compareSource) {
192
+ check(`${label}: at least one skill actually had a daemon URL rewritten`, rewrittenCount > 0, `rewritten ${rewrittenCount}`);
193
+ }
194
+
195
+ // error path
196
+ const unk = await mcp(url, { jsonrpc: '2.0', id: 20, method: 'tools/call', params: { name: 'rdc_skill_get', arguments: { name: '___nope___' } } });
197
+ check(`${label}: unknown skill returns helpful error`, /unknown skill/i.test(callText(unk.json)));
198
+ const unkJson = await mcp(url, { jsonrpc: '2.0', id: 24, method: 'tools/call', params: { name: 'rdc_skill_get', arguments: { name: '___nope___', format: 'json' } } });
199
+ let unkJsonBody; try { unkJsonBody = JSON.parse(callText(unkJson.json)); } catch { unkJsonBody = {}; }
200
+ check(`${label}: unknown skill format=json returns machine-readable error`, unkJsonBody.error === 'unknown_skill');
201
+ check(`${label}: unknown skill format=json includes valid alternatives`, unkJsonBody.valid_count === names.length && Array.isArray(unkJsonBody.valid) && unkJsonBody.valid.some((s) => s.slash === 'rdc:help'));
202
+
203
+ // Alias path: callers naturally copy the slash form from rdc_skill_list.
204
+ const aliasGet = await mcp(url, { jsonrpc: '2.0', id: 22, method: 'tools/call', params: { name: 'rdc_skill_get', arguments: { name: 'rdc:brochurify', variant: 'cli' } } });
205
+ const aliasBody = strip(callText(aliasGet.json));
206
+ check(`${label}: rdc_skill_get accepts slash aliases`, /rdc:brochurify Orchestrator/.test(aliasBody));
207
+
208
+ const jsonGet = await mcp(url, { jsonrpc: '2.0', id: 23, method: 'tools/call', params: { name: 'rdc_skill_get', arguments: { name: 'rdc:build', variant: 'cli', format: 'json' } } });
209
+ let jsonBody; try { jsonBody = JSON.parse(callText(jsonGet.json)); } catch { jsonBody = {}; }
210
+ check(`${label}: rdc_skill_get format=json returns resolved name`, jsonBody.resolved_name === 'build');
211
+ check(`${label}: rdc_skill_get format=json returns metadata`, jsonBody.skill?.slash === 'rdc:build' && jsonBody.skill?.codeflow_required === true);
212
+ check(`${label}: rdc_skill_get format=json returns rendered body`, typeof jsonBody.body === 'string' && jsonBody.body.includes('rdc:build'));
213
+
214
+ // search
215
+ const se = await mcp(url, { jsonrpc: '2.0', id: 21, method: 'tools/call', params: { name: 'rdc_skill_search', arguments: { query: 'deploy' } } });
216
+ let sr; try { sr = JSON.parse(callText(se.json)); } catch { sr = { results: [] }; }
217
+ check(`${label}: search returns ranked results`, (sr.results || []).length > 0 && sr.results[0].name);
218
+ check(`${label}: search result includes caller metadata`,
219
+ Boolean(sr.results?.[0]?.usage && sr.results?.[0]?.category && Array.isArray(sr.results?.[0]?.variants)));
220
+
221
+ return names.length;
222
+ }
223
+
224
+ // ── server lifecycle for integration ─────────────────────────────────────────
225
+ async function waitHealth(url, tries = 40) {
226
+ for (let i = 0; i < tries; i++) {
227
+ try {
228
+ const r = await fetch(`${url}/health`);
229
+ if (r.status === 200) return await r.json();
230
+ } catch { /* not up yet */ }
231
+ await new Promise((r) => setTimeout(r, 150));
232
+ }
233
+ throw new Error('server did not become healthy');
234
+ }
235
+
236
+ async function main() {
237
+ console.log('── unit ──');
238
+ unitTests();
239
+
240
+ console.log('── integration (spawned server) ──');
241
+ const proc = spawn('node', [BIN], { env: { ...process.env, PORT: String(TEST_PORT) }, stdio: 'ignore' });
242
+ let swept = 0;
243
+ try {
244
+ const health = await waitHealth(LOCAL);
245
+ check('integration: /health status ok', health.status === 'ok');
246
+ check('integration: /health skill count == catalog', health.skills === listSkills().length, `health ${health.skills} vs ${listSkills().length}`);
247
+ swept = await sweep(`${LOCAL}/mcp`, 'integration', { compareSource: true });
248
+ console.log(` swept ${swept} skills (both variants)`);
249
+ } finally {
250
+ proc.kill();
251
+ }
252
+
253
+ if (process.env.REMOTE || process.argv.includes('--remote')) {
254
+ console.log('── systems (live tunnel) ──');
255
+ const n = await sweep(REMOTE_URL, 'systems', { compareSource: false });
256
+ console.log(` swept ${n} skills over ${REMOTE_URL}`);
257
+ } else {
258
+ console.log('── systems skipped (set REMOTE=1 to sweep the live endpoint) ──');
259
+ }
260
+
261
+ console.log(`\nRESULTS: ${pass} passed, ${fail} failed`);
262
+ if (fail) {
263
+ console.log('FAILURES:');
264
+ for (const f of failures.slice(0, 40)) console.log(` ✗ ${f}`);
265
+ process.exit(1);
266
+ }
267
+ console.log('✓ all green');
268
+ process.exit(0);
269
+ }
270
+
271
+ main().catch((e) => { console.error('test harness error:', e); process.exit(1); });