@lifeaitools/rdc-skills 0.16.0 → 0.17.1

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rdc",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "RDC typed-agent dispatch skill suite for Claude Code — plan, build, review, overnight unattended builds with work-item tracking and TDD enforcement.",
5
5
  "author": {
6
6
  "name": "LIFEAI",
@@ -0,0 +1,238 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * bin/rdc-skills-mcp.mjs — local MCP server exposing the rdc-skills library.
4
+ *
5
+ * Mirrors the codeflow MCP topology (packages/codeflow/src/mcp/server.ts):
6
+ * express + @modelcontextprotocol/sdk McpServer + StreamableHTTPServerTransport,
7
+ * stateless (a fresh server+transport per POST /mcp), PORT from env.
8
+ *
9
+ * Routes:
10
+ * POST /mcp — MCP over StreamableHTTP. NO Authorization (URL is the shared
11
+ * secret, consistent with codeflow/clauth tunnel MCPs).
12
+ * GET /health — { status, service, version, skills } — no auth, no heavy work.
13
+ * /.well-known/* + /authorize + /token → 404 (force connectors to skip OAuth).
14
+ *
15
+ * It is tunneled in production at https://rdc-skills.regendevcorp.com/mcp; this
16
+ * process only listens on PORT (default 3110) and does not configure the tunnel.
17
+ *
18
+ * ── Caller detection → variant (best-effort) ────────────────────────────────
19
+ * Tools render a `cli` or `cloud` body. Detection rule: on MCP `initialize`,
20
+ * `clientInfo.name` containing `claude-code` or `codex` → `cli`; anything else
21
+ * (claude.ai web) → `cloud`; unknown → `cloud` (safer for web).
22
+ *
23
+ * APPROACH TAKEN — default + explicit override (NOT session-threaded):
24
+ * The transport is stateless (a new McpServer per POST, sessionIdGenerator:
25
+ * undefined), so there is no durable session to thread clientInfo through to a
26
+ * later tools/call. Rather than build a fragile session map, we:
27
+ * - capture clientInfo on `initialize` and remember the MOST RECENT one
28
+ * process-wide as a soft default (helps the common single-client case), and
29
+ * - ALWAYS honor an explicit `variant` arg on rdc_skill_get (the must-have).
30
+ * If no explicit variant is given and no client has initialized this process,
31
+ * the default is `cloud`. This keeps the override correct and the auto-detect
32
+ * best-effort, exactly as the spec permits.
33
+ */
34
+
35
+ import express from 'express';
36
+ import { z } from 'zod';
37
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
38
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
39
+ import fs from 'node:fs';
40
+ import path from 'node:path';
41
+ import { fileURLToPath } from 'node:url';
42
+
43
+ import {
44
+ listSkills,
45
+ getSkill,
46
+ getSkillBody,
47
+ getCloudOverride,
48
+ skillNames,
49
+ searchSkills,
50
+ } from '../lib/catalog.mjs';
51
+ import { toCloudBody } from '../lib/cloud-rewrite.mjs';
52
+
53
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
54
+ const REPO_ROOT = path.resolve(__dirname, '..');
55
+ const PORT = parseInt(process.env.PORT || '3110', 10);
56
+
57
+ function pkgVersion() {
58
+ try {
59
+ return JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'package.json'), 'utf8')).version || '0.0.0';
60
+ } catch {
61
+ return '0.0.0';
62
+ }
63
+ }
64
+
65
+ // Soft process-wide default variant, updated whenever a client initializes.
66
+ let lastDetectedVariant = 'cloud';
67
+
68
+ /** Map a clientInfo.name → variant. */
69
+ function variantForClient(clientName) {
70
+ const n = String(clientName || '').toLowerCase();
71
+ if (n.includes('claude-code') || n.includes('codex')) return 'cli';
72
+ return 'cloud'; // claude.ai web client and anything unknown
73
+ }
74
+
75
+ /**
76
+ * Render a skill body for the resolved variant.
77
+ * - cli → SKILL.md body unchanged.
78
+ * - cloud → SKILL.cloud.md verbatim if present, else toCloudBody(SKILL.md).
79
+ * Returns { header, body } or null if the skill has no body on disk.
80
+ */
81
+ function renderSkill(name, variant) {
82
+ const body = getSkillBody(name);
83
+ if (body == null) return null;
84
+ if (variant === 'cli') {
85
+ return { header: `<!-- rdc-skills: '${name}' served as CLI variant -->`, body };
86
+ }
87
+ const override = getCloudOverride(name);
88
+ if (override != null) {
89
+ return {
90
+ header: `<!-- rdc-skills: '${name}' served as CLOUD variant (hand-tuned SKILL.cloud.md) -->`,
91
+ body: override,
92
+ };
93
+ }
94
+ return {
95
+ header: `<!-- rdc-skills: '${name}' served as CLOUD variant (auto-rewritten from SKILL.md) -->`,
96
+ body: toCloudBody(body),
97
+ };
98
+ }
99
+
100
+ function textResult(text) {
101
+ return { content: [{ type: 'text', text }] };
102
+ }
103
+
104
+ function buildMcpServer() {
105
+ const srv = new McpServer({ name: 'rdc-skills', version: pkgVersion() });
106
+
107
+ // Capture clientInfo on initialize to refine the soft default variant.
108
+ // The SDK exposes the underlying low-level server; oninitialized fires after
109
+ // the client's initialize params are recorded.
110
+ try {
111
+ srv.server.oninitialized = () => {
112
+ const info = srv.server.getClientVersion?.();
113
+ if (info?.name) lastDetectedVariant = variantForClient(info.name);
114
+ };
115
+ } catch {
116
+ /* non-fatal: detection stays at the cloud default */
117
+ }
118
+
119
+ // ── rdc_skill_list ─────────────────────────────────────────────────────────
120
+ srv.registerTool(
121
+ 'rdc_skill_list',
122
+ {
123
+ description:
124
+ 'List the rdc-skills catalog: every skill with its slash form, category, summary, when-to-use triggers, usage, args, and required capabilities. No input required.',
125
+ inputSchema: {},
126
+ },
127
+ async () => {
128
+ const catalog = listSkills();
129
+ return textResult(JSON.stringify({ count: catalog.length, skills: catalog }, null, 2));
130
+ },
131
+ );
132
+
133
+ // ── rdc_skill_get ──────────────────────────────────────────────────────────
134
+ srv.registerTool(
135
+ 'rdc_skill_get',
136
+ {
137
+ description:
138
+ "Get a skill's SKILL.md body rendered for the caller. variant 'cli' returns the body unchanged; 'cloud' rewrites local-shell/clauth-daemon steps for the claude.ai web client. Omit variant to use auto-detection (defaults to cloud).",
139
+ inputSchema: {
140
+ name: z.string().describe('Skill name (e.g. "deploy", "build"). See rdc_skill_list.'),
141
+ variant: z.enum(['cli', 'cloud']).optional().describe('Force the rendered variant; overrides caller detection.'),
142
+ },
143
+ },
144
+ async ({ name, variant }) => {
145
+ const valid = skillNames();
146
+ if (!valid.includes(name)) {
147
+ return textResult(
148
+ `Unknown skill '${name}'. Valid skills (${valid.length}): ${valid.join(', ')}`,
149
+ );
150
+ }
151
+ const resolved = variant || lastDetectedVariant || 'cloud';
152
+ const rendered = renderSkill(name, resolved);
153
+ if (!rendered) {
154
+ return textResult(`Skill '${name}' exists in the catalog but has no SKILL.md body on disk.`);
155
+ }
156
+ return textResult(`${rendered.header}\n\n${rendered.body}`);
157
+ },
158
+ );
159
+
160
+ // ── rdc_skill_search ───────────────────────────────────────────────────────
161
+ srv.registerTool(
162
+ 'rdc_skill_search',
163
+ {
164
+ description:
165
+ 'Fuzzy/substring search the rdc-skills catalog over name, slash, summary, and trigger phrases. Returns ranked matches.',
166
+ inputSchema: {
167
+ query: z.string().describe('Search terms, e.g. "deploy coolify" or "work items".'),
168
+ },
169
+ },
170
+ async ({ query }) => {
171
+ const results = searchSkills(query);
172
+ return textResult(JSON.stringify({ query, count: results.length, results }, null, 2));
173
+ },
174
+ );
175
+
176
+ return srv;
177
+ }
178
+
179
+ function startHttp() {
180
+ const app = express();
181
+ app.use(express.json({ limit: '4mb' }));
182
+
183
+ // /health — open, cheap, reports live skill count.
184
+ app.get('/health', (_req, res) => {
185
+ let skills = 0;
186
+ try {
187
+ skills = listSkills().length;
188
+ } catch {
189
+ skills = 0;
190
+ }
191
+ res.json({ status: 'ok', service: 'rdc-skills-mcp', version: pkgVersion(), skills });
192
+ });
193
+
194
+ // Block OAuth discovery so connectors skip OAuth and connect direct — /mcp is open.
195
+ app.get('/.well-known/oauth-authorization-server', (_req, res) => res.status(404).end());
196
+ app.get('/.well-known/openid-configuration', (_req, res) => res.status(404).end());
197
+ app.get('/authorize', (_req, res) => res.status(404).end());
198
+ app.post('/token', (_req, res) => res.status(404).end());
199
+
200
+ // POST /mcp — stateless StreamableHTTP transport, no Authorization required.
201
+ app.post('/mcp', async (req, res) => {
202
+ try {
203
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
204
+ const srv = buildMcpServer();
205
+ await srv.connect(transport);
206
+ await transport.handleRequest(req, res, req.body);
207
+ res.on('close', async () => {
208
+ await transport.close().catch(() => {});
209
+ await srv.close().catch(() => {});
210
+ });
211
+ } catch (err) {
212
+ console.error('[rdc-skills-mcp] MCP error:', err);
213
+ if (!res.headersSent) {
214
+ res.status(500).json({
215
+ jsonrpc: '2.0',
216
+ error: { code: -32603, message: err?.message || 'internal error' },
217
+ id: null,
218
+ });
219
+ }
220
+ }
221
+ });
222
+
223
+ app.get('/mcp', (_req, res) => {
224
+ res.status(405).json({
225
+ jsonrpc: '2.0',
226
+ error: { code: -32000, message: 'Use POST for MCP requests' },
227
+ id: null,
228
+ });
229
+ });
230
+
231
+ app.listen(PORT, () => {
232
+ console.log(`[rdc-skills-mcp] ready on port ${PORT}`);
233
+ console.log(` MCP: POST http://localhost:${PORT}/mcp`);
234
+ console.log(` Health: GET http://localhost:${PORT}/health`);
235
+ });
236
+ }
237
+
238
+ startHttp();
@@ -0,0 +1,174 @@
1
+ /**
2
+ * lib/catalog.mjs — rdc-skills catalog loader for the local MCP server.
3
+ *
4
+ * Source of truth: `.claude-plugin/plugin.json` → `skills_meta` (an object keyed
5
+ * by skill name). Each entry provides { name, slash, category, usage, args,
6
+ * requires, triggers, ... }. We enrich each entry with the `description` from
7
+ * the skill's own `skills/<name>/SKILL.md` frontmatter (the human summary).
8
+ *
9
+ * Loading is LIVE: the catalog is re-read from disk when older than CACHE_TTL_MS
10
+ * so a skill edit (frontmatter or body) is picked up without a server restart.
11
+ * A skill that has a `skills_meta` entry but no SKILL.md on disk is still listed
12
+ * (meta-only); a skill dir with a SKILL.md but no meta entry is also surfaced
13
+ * (frontmatter-only) so nothing silently disappears from the catalog.
14
+ */
15
+
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+ import YAML from 'yaml';
20
+
21
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
22
+ const REPO_ROOT = path.resolve(__dirname, '..');
23
+ const PLUGIN_JSON = path.join(REPO_ROOT, '.claude-plugin', 'plugin.json');
24
+ const SKILLS_DIR = path.join(REPO_ROOT, 'skills');
25
+
26
+ const CACHE_TTL_MS = 5000; // short TTL: live-from-disk feel, cheap re-reads
27
+
28
+ let _cache = null;
29
+ let _cacheAt = 0;
30
+
31
+ /** Read + parse the YAML frontmatter block of a SKILL.md. Returns {} on miss. */
32
+ function readFrontmatter(skillMdPath) {
33
+ try {
34
+ const raw = fs.readFileSync(skillMdPath, 'utf8').replace(/\r\n/g, '\n');
35
+ const m = raw.match(/^---\n([\s\S]*?)\n---/);
36
+ if (!m) return {};
37
+ const parsed = YAML.parse(m[1]);
38
+ return parsed && typeof parsed === 'object' ? parsed : {};
39
+ } catch {
40
+ return {};
41
+ }
42
+ }
43
+
44
+ /** Map a raw skills_meta entry + SKILL.md frontmatter into a compact catalog row. */
45
+ function toCatalogEntry(name, meta, frontmatter) {
46
+ const description = frontmatter.description || meta.description || '';
47
+ // `summary` is the short customer-facing line; `when_to_use` pulls the triggers.
48
+ const triggers = Array.isArray(meta.triggers) ? meta.triggers : [];
49
+ return {
50
+ name,
51
+ slash: meta.slash || `rdc:${name}`,
52
+ category: meta.category || 'tooling',
53
+ summary: description,
54
+ when_to_use: triggers,
55
+ usage: meta.usage || '',
56
+ args: meta.args || { positional: [], flags: [] },
57
+ requires: Array.isArray(meta.requires) ? meta.requires : [],
58
+ };
59
+ }
60
+
61
+ /** Re-read plugin.json + skill frontmatter from disk and rebuild the catalog. */
62
+ function buildCatalog() {
63
+ let plugin = {};
64
+ try {
65
+ plugin = JSON.parse(fs.readFileSync(PLUGIN_JSON, 'utf8'));
66
+ } catch {
67
+ plugin = {};
68
+ }
69
+ const skillsMeta = plugin.skills_meta && typeof plugin.skills_meta === 'object' ? plugin.skills_meta : {};
70
+
71
+ const byName = new Map();
72
+
73
+ // 1. Every skills_meta entry → enrich with its SKILL.md frontmatter.
74
+ for (const [name, meta] of Object.entries(skillsMeta)) {
75
+ const skillMd = path.join(SKILLS_DIR, name, 'SKILL.md');
76
+ const fm = fs.existsSync(skillMd) ? readFrontmatter(skillMd) : {};
77
+ byName.set(name, toCatalogEntry(name, meta || {}, fm));
78
+ }
79
+
80
+ // 2. Any skill dir with a SKILL.md but NO meta entry → frontmatter-only row.
81
+ if (fs.existsSync(SKILLS_DIR)) {
82
+ for (const entry of fs.readdirSync(SKILLS_DIR, { withFileTypes: true })) {
83
+ if (!entry.isDirectory() || byName.has(entry.name)) continue;
84
+ const skillMd = path.join(SKILLS_DIR, entry.name, 'SKILL.md');
85
+ if (!fs.existsSync(skillMd)) continue;
86
+ const fm = readFrontmatter(skillMd);
87
+ byName.set(entry.name, toCatalogEntry(entry.name, {}, fm));
88
+ }
89
+ }
90
+
91
+ return Array.from(byName.values()).sort((a, b) => a.name.localeCompare(b.name));
92
+ }
93
+
94
+ /** Return the cached catalog, rebuilding if the TTL has elapsed. */
95
+ function getCatalog() {
96
+ const now = Date.now();
97
+ if (!_cache || now - _cacheAt > CACHE_TTL_MS) {
98
+ _cache = buildCatalog();
99
+ _cacheAt = now;
100
+ }
101
+ return _cache;
102
+ }
103
+
104
+ /** Compact catalog for `rdc_skill_list`. */
105
+ export function listSkills() {
106
+ return getCatalog();
107
+ }
108
+
109
+ /** Full catalog row for one skill, or null if unknown. */
110
+ export function getSkill(name) {
111
+ return getCatalog().find((s) => s.name === name) || null;
112
+ }
113
+
114
+ /** List of valid skill names (for error messages). */
115
+ export function skillNames() {
116
+ return getCatalog().map((s) => s.name);
117
+ }
118
+
119
+ /**
120
+ * Read the raw SKILL.md body (frontmatter stripped) for a skill, live from disk.
121
+ * Returns null if the file does not exist.
122
+ */
123
+ export function getSkillBody(name) {
124
+ const skillMd = path.join(SKILLS_DIR, name, 'SKILL.md');
125
+ if (!fs.existsSync(skillMd)) return null;
126
+ const raw = fs.readFileSync(skillMd, 'utf8').replace(/\r\n/g, '\n');
127
+ // Strip the leading frontmatter block if present.
128
+ const m = raw.match(/^---\n[\s\S]*?\n---\n?/);
129
+ return m ? raw.slice(m[0].length).replace(/^\n+/, '') : raw;
130
+ }
131
+
132
+ /** Absolute path to a hand-tuned cloud override, or null if absent. */
133
+ export function cloudOverridePath(name) {
134
+ const p = path.join(SKILLS_DIR, name, 'SKILL.cloud.md');
135
+ return fs.existsSync(p) ? p : null;
136
+ }
137
+
138
+ /** Read the hand-tuned cloud override body (frontmatter stripped), or null. */
139
+ export function getCloudOverride(name) {
140
+ const p = cloudOverridePath(name);
141
+ if (!p) return null;
142
+ const raw = fs.readFileSync(p, 'utf8').replace(/\r\n/g, '\n');
143
+ const m = raw.match(/^---\n[\s\S]*?\n---\n?/);
144
+ return m ? raw.slice(m[0].length).replace(/^\n+/, '') : raw;
145
+ }
146
+
147
+ /**
148
+ * Fuzzy/substring search over name + slash + summary + triggers.
149
+ * Returns ranked [{ name, slash, summary, score }] (score: higher = better).
150
+ */
151
+ export function searchSkills(query) {
152
+ const q = String(query || '').trim().toLowerCase();
153
+ if (!q) return [];
154
+ const terms = q.split(/\s+/).filter(Boolean);
155
+ const results = [];
156
+ for (const s of getCatalog()) {
157
+ const hay = {
158
+ name: s.name.toLowerCase(),
159
+ slash: (s.slash || '').toLowerCase(),
160
+ summary: (s.summary || '').toLowerCase(),
161
+ triggers: (s.when_to_use || []).join(' ').toLowerCase(),
162
+ };
163
+ let score = 0;
164
+ for (const term of terms) {
165
+ if (hay.name === term) score += 100;
166
+ else if (hay.name.includes(term)) score += 40;
167
+ if (hay.slash.includes(term)) score += 20;
168
+ if (hay.triggers.includes(term)) score += 12;
169
+ if (hay.summary.includes(term)) score += 6;
170
+ }
171
+ if (score > 0) results.push({ name: s.name, slash: s.slash, summary: s.summary, score });
172
+ }
173
+ return results.sort((a, b) => b.score - a.score);
174
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * lib/cloud-rewrite.mjs — transform a CLI SKILL.md body into a cloud
3
+ * (claude.ai web) body.
4
+ *
5
+ * The CLI bodies assume a Claude Code session on Dave's Windows host: a local
6
+ * clauth HTTP daemon at 127.0.0.1:52437, a local shell, PM2, git, pnpm, and
7
+ * local node scripts. The claude.ai web client has NONE of those — it reaches
8
+ * credentials through the clauth MCP and the filesystem through the File System
9
+ * MCP, and it cannot run local processes at all.
10
+ *
11
+ * `toCloudBody(markdown)` applies rule-based rewrites:
12
+ * 1. clauth daemon curl → "retrieve `<svc>` via the clauth MCP"
13
+ * 2. local shell/Bash fs ops + local `fs` → File System MCP tools
14
+ * 3. CLI-only mechanics (PM2 reload, git push, local node scripts, daemon
15
+ * restart, pnpm) → annotated with a callout (NOT deleted — the cloud
16
+ * caller needs to know to hand that step to a Claude Code session).
17
+ *
18
+ * The rewriter is intentionally rule-based and conservative: it annotates rather
19
+ * than rips out, so no information is lost. A hand-tuned `SKILL.cloud.md` (handled
20
+ * by the caller in catalog.mjs) bypasses this entirely.
21
+ *
22
+ * Contract: the cloud body for `deploy` MUST NOT contain `127.0.0.1:52437`.
23
+ */
24
+
25
+ const CLI_CALLOUT = '> ⚠ CLI-only step — hand this to a Claude Code session.';
26
+
27
+ /**
28
+ * Rule 1 — clauth daemon credential retrieval.
29
+ * Matches `curl ... http://127.0.0.1:52437/v/<svc>` (and /get/<svc>), with or
30
+ * without flags, optionally wrapped in $(...). Collapses to instruction text.
31
+ */
32
+ function rewriteClauthCurls(md) {
33
+ let out = md;
34
+
35
+ // `_VAR=$(curl ... 127.0.0.1:52437/v/<svc>)` → assignment-style instruction
36
+ out = out.replace(
37
+ /([A-Za-z_][A-Za-z0-9_]*)=\$\(\s*curl[^\n)]*?(?:127\.0\.0\.1|localhost):52437\/(?:v|get)\/([A-Za-z0-9._-]+)[^\n)]*\)/g,
38
+ (_m, varName, svc) => `# ${varName}: retrieve \`${svc}\` via the clauth MCP (clauth_inject / clauth_get)`,
39
+ );
40
+
41
+ // bare `curl ... 127.0.0.1:52437/v/<svc>` → instruction
42
+ out = out.replace(
43
+ /curl[^\n]*?(?:127\.0\.0\.1|localhost):52437\/(?:v|get)\/([A-Za-z0-9._-]+)[^\n]*/g,
44
+ (_m, svc) => `retrieve \`${svc}\` via the clauth MCP (clauth_inject / clauth_get)`,
45
+ );
46
+
47
+ // any remaining ping/list/other daemon references → MCP instruction
48
+ out = out.replace(
49
+ /curl[^\n]*?(?:127\.0\.0\.1|localhost):52437\/(?:ping|list-services|status|meta)[^\n]*/g,
50
+ 'check clauth health via the clauth MCP (clauth_ping / clauth_status)',
51
+ );
52
+
53
+ // belt-and-suspenders: kill any stray bare daemon URL still standing
54
+ out = out.replace(
55
+ /https?:\/\/(?:127\.0\.0\.1|localhost):52437\/(?:v|get)\/([A-Za-z0-9._-]+)/g,
56
+ (_m, svc) => `the clauth MCP value for \`${svc}\``,
57
+ );
58
+ out = out.replace(
59
+ /https?:\/\/(?:127\.0\.0\.1|localhost):52437\S*/g,
60
+ 'the clauth MCP',
61
+ );
62
+
63
+ return out;
64
+ }
65
+
66
+ /**
67
+ * Rule 2 — local shell/Bash file ops and a local `fs` → File System MCP.
68
+ * We add a one-line note rather than rewriting every cat/ls/grep, since the
69
+ * shapes vary wildly; the note tells the cloud caller which tools to reach for.
70
+ */
71
+ function rewriteFsOps(md) {
72
+ let out = md;
73
+ // Direct references to a local `fs` module / node fs.
74
+ out = out.replace(
75
+ /\bnode:fs\b|\brequire\(['"]fs['"]\)\b|\bfrom ['"]fs['"]/g,
76
+ 'the File System MCP (fs_read/fs_write/fs_glob/fs_grep)',
77
+ );
78
+ return out;
79
+ }
80
+
81
+ /**
82
+ * Rule 3 — flag CLI-only mechanics with a callout. We annotate fenced code
83
+ * blocks and bullet/numbered lines that contain a CLI-only verb. The callout
84
+ * is inserted ABOVE the offending block/line; the content is preserved.
85
+ */
86
+ const CLI_ONLY_PATTERNS = [
87
+ /\bpm2\s+(?:start|restart|reload|delete|stop|list|status)\b/i,
88
+ /\bgit\s+push\b/i,
89
+ /\bpnpm\b/i,
90
+ /\bnpm\s+(?:install|run|publish|ci)\b/i,
91
+ /\bnode\s+[A-Za-z0-9_./-]+\.(?:mjs|cjs|js)\b/i,
92
+ /\brestart-clauth(?:\.bat)?\b/i,
93
+ /\bclauth\s+(?:scrub|restart|serve|lock|unlock)\b/i,
94
+ ];
95
+
96
+ function isCliOnlyLine(line) {
97
+ return CLI_ONLY_PATTERNS.some((re) => re.test(line));
98
+ }
99
+
100
+ /**
101
+ * Walk the markdown line by line. Inside fenced ``` blocks, if ANY line trips a
102
+ * CLI-only pattern, emit one callout immediately before the fence. Outside code
103
+ * blocks, annotate individual list/prose lines that trip a pattern.
104
+ */
105
+ function annotateCliOnly(md) {
106
+ const lines = md.split('\n');
107
+ const out = [];
108
+ let i = 0;
109
+ while (i < lines.length) {
110
+ const line = lines[i];
111
+ const fenceMatch = line.match(/^(\s*)```/);
112
+ if (fenceMatch) {
113
+ // Collect the whole fenced block.
114
+ const block = [line];
115
+ let j = i + 1;
116
+ for (; j < lines.length; j++) {
117
+ block.push(lines[j]);
118
+ if (/^\s*```/.test(lines[j])) {
119
+ j++;
120
+ break;
121
+ }
122
+ }
123
+ const hasCliOnly = block.some(isCliOnlyLine);
124
+ if (hasCliOnly) out.push(`${fenceMatch[1]}${CLI_CALLOUT}`);
125
+ out.push(...block);
126
+ i = j;
127
+ continue;
128
+ }
129
+
130
+ // Non-fence line: annotate list/prose lines that trip a pattern, but never
131
+ // double-annotate (skip if previous emitted line is already the callout).
132
+ if (isCliOnlyLine(line) && out[out.length - 1] !== CLI_CALLOUT) {
133
+ const indent = (line.match(/^(\s*)/) || ['', ''])[1];
134
+ out.push(`${indent}${CLI_CALLOUT}`);
135
+ }
136
+ out.push(line);
137
+ i++;
138
+ }
139
+ return out.join('\n');
140
+ }
141
+
142
+ /**
143
+ * Transform a CLI SKILL.md body into the cloud body.
144
+ * Order matters: rewrite clauth curls FIRST (they often live in code blocks that
145
+ * the CLI-only annotator would otherwise also flag), then fs ops, then annotate
146
+ * remaining CLI-only mechanics.
147
+ */
148
+ export function toCloudBody(markdown) {
149
+ if (typeof markdown !== 'string' || !markdown) return markdown;
150
+ let out = markdown;
151
+ out = rewriteClauthCurls(out);
152
+ out = rewriteFsOps(out);
153
+ out = annotateCliOnly(out);
154
+ return out;
155
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lifeaitools/rdc-skills",
3
- "version": "0.16.0",
3
+ "version": "0.17.1",
4
4
  "description": "RDC typed-agent dispatch skill suite for Claude Code - plan, build, review, overnight builds",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -24,7 +24,8 @@
24
24
  },
25
25
  "bin": {
26
26
  "rdc-skills-install": "scripts/install-rdc-skills.js",
27
- "rdc-skills-self-test": "scripts/self-test.mjs"
27
+ "rdc-skills-self-test": "scripts/self-test.mjs",
28
+ "rdc-skills-mcp": "bin/rdc-skills-mcp.mjs"
28
29
  },
29
30
  "scripts": {
30
31
  "install-rdc-skills": "node scripts/install-rdc-skills.js",
@@ -33,9 +34,16 @@
33
34
  "validate": "node tests/validate-skills.js",
34
35
  "rdc-design": "node scripts/rdc-design-cli.mjs",
35
36
  "test:hooks": "node scripts/test-rdc-hooks.mjs",
37
+ "test:mcp": "node tests/mcp.test.mjs",
38
+ "test:mcp:remote": "node tests/mcp.test.mjs --remote",
39
+ "mcp": "node bin/rdc-skills-mcp.mjs",
40
+ "rebuild-mcp": "node scripts/rebuild-mcp.mjs",
36
41
  "prepack": "node scripts/validate-publish-manifests.js --mode warn || true && node scripts/validate-place-histories.js --mode warn || true"
37
42
  },
38
- "devDependencies": {
39
- "yaml": "^2.9.0"
43
+ "dependencies": {
44
+ "@modelcontextprotocol/sdk": "^1.29.0",
45
+ "express": "^5.0.0",
46
+ "yaml": "^2.9.0",
47
+ "zod": "^3.25.76"
40
48
  }
41
49
  }
@@ -675,6 +675,66 @@ function buildHooksConfig(hooksDir, profile = 'core') {
675
675
  return config;
676
676
  }
677
677
 
678
+ // ── MCP server registration (non-fatal) ───────────────────────────────────────
679
+ // Ensures the rdc-skills MCP deps are installed, registers/starts the local MCP
680
+ // under PM2 as `rdc-skills-mcp` on PORT=3110, and prints the claude.ai connector
681
+ // line. Every failure here WARNs — it must never abort the installer.
682
+ function registerMcpServer() {
683
+ const MCP_NAME = 'rdc-skills-mcp';
684
+ const MCP_PORT = '3110';
685
+ const binPath = path.join(repoRoot, 'bin', 'rdc-skills-mcp.mjs');
686
+ const connector = 'https://rdc-skills.regendevcorp.com/mcp';
687
+
688
+ try {
689
+ // (a) ensure MCP runtime deps are present (express, @modelcontextprotocol/sdk, yaml, zod)
690
+ const needDeps = ['express', '@modelcontextprotocol/sdk', 'yaml', 'zod'].some((d) => {
691
+ try { require.resolve(d, { paths: [repoRoot] }); return false; } catch { return true; }
692
+ });
693
+ if (needDeps) {
694
+ try {
695
+ execSync('npm install --no-audit --no-fund', { cwd: repoRoot, stdio: 'pipe' });
696
+ ok('[7/7] MCP deps — installed');
697
+ } catch (e) {
698
+ warn(`[7/7] MCP deps — npm install failed (${e.message.split('\n')[0]}); install manually with \`npm install\``);
699
+ }
700
+ } else {
701
+ ok('[7/7] MCP deps — already present');
702
+ }
703
+
704
+ // (b) register/start under PM2 (tolerate pm2 missing)
705
+ let pm2Ok = false;
706
+ try { execSync('pm2 -v', { stdio: 'pipe' }); pm2Ok = true; } catch { pm2Ok = false; }
707
+
708
+ if (!pm2Ok) {
709
+ warn('[7/7] MCP server — pm2 not found; start manually:');
710
+ info(` PORT=${MCP_PORT} pm2 start ${binPath} --name ${MCP_NAME}`);
711
+ } else {
712
+ let registered = false;
713
+ try {
714
+ const jlist = JSON.parse(execSync('pm2 jlist', { encoding: 'utf8', stdio: 'pipe' }));
715
+ registered = Array.isArray(jlist) && jlist.some((p) => p.name === MCP_NAME);
716
+ } catch {}
717
+ try {
718
+ if (registered) {
719
+ execSync(`pm2 restart ${MCP_NAME} --update-env`, { cwd: repoRoot, stdio: 'pipe', env: { ...process.env, PORT: MCP_PORT } });
720
+ ok(`[7/7] MCP server — pm2 restarted ${MCP_NAME} (PORT=${MCP_PORT})`);
721
+ } else {
722
+ execSync(`pm2 start "${binPath}" --name ${MCP_NAME}`, { cwd: repoRoot, stdio: 'pipe', env: { ...process.env, PORT: MCP_PORT } });
723
+ ok(`[7/7] MCP server — pm2 started ${MCP_NAME} (PORT=${MCP_PORT})`);
724
+ }
725
+ } catch (e) {
726
+ warn(`[7/7] MCP server — pm2 start/restart failed (${e.message.split('\n')[0]})`);
727
+ info(` PORT=${MCP_PORT} pm2 start ${binPath} --name ${MCP_NAME}`);
728
+ }
729
+ }
730
+
731
+ // (c) print the claude.ai connector line
732
+ info(` claude.ai connector: ${connector} (Auth: none — URL is the shared secret)`);
733
+ } catch (e) {
734
+ warn(`[7/7] MCP server — skipped (${e.message.split('\n')[0]})`);
735
+ }
736
+ }
737
+
678
738
  // ── Preflight ─────────────────────────────────────────────────────────────────
679
739
  function runPreflight() {
680
740
  const nodeMajor = parseInt(process.versions.node.split('.')[0], 10);
@@ -982,6 +1042,11 @@ async function main() {
982
1042
  process.exit(2);
983
1043
  }
984
1044
 
1045
+ // 7. MCP server registration (non-fatal — WARNs only, never aborts)
1046
+ console.log('');
1047
+ console.log(' \x1b[36mMCP server:\x1b[0m');
1048
+ try { registerMcpServer(); } catch (e) { warn(`[7/7] MCP server — unexpected error (${e.message})`); }
1049
+
985
1050
  // Done
986
1051
  console.log('');
987
1052
  console.log(' \x1b[32mDone!\x1b[0m');
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * scripts/rebuild-mcp.mjs — "build hook" for the rdc-skills MCP server.
4
+ *
5
+ * The server reads skills live from disk, so a skill edit is usually picked up
6
+ * without a restart. This script exists to (a) bounce the PM2 process so any
7
+ * in-memory module cache is dropped, and (b) assert the local /health endpoint
8
+ * reports the current on-disk skill count — a fast smoke that the server is up
9
+ * and seeing the same catalog this script does.
10
+ *
11
+ * Tolerant of a missing PM2: if pm2 is not installed/registered, it prints how
12
+ * to start the server and exits 0. It never hard-fails the caller.
13
+ */
14
+
15
+ import { execSync } from 'node:child_process';
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+ import { listSkills } from '../lib/catalog.mjs';
20
+
21
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
22
+ const REPO_ROOT = path.resolve(__dirname, '..');
23
+ const PM2_NAME = 'rdc-skills-mcp';
24
+ const PORT = parseInt(process.env.PORT || '3110', 10);
25
+ const BIN = path.join(REPO_ROOT, 'bin', 'rdc-skills-mcp.mjs');
26
+
27
+ const ok = (m) => console.log(` \x1b[32m✓\x1b[0m ${m}`);
28
+ const info = (m) => console.log(` \x1b[36m→\x1b[0m ${m}`);
29
+ const warn = (m) => console.log(` \x1b[33m⚠\x1b[0m ${m}`);
30
+
31
+ function sh(cmd) {
32
+ return execSync(cmd, { encoding: 'utf8', stdio: 'pipe' }).trim();
33
+ }
34
+
35
+ function pm2Available() {
36
+ try {
37
+ sh('pm2 -v');
38
+ return true;
39
+ } catch {
40
+ return false;
41
+ }
42
+ }
43
+
44
+ function pm2HasProcess() {
45
+ try {
46
+ const list = JSON.parse(sh('pm2 jlist'));
47
+ return Array.isArray(list) && list.some((p) => p.name === PM2_NAME);
48
+ } catch {
49
+ return false;
50
+ }
51
+ }
52
+
53
+ async function checkHealth(expectedSkills) {
54
+ // Node 22 has global fetch.
55
+ try {
56
+ const res = await fetch(`http://127.0.0.1:${PORT}/health`, { signal: AbortSignal.timeout(4000) });
57
+ if (!res.ok) {
58
+ warn(`/health returned HTTP ${res.status}`);
59
+ return false;
60
+ }
61
+ const json = await res.json();
62
+ if (json.skills === expectedSkills) {
63
+ ok(`/health OK — skills=${json.skills} (matches disk), version=${json.version}`);
64
+ return true;
65
+ }
66
+ warn(`/health skills=${json.skills} but disk has ${expectedSkills} — server may be stale`);
67
+ return false;
68
+ } catch (err) {
69
+ warn(`/health unreachable on port ${PORT}: ${err?.message || err}`);
70
+ return false;
71
+ }
72
+ }
73
+
74
+ async function main() {
75
+ const diskSkills = listSkills().length;
76
+ info(`On-disk catalog: ${diskSkills} skill(s)`);
77
+
78
+ if (!pm2Available()) {
79
+ warn('pm2 not found — cannot bounce the MCP process.');
80
+ info(`Start it manually: PORT=${PORT} pm2 start ${BIN} --name ${PM2_NAME}`);
81
+ info(`Or run directly: PORT=${PORT} node ${BIN}`);
82
+ process.exit(0);
83
+ }
84
+
85
+ if (pm2HasProcess()) {
86
+ try {
87
+ sh(`pm2 restart ${PM2_NAME} --update-env`);
88
+ ok(`pm2 restart ${PM2_NAME}`);
89
+ } catch (err) {
90
+ warn(`pm2 restart failed: ${err?.message || err}`);
91
+ }
92
+ } else {
93
+ info(`pm2 process '${PM2_NAME}' not registered.`);
94
+ info(`Start it: PORT=${PORT} pm2 start ${BIN} --name ${PM2_NAME}`);
95
+ process.exit(0);
96
+ }
97
+
98
+ // Give the process a beat to bind, then probe /health (no long sleeps).
99
+ await new Promise((r) => setTimeout(r, 1200));
100
+ await checkHealth(diskSkills);
101
+ process.exit(0);
102
+ }
103
+
104
+ main().catch((err) => {
105
+ warn(`rebuild-mcp error (non-fatal): ${err?.message || err}`);
106
+ process.exit(0);
107
+ });
@@ -0,0 +1,219 @@
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
+ } from '../lib/catalog.mjs';
36
+ import { toCloudBody } from '../lib/cloud-rewrite.mjs';
37
+
38
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
39
+ const REPO_ROOT = path.resolve(__dirname, '..');
40
+ const BIN = path.join(REPO_ROOT, 'bin', 'rdc-skills-mcp.mjs');
41
+ const TEST_PORT = parseInt(process.env.TEST_PORT || '3197', 10);
42
+ const LOCAL = `http://127.0.0.1:${TEST_PORT}`;
43
+ const REMOTE_URL = 'https://rdc-skills.regendevcorp.com/mcp';
44
+ const UA = 'Mozilla/5.0 (rdc-skills-test)';
45
+ const DAEMON = '127.0.0.1:52437';
46
+
47
+ // ── tiny assert harness ──────────────────────────────────────────────────────
48
+ let pass = 0;
49
+ let fail = 0;
50
+ const failures = [];
51
+ function check(name, cond, detail = '') {
52
+ if (cond) { pass++; }
53
+ else { fail++; failures.push(`${name}${detail ? ` — ${detail}` : ''}`); }
54
+ }
55
+ function strip(text) {
56
+ // The server prepends `<!-- rdc-skills: ... -->\n\n` to a rendered body.
57
+ return text.replace(/^<!--[^\n]*-->\n\n/, '');
58
+ }
59
+
60
+ // ── MCP-over-HTTP helper (stateless; SSE or JSON response) ───────────────────
61
+ async function mcp(url, body) {
62
+ const res = await fetch(url, {
63
+ method: 'POST',
64
+ headers: {
65
+ 'Content-Type': 'application/json',
66
+ Accept: 'application/json, text/event-stream',
67
+ 'User-Agent': UA,
68
+ },
69
+ body: JSON.stringify(body),
70
+ });
71
+ const raw = await res.text();
72
+ if (raw.includes('data:')) {
73
+ let out = null;
74
+ for (const line of raw.split('\n')) {
75
+ if (line.startsWith('data:')) out = JSON.parse(line.slice(5).trim());
76
+ }
77
+ return { status: res.status, json: out };
78
+ }
79
+ return { status: res.status, json: raw ? JSON.parse(raw) : null };
80
+ }
81
+ function callText(json) {
82
+ return json?.result?.content?.[0]?.text ?? '';
83
+ }
84
+
85
+ // ── 1. UNIT ──────────────────────────────────────────────────────────────────
86
+ function unitTests() {
87
+ const cat = listSkills();
88
+ check('unit: catalog non-empty', cat.length >= 20, `got ${cat.length}`);
89
+ check('unit: every entry has name+slash+category+summary field',
90
+ cat.every((s) => s.name && s.slash && s.category && 'summary' in s));
91
+ check('unit: every slash is rdc:<name> shape', cat.every((s) => /^rdc:/.test(s.slash)));
92
+ check('unit: getSkill(deploy) resolves', !!getSkill('deploy'));
93
+ check('unit: getSkill(nonexistent) is null', getSkill('___nope___') === null);
94
+ check('unit: search ranks exact name first', searchSkills('deploy')[0]?.name === 'deploy');
95
+ check('unit: empty search returns []', searchSkills('').length === 0);
96
+
97
+ // cloud-rewrite contract
98
+ const sample = '```bash\n_T=$(curl -s http://127.0.0.1:52437/v/coolify-api)\npm2 restart app\n```';
99
+ const rw = toCloudBody(sample);
100
+ check('unit: rewrite strips daemon URL', !rw.includes(DAEMON));
101
+ check('unit: rewrite mentions clauth MCP', rw.includes('clauth MCP'));
102
+ check('unit: rewrite flags CLI-only pm2', rw.includes('CLI-only'));
103
+ check('unit: rewrite guards empty input', toCloudBody('') === '');
104
+ }
105
+
106
+ // ── 2 & 3. shared per-skill sweep over an MCP endpoint ───────────────────────
107
+ async function sweep(url, label, { compareSource }) {
108
+ // initialize
109
+ const init = await mcp(url, {
110
+ jsonrpc: '2.0', id: 1, method: 'initialize',
111
+ params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'rdc-skills-test', version: '1' } },
112
+ });
113
+ check(`${label}: initialize 200`, init.status === 200, `status ${init.status}`);
114
+ check(`${label}: serverInfo name`, init.json?.result?.serverInfo?.name === 'rdc-skills');
115
+
116
+ // tools/list
117
+ const tl = await mcp(url, { jsonrpc: '2.0', id: 2, method: 'tools/list' });
118
+ const toolNames = (tl.json?.result?.tools || []).map((t) => t.name).sort();
119
+ check(`${label}: exactly 3 expected tools`,
120
+ JSON.stringify(toolNames) === JSON.stringify(['rdc_skill_get', 'rdc_skill_list', 'rdc_skill_search']),
121
+ toolNames.join(','));
122
+
123
+ // rdc_skill_list
124
+ const sl = await mcp(url, { jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'rdc_skill_list', arguments: {} } });
125
+ let listed;
126
+ try { listed = JSON.parse(callText(sl.json)); } catch { listed = { count: 0, skills: [] }; }
127
+ const names = (listed.skills || []).map((s) => s.name);
128
+ check(`${label}: rdc_skill_list count matches`, listed.count === names.length && names.length >= 20, `count ${listed.count}`);
129
+ if (compareSource) {
130
+ check(`${label}: list names == local catalog`, JSON.stringify([...names].sort()) === JSON.stringify(skillNames().sort()));
131
+ }
132
+
133
+ // EVERY skill: fetch both variants, assert correctness + cloud contract.
134
+ let rewrittenCount = 0;
135
+ for (const name of names) {
136
+ const cloud = await mcp(url, { jsonrpc: '2.0', id: 10, method: 'tools/call', params: { name: 'rdc_skill_get', arguments: { name, variant: 'cloud' } } });
137
+ const cloudBody = strip(callText(cloud.json));
138
+ const cli = await mcp(url, { jsonrpc: '2.0', id: 11, method: 'tools/call', params: { name: 'rdc_skill_get', arguments: { name, variant: 'cli' } } });
139
+ const cliBody = strip(callText(cli.json));
140
+
141
+ check(`${label}: [${name}] cloud body non-empty`, cloudBody.length > 0);
142
+ check(`${label}: [${name}] cli body non-empty`, cliBody.length > 0);
143
+ // CLOUD CONTRACT — no daemon URL, for EVERY skill.
144
+ check(`${label}: [${name}] cloud has NO daemon URL`, !cloudBody.includes(DAEMON),
145
+ cloudBody.includes(DAEMON) ? 'leaked 127.0.0.1:52437' : '');
146
+
147
+ if (compareSource) {
148
+ // Correctness: the rendered bodies must equal the library's own output.
149
+ const expectedCli = getSkillBody(name);
150
+ const expectedCloud = getCloudOverride(name) ?? toCloudBody(expectedCli);
151
+ check(`${label}: [${name}] cli body == on-disk source`, cliBody === expectedCli);
152
+ check(`${label}: [${name}] cloud body == toCloudBody(source)/override`, cloudBody === expectedCloud);
153
+ if (expectedCli && expectedCli.includes(DAEMON)) rewrittenCount++;
154
+ }
155
+ }
156
+ if (compareSource) {
157
+ check(`${label}: at least one skill actually had a daemon URL rewritten`, rewrittenCount > 0, `rewritten ${rewrittenCount}`);
158
+ }
159
+
160
+ // error path
161
+ const unk = await mcp(url, { jsonrpc: '2.0', id: 20, method: 'tools/call', params: { name: 'rdc_skill_get', arguments: { name: '___nope___' } } });
162
+ check(`${label}: unknown skill returns helpful error`, /unknown skill/i.test(callText(unk.json)));
163
+
164
+ // search
165
+ const se = await mcp(url, { jsonrpc: '2.0', id: 21, method: 'tools/call', params: { name: 'rdc_skill_search', arguments: { query: 'deploy' } } });
166
+ let sr; try { sr = JSON.parse(callText(se.json)); } catch { sr = { results: [] }; }
167
+ check(`${label}: search returns ranked results`, (sr.results || []).length > 0 && sr.results[0].name);
168
+
169
+ return names.length;
170
+ }
171
+
172
+ // ── server lifecycle for integration ─────────────────────────────────────────
173
+ async function waitHealth(url, tries = 40) {
174
+ for (let i = 0; i < tries; i++) {
175
+ try {
176
+ const r = await fetch(`${url}/health`);
177
+ if (r.status === 200) return await r.json();
178
+ } catch { /* not up yet */ }
179
+ await new Promise((r) => setTimeout(r, 150));
180
+ }
181
+ throw new Error('server did not become healthy');
182
+ }
183
+
184
+ async function main() {
185
+ console.log('── unit ──');
186
+ unitTests();
187
+
188
+ console.log('── integration (spawned server) ──');
189
+ const proc = spawn('node', [BIN], { env: { ...process.env, PORT: String(TEST_PORT) }, stdio: 'ignore' });
190
+ let swept = 0;
191
+ try {
192
+ const health = await waitHealth(LOCAL);
193
+ check('integration: /health status ok', health.status === 'ok');
194
+ check('integration: /health skill count == catalog', health.skills === listSkills().length, `health ${health.skills} vs ${listSkills().length}`);
195
+ swept = await sweep(`${LOCAL}/mcp`, 'integration', { compareSource: true });
196
+ console.log(` swept ${swept} skills (both variants)`);
197
+ } finally {
198
+ proc.kill();
199
+ }
200
+
201
+ if (process.env.REMOTE || process.argv.includes('--remote')) {
202
+ console.log('── systems (live tunnel) ──');
203
+ const n = await sweep(REMOTE_URL, 'systems', { compareSource: false });
204
+ console.log(` swept ${n} skills over ${REMOTE_URL}`);
205
+ } else {
206
+ console.log('── systems skipped (set REMOTE=1 to sweep the live endpoint) ──');
207
+ }
208
+
209
+ console.log(`\nRESULTS: ${pass} passed, ${fail} failed`);
210
+ if (fail) {
211
+ console.log('FAILURES:');
212
+ for (const f of failures.slice(0, 40)) console.log(` ✗ ${f}`);
213
+ process.exit(1);
214
+ }
215
+ console.log('✓ all green');
216
+ process.exit(0);
217
+ }
218
+
219
+ main().catch((e) => { console.error('test harness error:', e); process.exit(1); });