@lifeaitools/rdc-skills 0.24.37 → 0.24.39

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 (191) 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 +377 -377
  10. package/README.sandbox.md +3 -3
  11. package/assets/watcher/viewer.html +164 -164
  12. package/bin/rdc-skills-mcp.mjs +316 -316
  13. package/commands/build.md +183 -183
  14. package/commands/collab.md +180 -180
  15. package/commands/deploy.md +152 -152
  16. package/commands/design.md +31 -31
  17. package/commands/edit.md +28 -28
  18. package/commands/fixit.md +124 -124
  19. package/commands/handoff.md +173 -173
  20. package/commands/help.md +95 -95
  21. package/commands/overnight.md +220 -220
  22. package/commands/plan.md +158 -158
  23. package/commands/preplan.md +131 -131
  24. package/commands/prototype.md +145 -145
  25. package/commands/release.md +49 -49
  26. package/commands/report.md +99 -99
  27. package/commands/review.md +120 -120
  28. package/commands/self-test.md +113 -113
  29. package/commands/status.md +86 -86
  30. package/commands/watch.md +98 -98
  31. package/commands/workitems.md +137 -137
  32. package/git-sha.json +1 -1
  33. package/guides/agent-bootstrap.md +295 -295
  34. package/guides/agents/backend.md +104 -104
  35. package/guides/agents/content.md +94 -94
  36. package/guides/agents/cs2.md +56 -56
  37. package/guides/agents/data.md +87 -87
  38. package/guides/agents/design.md +77 -77
  39. package/guides/agents/frontend.md +92 -92
  40. package/guides/agents/infrastructure.md +81 -81
  41. package/guides/agents/setup.md +281 -281
  42. package/guides/agents/verify.md +151 -151
  43. package/guides/agents/viz.md +106 -106
  44. package/guides/backend.md +146 -146
  45. package/guides/content.md +147 -147
  46. package/guides/cs2.md +190 -190
  47. package/guides/data.md +123 -123
  48. package/guides/design.md +116 -116
  49. package/guides/engineering-behavior.md +43 -43
  50. package/guides/escalation-protocol.md +125 -125
  51. package/guides/frontend.md +151 -151
  52. package/guides/history-md-spec.md +297 -297
  53. package/guides/infrastructure.md +179 -179
  54. package/guides/lessons-learned-spec.md +153 -153
  55. package/guides/output-contract.md +108 -108
  56. package/guides/publish-md-spec.md +289 -289
  57. package/guides/rdc-skills-startup.md +30 -30
  58. package/guides/verify.md +11 -11
  59. package/hooks/check-cwd.js +31 -31
  60. package/hooks/check-rdc-environment.js +164 -164
  61. package/hooks/check-services.js +6 -6
  62. package/hooks/check-stale-work-items.js +19 -19
  63. package/hooks/foreground-process-gate.js +128 -128
  64. package/hooks/gate-watchdog-selfcheck.js +257 -257
  65. package/hooks/hook-logger.js +25 -25
  66. package/hooks/lib/run-evidence-gate.mjs +241 -241
  67. package/hooks/no-stop-open-epics.js +127 -127
  68. package/hooks/post-tool-batch-gate.js +203 -203
  69. package/hooks/post-work-check.js +21 -21
  70. package/hooks/postcompact-log.js +13 -13
  71. package/hooks/precompact-log.js +13 -13
  72. package/hooks/rate-limit-retry.js +46 -46
  73. package/hooks/rdc-invocation-marker.js +157 -157
  74. package/hooks/rdc-output-contract-gate.js +94 -94
  75. package/hooks/require-work-item-on-commit.js +294 -294
  76. package/hooks/restart-brief.js +19 -19
  77. package/hooks/run-hidden-hook.ps1 +47 -47
  78. package/hooks/task-completed-gate.js +274 -274
  79. package/hooks/work-item-exit-gate.js +944 -944
  80. package/lib/catalog.mjs +236 -236
  81. package/lib/cloud-rewrite.mjs +155 -155
  82. package/package.json +56 -56
  83. package/rules/work-items-rpc.md +520 -520
  84. package/scaffold/templates/HISTORY.md.template +39 -39
  85. package/scaffold/templates/PUBLISH.md.template +21 -21
  86. package/scaffold/templates/brochure-studio-default.html +70 -70
  87. package/scripts/acceptance.mjs +502 -502
  88. package/scripts/fixtures/guides/bad-guide.md +15 -15
  89. package/scripts/fixtures/guides-clean/good-guide.md +16 -16
  90. package/scripts/install-rdc-skills.js +1289 -1289
  91. package/scripts/install.ps1 +202 -202
  92. package/scripts/install.sh +132 -132
  93. package/scripts/lib/assertions.mjs +287 -287
  94. package/scripts/lib/manifest-schema.mjs +754 -754
  95. package/scripts/lib/runner.mjs +465 -465
  96. package/scripts/lib/sandbox.mjs +435 -435
  97. package/scripts/prepack.mjs +32 -32
  98. package/scripts/rdc-brochure.mjs +464 -464
  99. package/scripts/rdc-design-cli.mjs +134 -134
  100. package/scripts/rebuild-mcp.mjs +107 -107
  101. package/scripts/self-test.mjs +1460 -1460
  102. package/scripts/stamp-git-sha.mjs +29 -29
  103. package/scripts/test-guide-validator.mjs +196 -196
  104. package/scripts/test-rdc-hooks.mjs +145 -145
  105. package/scripts/uninstall.ps1 +77 -77
  106. package/scripts/uninstall.sh +69 -69
  107. package/scripts/update.ps1 +43 -43
  108. package/scripts/update.sh +43 -43
  109. package/scripts/validate-place-histories.js +461 -461
  110. package/scripts/validate-publish-manifests.js +424 -424
  111. package/scripts/watch-init.mjs +100 -100
  112. package/skills/brochure/SKILL.md +107 -107
  113. package/skills/build/SKILL.md +563 -554
  114. package/skills/channel-formatter/SKILL.md +533 -533
  115. package/skills/co-develop/SKILL.md +196 -196
  116. package/skills/collab/SKILL.md +239 -239
  117. package/skills/convert/SKILL.md +140 -140
  118. package/skills/deploy/SKILL.md +541 -541
  119. package/skills/design/SKILL.md +211 -211
  120. package/skills/design/reference/ownership.md +16 -16
  121. package/skills/design/reference/rampa.md +92 -92
  122. package/skills/design/reference/studio-model.md +153 -153
  123. package/skills/edit/SKILL.md +98 -98
  124. package/skills/fixit/SKILL.md +165 -165
  125. package/skills/fs-mcp/SKILL.md +148 -148
  126. package/skills/handoff/SKILL.md +236 -200
  127. package/skills/help/SKILL.md +143 -143
  128. package/skills/housekeeping/SKILL.md +189 -189
  129. package/skills/lifeai-brochure-author/SKILL.md +340 -340
  130. package/skills/overnight/SKILL.md +251 -251
  131. package/skills/plan/SKILL.md +345 -314
  132. package/skills/preplan/SKILL.md +90 -90
  133. package/skills/prototype/SKILL.md +150 -150
  134. package/skills/rdc-brochurify/SKILL.md +245 -245
  135. package/skills/rdc-extract-verifier-rules/SKILL.md +191 -191
  136. package/skills/release/SKILL.md +140 -140
  137. package/skills/report/SKILL.md +100 -100
  138. package/skills/review/SKILL.md +152 -152
  139. package/skills/rpms-filemap/SKILL.cloud.md +111 -111
  140. package/skills/rpms-filemap/SKILL.md +111 -111
  141. package/skills/self-test/SKILL.md +132 -132
  142. package/skills/status/SKILL.md +99 -99
  143. package/skills/terminal-config/SKILL.md +62 -62
  144. package/skills/tests/MATRIX.md +54 -54
  145. package/skills/tests/README.md +47 -47
  146. package/skills/tests/rdc-brochure.test.json +34 -34
  147. package/skills/tests/rdc-build.test.json +36 -36
  148. package/skills/tests/rdc-channel-formatter.test.json +45 -45
  149. package/skills/tests/rdc-co-develop.test.json +29 -29
  150. package/skills/tests/rdc-collab.test.json +29 -29
  151. package/skills/tests/rdc-convert.test.json +35 -35
  152. package/skills/tests/rdc-deploy.test.json +30 -30
  153. package/skills/tests/rdc-design.test.json +27 -27
  154. package/skills/tests/rdc-edit.test.json +29 -29
  155. package/skills/tests/rdc-fixit.test.json +36 -36
  156. package/skills/tests/rdc-fs-mcp.test.json +36 -36
  157. package/skills/tests/rdc-handoff.test.json +28 -28
  158. package/skills/tests/rdc-help.test.json +29 -29
  159. package/skills/tests/rdc-housekeeping.test.json +31 -31
  160. package/skills/tests/rdc-lifeai-brochure-author.test.json +35 -35
  161. package/skills/tests/rdc-overnight.test.json +37 -37
  162. package/skills/tests/rdc-plan.test.json +27 -27
  163. package/skills/tests/rdc-preplan.test.json +31 -31
  164. package/skills/tests/rdc-prototype.test.json +28 -28
  165. package/skills/tests/rdc-rdc-brochurify.test.json +23 -23
  166. package/skills/tests/rdc-rdc-extract-verifier-rules.test.json +34 -34
  167. package/skills/tests/rdc-release.test.json +29 -29
  168. package/skills/tests/rdc-report.test.json +28 -28
  169. package/skills/tests/rdc-review.test.json +29 -29
  170. package/skills/tests/rdc-rpms-filemap.test.json +28 -28
  171. package/skills/tests/rdc-self-test.test.json +24 -24
  172. package/skills/tests/rdc-status.test.json +29 -29
  173. package/skills/tests/rdc-terminal-config.test.json +29 -29
  174. package/skills/tests/rdc-watch.test.json +24 -24
  175. package/skills/tests/rdc-workitems.test.json +27 -27
  176. package/skills/watch/SKILL.md +97 -97
  177. package/skills/workitems/SKILL.md +151 -151
  178. package/tests/acceptance.test.mjs +59 -59
  179. package/tests/channel-formatter.contract.test.mjs +251 -251
  180. package/tests/curl-surface.test.mjs +289 -289
  181. package/tests/harness-gates.test.mjs +325 -325
  182. package/tests/help-surface.test.mjs +61 -61
  183. package/tests/install-rdc-skills.test.mjs +49 -49
  184. package/tests/manifest-contract-fields.test.mjs +78 -78
  185. package/tests/mcp.test.mjs +271 -271
  186. package/tests/require-work-item-on-commit.test.mjs +162 -162
  187. package/tests/run-evidence-gate.test.mjs +82 -82
  188. package/tests/skill-test-matrix.test.mjs +66 -66
  189. package/tests/validate-skills.js +27 -27
  190. package/tests/work-item-exit-gate-l2.test.mjs +368 -368
  191. 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); });