@lifeaitools/rdc-skills 0.16.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/bin/rdc-skills-mcp.mjs +238 -0
- package/lib/catalog.mjs +174 -0
- package/lib/cloud-rewrite.mjs +155 -0
- package/package.json +10 -4
- package/scripts/install-rdc-skills.js +65 -0
- package/scripts/rebuild-mcp.mjs +107 -0
|
@@ -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();
|
package/lib/catalog.mjs
ADDED
|
@@ -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.
|
|
3
|
+
"version": "0.17.0",
|
|
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,14 @@
|
|
|
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
|
+
"mcp": "node bin/rdc-skills-mcp.mjs",
|
|
38
|
+
"rebuild-mcp": "node scripts/rebuild-mcp.mjs",
|
|
36
39
|
"prepack": "node scripts/validate-publish-manifests.js --mode warn || true && node scripts/validate-place-histories.js --mode warn || true"
|
|
37
40
|
},
|
|
38
|
-
"
|
|
39
|
-
"
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
43
|
+
"express": "^5.0.0",
|
|
44
|
+
"yaml": "^2.9.0",
|
|
45
|
+
"zod": "^3.25.76"
|
|
40
46
|
}
|
|
41
47
|
}
|
|
@@ -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
|
+
});
|