@lifeaitools/rdc-skills 0.25.4 → 0.25.9

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 (71) hide show
  1. package/.claude-plugin/plugin.json +1574 -1528
  2. package/.github/workflows/self-test.yml +34 -34
  3. package/CHANGELOG.md +326 -313
  4. package/MANIFEST.md +224 -224
  5. package/README.md +372 -369
  6. package/RELEASE.md +26 -2
  7. package/commands/build.md +181 -181
  8. package/commands/collab.md +180 -180
  9. package/commands/deploy.md +148 -148
  10. package/commands/fixit.md +150 -150
  11. package/commands/handoff.md +173 -173
  12. package/commands/overnight.md +220 -220
  13. package/commands/plan.md +158 -158
  14. package/commands/preplan.md +131 -131
  15. package/commands/prototype.md +145 -145
  16. package/commands/report.md +99 -99
  17. package/commands/review.md +120 -120
  18. package/commands/status.md +86 -86
  19. package/commands/workitems.md +127 -127
  20. package/git-sha.json +1 -1
  21. package/guides/agent-bootstrap.md +195 -195
  22. package/guides/agents/backend.md +102 -102
  23. package/guides/agents/content.md +94 -94
  24. package/guides/agents/cs2.md +56 -56
  25. package/guides/agents/data.md +86 -86
  26. package/guides/agents/design.md +77 -77
  27. package/guides/agents/frontend.md +91 -91
  28. package/guides/agents/infrastructure.md +81 -81
  29. package/guides/agents/setup.md +272 -272
  30. package/guides/agents/verify.md +119 -119
  31. package/guides/agents/viz.md +106 -106
  32. package/hooks/check-rdc-environment.js +318 -164
  33. package/hooks/lib/box-lock.js +186 -0
  34. package/package.json +1 -1
  35. package/scripts/install-rdc-skills.js +1474 -1400
  36. package/scripts/local-install-with-stop.sh +41 -0
  37. package/scripts/probe-box-lock.mjs +179 -0
  38. package/scripts/probe-installed-hooks.mjs +60 -0
  39. package/scripts/probe-lock-holders.mjs +36 -0
  40. package/scripts/self-test.mjs +1459 -1459
  41. package/scripts/validate-publish-manifests.js +502 -502
  42. package/skills/build/SKILL.md +578 -578
  43. package/skills/channel-formatter/SKILL.md +538 -538
  44. package/skills/collab/SKILL.md +239 -239
  45. package/skills/convert/SKILL.md +150 -161
  46. package/skills/deploy/SKILL.md +541 -541
  47. package/skills/design/SKILL.md +205 -205
  48. package/skills/env/SKILL.md +139 -139
  49. package/skills/fixit/SKILL.md +203 -203
  50. package/skills/handoff/SKILL.md +236 -236
  51. package/skills/housekeeping/SKILL.md +189 -189
  52. package/skills/new-model/SKILL.md +14 -3
  53. package/skills/onramp/SKILL.md +1459 -1459
  54. package/skills/overnight/SKILL.md +251 -251
  55. package/skills/plan/SKILL.md +345 -345
  56. package/skills/preplan/SKILL.md +90 -90
  57. package/skills/prototype/SKILL.md +150 -150
  58. package/skills/regen-media/SKILL.md +94 -94
  59. package/skills/release/SKILL.md +140 -140
  60. package/skills/report/SKILL.md +100 -100
  61. package/skills/review/SKILL.md +151 -151
  62. package/skills/self-test/SKILL.md +108 -108
  63. package/skills/status/SKILL.md +99 -99
  64. package/skills/tests/MATRIX.md +55 -55
  65. package/skills/tests/onramp.test.json +87 -87
  66. package/skills/tests/rdc-regen-media.test.json +29 -29
  67. package/skills/watch/SKILL.md +84 -84
  68. package/skills/workitems/SKILL.md +151 -151
  69. package/tests/curl-surface.test.mjs +4 -2
  70. package/tests/help-surface.test.mjs +1 -1
  71. package/tests/install-rdc-skills.test.mjs +1 -1
@@ -1,1401 +1,1475 @@
1
1
  #!/usr/bin/env node
2
- /**
3
- * install-rdc-skills — registers rdc-skills on every Claude surface.
4
- *
5
- * Usage:
6
- * node scripts/install-rdc-skills.js ← standard
7
- * node scripts/install-rdc-skills.js --skip-hooks ← skip hook wiring
8
- * node scripts/install-rdc-skills.js --profile core ← clean-box portable hooks
9
- * node scripts/install-rdc-skills.js --profile lifeai ← LIFEAI/regen-root hooks
10
- * node scripts/install-rdc-skills.js --claude-home <path> ← custom CLI home
11
- * node scripts/install-rdc-skills.js --codex-root <path> ← also install to .agents/skills/user/
12
- * node scripts/install-rdc-skills.js --codex-skill-dir <path> ← also install to a Codex skill dir
13
- * node scripts/install-rdc-skills.js --project-root <path> --write-startup-blocks
14
- * node scripts/install-rdc-skills.js --migrate <path> ← migrate docs/ → .rdc/
15
- *
16
- * What it does:
17
- * 1. git pull (latest commands + guides)
18
- * 2. CLI plugin — registers in ~/.claude/plugins/ + settings.json
19
- * 3. Cowork — registers in Desktop cowork_plugins/ + cowork_settings.json
20
- * 3.5 Codex — copies skills to detected Codex skill dirs
21
- * 4. Hook files — copies hooks/*.js → ~/.claude/hooks/
22
- * 5. Hook wiring — wires hooks into ~/.claude/settings.json
23
- * 6. Zip — builds dist/rdc-skills-plugin.zip for claude.ai / distribution
24
- * 7. Preflight — Node version, clauth daemon
25
- * 8. Commands — lists all /rdc:* commands
26
- */
27
-
28
- 'use strict';
29
- const fs = require('fs');
30
- const path = require('path');
31
- const os = require('os');
32
- const readline = require('readline');
33
- const http = require('http');
34
- const crypto = require('crypto');
35
- const { execSync } = require('child_process');
36
-
37
- // ── Args ──────────────────────────────────────────────────────────────────────
38
- const args = process.argv.slice(2);
39
- const skipHooks = args.includes('--skip-hooks');
40
- const profileIdx = args.indexOf('--profile');
41
- const profileArg = profileIdx >= 0 ? String(args[profileIdx + 1] || '').toLowerCase() : 'auto';
42
- const homeIdx = args.indexOf('--claude-home');
43
- const claudeHome = homeIdx >= 0 ? args[homeIdx + 1] : path.join(os.homedir(), '.claude');
44
- const migrateIdx = args.indexOf('--migrate');
45
- const doMigrate = migrateIdx >= 0;
46
- const migratePath = doMigrate ? (args[migrateIdx + 1] || process.cwd()) : null;
47
- const projectIdx = args.indexOf('--project-root');
48
- const projectRootArg = projectIdx >= 0 ? path.resolve(args[projectIdx + 1]) : null;
49
- const shouldWriteStartupBlocks = args.includes('--write-startup-blocks');
50
-
51
- const repoRoot = path.resolve(__dirname, '..');
52
-
53
- const codexIdx = args.indexOf('--codex-root');
54
- const codexRoot = codexIdx >= 0
55
- ? path.resolve(args[codexIdx + 1])
56
- : (() => {
57
- // Auto-detect the consuming project's .agents tree so a plain `install`
58
- // refreshes Codex without needing --codex-root. Check, in order: an
59
- // explicit --project-root, the current working directory, then the
60
- // regen-root sibling of this repo. First one with a `.agents` wins.
61
- const candidates = [
62
- projectRootArg ? path.resolve(projectRootArg) : null,
63
- process.cwd(),
64
- path.resolve(repoRoot, '..', 'regen-root'),
65
- ].filter(Boolean);
66
- for (const c of candidates) {
67
- if (fs.existsSync(path.join(c, '.agents'))) return c;
68
- }
69
- return null;
70
- })();
71
- const codexSkillDirIdx = args.indexOf('--codex-skill-dir');
72
- const explicitCodexSkillDir = codexSkillDirIdx >= 0
73
- ? path.resolve(args[codexSkillDirIdx + 1])
74
- : null;
75
- const hooksSrc = path.join(repoRoot, 'hooks');
76
- const hooksDst = path.join(claudeHome, 'hooks');
77
- const settingsPath = path.join(claudeHome, 'settings.json');
78
- const detectedLifeaiRoot = (() => {
79
- const sibling = path.resolve(repoRoot, '..', 'regen-root');
80
- return fs.existsSync(path.join(sibling, 'CLAUDE.md')) && fs.existsSync(path.join(sibling, '.rdc')) ? sibling : null;
81
- })();
82
- const installProfile = (() => {
83
- if (profileArg === 'core' || profileArg === 'lifeai') return profileArg;
84
- if (profileArg !== 'auto') {
85
- console.log(` \x1b[33m⚠\x1b[0m Unknown --profile "${profileArg}" — using auto`);
86
- }
87
- return detectedLifeaiRoot ? 'lifeai' : 'core';
88
- })();
89
- const projectRoot = projectRootArg || codexRoot || detectedLifeaiRoot;
90
-
91
- const PLUGIN_KEY = 'rdc-skills@rdc-skills';
92
- const MARKETPLACE = 'rdc-skills';
93
- const NPM_PACKAGE = '@lifeaitools/rdc-skills';
94
- const MCP_NAME = 'rdc-skills-mcp';
95
- const MCP_PORT = '3110';
96
-
97
- // ── Logging ───────────────────────────────────────────────────────────────────
98
- const ok = msg => console.log(` \x1b[32m✓\x1b[0m ${msg}`);
99
- const info = msg => console.log(` \x1b[36m→\x1b[0m ${msg}`);
100
- const warn = msg => console.log(` \x1b[33m⚠\x1b[0m ${msg}`);
101
- const fail = msg => console.log(` \x1b[31m✗\x1b[0m ${msg}`);
102
-
103
- function run(cmd, options = {}) {
104
- return execSync(cmd, { encoding: 'utf8', stdio: 'pipe', ...options }).trim();
105
- }
106
-
107
- function shellQuote(s) {
108
- const raw = String(s);
109
- if (process.platform === 'win32') return `"${raw.replace(/"/g, '\\"')}"`;
110
- return `'${raw.replace(/'/g, `'\\''`)}'`;
111
- }
112
-
113
- function updateCodexMcpToml(toml, mcpUrl) {
114
- const blockRe = /(^|\n)(\[mcp_servers\.rdc-skills\]\n)([\s\S]*?)(?=\n\[|\s*$)/;
115
- const desiredLine = `url = '${mcpUrl}'`;
116
- if (blockRe.test(toml)) {
117
- return toml.replace(blockRe, (match, prefix, header, body) => {
118
- if (/^url\s*=.*$/m.test(body)) {
119
- return `${prefix}${header}${body.replace(/^url\s*=.*$/m, desiredLine)}`;
120
- }
121
- const trimmed = body.replace(/\s*$/, '');
122
- return `${prefix}${header}${trimmed}${trimmed ? '\n' : ''}${desiredLine}\n`;
123
- });
124
- }
125
- return toml.replace(/\s*$/, '\n') + `\n[mcp_servers.rdc-skills]\n${desiredLine}\n`;
126
- }
127
-
128
- function selfTestCodexMcpToml() {
129
- const url = 'https://rdc-skills.regendevcorp.com/mcp';
130
- const stale = "[mcp_servers.clauth]\nurl = 'https://clauth.regendevcorp.com/mcp'\n\n[mcp_servers.rdc-skills]\nurl = 'https://rdc-skills.dev.regendevcorp.com/mcp'\n\n[mcp_servers.web-research]\nurl = 'https://research.regendevcorp.com/mcp'\n";
131
- const updated = updateCodexMcpToml(stale, url);
132
- if (!updated.includes(`url = '${url}'`)) throw new Error('did not write production rdc-skills URL');
133
- if (updated.includes('rdc-skills.dev.regendevcorp.com')) throw new Error('stale dev URL survived');
134
- if (!updated.includes('[mcp_servers.clauth]') || !updated.includes('[mcp_servers.web-research]')) throw new Error('neighbor MCP blocks were damaged');
135
-
136
- const missing = updateCodexMcpToml("[mcp_servers.clauth]\nurl = 'https://clauth.regendevcorp.com/mcp'\n", url);
137
- if (!missing.includes('[mcp_servers.rdc-skills]')) throw new Error('missing block was not appended');
138
-
139
- const noUrl = updateCodexMcpToml('[mcp_servers.rdc-skills]\nstartup_timeout_sec = 30\n', url);
140
- if (!noUrl.includes("startup_timeout_sec = 30") || !noUrl.includes(`url = '${url}'`)) throw new Error('url-less block was not repaired');
141
-
142
- console.log('install-rdc-skills codex MCP TOML self-test — PASS');
143
- }
144
-
145
- if (args.includes('--self-test-codex-mcp-toml')) {
146
- try {
147
- selfTestCodexMcpToml();
148
- process.exit(0);
149
- } catch (e) {
150
- console.error(e.message || e);
151
- process.exit(1);
152
- }
153
- }
154
-
155
- // ── Filesystem helpers ────────────────────────────────────────────────────────
156
- function copyDir(src, dst, ext) {
157
- if (!fs.existsSync(src)) { warn(`Source not found: ${src}`); return 0; }
158
- fs.mkdirSync(dst, { recursive: true });
159
- const files = fs.readdirSync(src).filter(f => !ext || f.endsWith(ext));
160
- let count = 0;
161
- for (const f of files) {
162
- const s = path.join(src, f);
163
- if (fs.statSync(s).isFile()) { fs.copyFileSync(s, path.join(dst, f)); count++; }
164
- }
165
- return count;
166
- }
167
-
168
- function copyHookFiles(src, dst) {
169
- if (!fs.existsSync(src)) { warn(`Source not found: ${src}`); return 0; }
170
- fs.mkdirSync(dst, { recursive: true });
171
- const files = fs.readdirSync(src).filter(f => /\.(?:js|ps1)$/i.test(f));
172
- let count = 0;
173
- for (const f of files) {
174
- const s = path.join(src, f);
175
- if (fs.statSync(s).isFile()) { fs.copyFileSync(s, path.join(dst, f)); count++; }
176
- }
177
- return count;
178
- }
179
-
180
- function copyDirRecursive(src, dst) {
181
- if (!fs.existsSync(src)) return;
182
- fs.mkdirSync(dst, { recursive: true });
183
- for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
184
- const s = path.join(src, entry.name);
185
- const d = path.join(dst, entry.name);
186
- if (entry.isDirectory()) copyDirRecursive(s, d);
187
- else {
188
- // Atomic write (temp + rename) so a live Claude Code / Codex session never
189
- // reads a half-written skill file when the installer runs over a live box.
190
- const tmp = `${d}.tmp-${process.pid}`;
191
- fs.copyFileSync(s, tmp);
192
- fs.renameSync(tmp, d);
193
- }
194
- }
195
- }
196
-
197
- function copyMissingProjectGuides(projectRoot) {
198
- if (!projectRoot) return 0;
199
- const src = path.join(repoRoot, 'guides');
200
- const dst = path.join(projectRoot, '.rdc', 'guides');
201
- if (!fs.existsSync(src) || !fs.existsSync(dst)) return 0;
202
- let copied = 0;
203
- for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
204
- if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
205
- if (entry.name === 'rdc-skills-startup.md') continue; // installed only by --write-startup-blocks
206
- const target = path.join(dst, entry.name);
207
- if (fs.existsSync(target)) continue;
208
- fs.copyFileSync(path.join(src, entry.name), target);
209
- copied++;
210
- }
211
- return copied;
212
- }
213
-
214
- function readJson(p, fallback = {}) {
215
- if (!fs.existsSync(p)) return fallback;
216
- try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return fallback; }
217
- }
218
-
219
- function writeJson(p, data, indent = 2) {
220
- fs.mkdirSync(path.dirname(p), { recursive: true });
221
- fs.writeFileSync(p, JSON.stringify(data, null, indent));
222
- }
223
-
224
- function upsertManagedBlock(filePath, title, body) {
225
- const begin = `<!-- BEGIN RDC-SKILLS:${title} -->`;
226
- const end = `<!-- END RDC-SKILLS:${title} -->`;
227
- const block = `${begin}\n${body.trim()}\n${end}\n`;
228
- const current = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : '';
229
- const re = new RegExp(`${begin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\s\\S]*?${end.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\n?`, 'm');
230
- const next = re.test(current)
231
- ? current.replace(re, block)
232
- : `${current.replace(/\s*$/, '')}${current.trim() ? '\n\n' : ''}${block}`;
233
- if (next !== current) {
234
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
235
- fs.writeFileSync(filePath, next);
236
- return true;
237
- }
238
- return false;
239
- }
240
-
241
- function writeStartupBlocks(targetRoot, profile) {
242
- if (!targetRoot) return { wrote: 0, skipped: 'no-project-root' };
243
- const startupSrc = path.join(repoRoot, 'guides', 'rdc-skills-startup.md');
244
- const guidesDir = path.join(targetRoot, '.rdc', 'guides');
245
- const startupDst = path.join(guidesDir, 'rdc-skills-startup.md');
246
- fs.mkdirSync(guidesDir, { recursive: true });
247
- fs.copyFileSync(startupSrc, startupDst);
248
-
249
- let wrote = 1;
250
- const claudeBody = [
251
- '## RDC Skills',
252
- '',
253
- '@.rdc/guides/rdc-skills-startup.md',
254
- '',
255
- `Installed profile: \`${profile}\`.`,
256
- 'For `/rdc:*` work, follow `.rdc/guides/output-contract.md` and `.rdc/guides/engineering-behavior.md`.',
257
- ].join('\n');
258
- const agentsBody = [
259
- '## RDC Skills',
260
- '',
261
- 'Read `.rdc/guides/rdc-skills-startup.md` before using any `rdc:*` workflow.',
262
- `Installed profile: \`${profile}\`.`,
263
- 'For `/rdc:*` work, follow `.rdc/guides/output-contract.md` and `.rdc/guides/engineering-behavior.md`.',
264
- ].join('\n');
265
-
266
- if (upsertManagedBlock(path.join(targetRoot, 'CLAUDE.md'), 'STARTUP', claudeBody)) wrote++;
267
- if (upsertManagedBlock(path.join(targetRoot, 'AGENTS.md'), 'STARTUP', agentsBody)) wrote++;
268
- return { wrote, skipped: null };
269
- }
270
-
271
- // ── Frontmatter parser ────────────────────────────────────────────────────────
272
- function readFrontmatter(filePath) {
273
- try {
274
- const content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
275
- const match = content.match(/^---\n([\s\S]*?)\n---/);
276
- if (!match) return {};
277
- const fm = {};
278
- let key = null, multiline = false, multilineVal = '';
279
- for (const line of match[1].split('\n')) {
280
- if (multiline) {
281
- if (/^\s+/.test(line)) { multilineVal += ' ' + line.trim(); continue; }
282
- fm[key] = multilineVal.trim();
283
- multiline = false;
284
- }
285
- const kv = line.match(/^(\w+):\s*(>-|>)?\s*(.*)?$/);
286
- if (!kv) continue;
287
- key = kv[1];
288
- if (kv[2]) { multiline = true; multilineVal = kv[3] || ''; }
289
- else fm[key] = kv[3] || '';
290
- }
291
- if (multiline && key) fm[key] = multilineVal.trim();
292
- return fm;
293
- } catch { return {}; }
294
- }
295
-
296
- // ── Plugin cache builder (shared between CLI + Cowork) ────────────────────────
297
- function buildPluginCache(cacheDir, version, gitSha) {
298
- fs.mkdirSync(cacheDir, { recursive: true });
299
- for (const item of ['.claude-plugin', 'commands', 'skills', 'guides', 'hooks', 'package.json', 'README.md']) {
300
- const src = path.join(repoRoot, item);
301
- if (!fs.existsSync(src)) continue;
302
- const dst = path.join(cacheDir, item);
303
- if (fs.statSync(src).isDirectory()) copyDirRecursive(src, dst);
304
- else fs.copyFileSync(src, dst);
305
- }
306
- }
307
-
308
- function getPm2Process(name) {
309
- try {
310
- const list = JSON.parse(run('pm2 jlist'));
311
- return Array.isArray(list) ? list.find((p) => p.name === name) || null : null;
312
- } catch {
313
- return null;
314
- }
315
- }
316
-
317
- function isGlobalNpmRdcSkillsPath(scriptPath) {
318
- if (!scriptPath) return false;
319
- const normalized = scriptPath.replace(/\\/g, '/').toLowerCase();
320
- return normalized.includes('/node_modules/@lifeaitools/rdc-skills/');
321
- }
322
-
323
- function packageRootFromMcpScript(scriptPath) {
324
- return scriptPath ? path.resolve(path.dirname(scriptPath), '..') : null;
325
- }
326
-
327
- function readInstalledPackageVersion(scriptPath) {
328
- const packageRoot = packageRootFromMcpScript(scriptPath);
329
- if (!packageRoot) return null;
330
- return readJson(path.join(packageRoot, 'package.json'), {}).version || null;
331
- }
332
-
333
- function collectFilesRecursive(baseDir, rel, out) {
334
- const full = path.join(baseDir, rel);
335
- if (!fs.existsSync(full)) return;
336
- const stat = fs.statSync(full);
337
- if (stat.isDirectory()) {
338
- for (const entry of fs.readdirSync(full).sort()) {
339
- collectFilesRecursive(baseDir, path.join(rel, entry), out);
340
- }
341
- return;
342
- }
343
- if (stat.isFile()) out.push(rel.replace(/\\/g, '/'));
344
- }
345
-
346
- function hashInstallSurface(root) {
347
- if (!root || !fs.existsSync(root)) return null;
348
- const rels = [];
349
- for (const rel of ['.claude-plugin', 'commands', 'guides', 'skills', 'package.json', 'README.md']) {
350
- collectFilesRecursive(root, rel, rels);
351
- }
352
- const hash = crypto.createHash('sha256');
353
- for (const rel of rels.sort()) {
354
- hash.update(rel);
355
- hash.update('\0');
356
- hash.update(fs.readFileSync(path.join(root, rel)));
357
- hash.update('\0');
358
- }
359
- return hash.digest('hex');
360
- }
361
-
362
- function syncGlobalMcpInstall(version) {
363
- const proc = getPm2Process(MCP_NAME);
364
- if (!proc) {
365
- info('[2.9] MCP pkg — PM2 process not registered yet; start handled below');
366
- return;
367
- }
368
-
369
- const scriptPath = proc.pm2_env?.pm_exec_path || proc.pm_exec_path || '';
370
- if (!isGlobalNpmRdcSkillsPath(scriptPath)) {
371
- info(`[2.9] MCP pkg — PM2 uses source checkout, not global npm (${scriptPath || 'unknown path'})`);
372
- return;
373
- }
374
-
375
- const installedVersion = readInstalledPackageVersion(scriptPath);
376
- const packageRoot = packageRootFromMcpScript(scriptPath);
377
- const sourceHash = hashInstallSurface(repoRoot);
378
- const installedHash = hashInstallSurface(packageRoot);
379
- if (installedVersion === version && sourceHash && installedHash && sourceHash === installedHash) {
380
- ok(`[2.9] MCP pkg — global ${NPM_PACKAGE}@${version} already matches source`);
381
- return;
382
- }
383
-
384
- let stopped = false;
385
- let installed = false;
386
- try {
387
- const reason = installedVersion === version ? 'same version, source changed' : `${installedVersion || '?'} → ${version}`;
388
- info(`[2.9] MCP pkg — updating global ${NPM_PACKAGE} (${reason}) from local source`);
389
- run(`pm2 stop ${MCP_NAME}`);
390
- stopped = true;
391
- run(`npm install -g ${shellQuote(repoRoot)}`);
392
- installed = true;
393
- ok(`[2.9] MCP pkg — installed global ${NPM_PACKAGE}@${version}`);
394
- } catch (e) {
395
- warn(`[2.9] MCP pkg — global install failed (${String(e.message || e).split('\n')[0]})`);
396
- if (String(e.message || '').includes('EBUSY')) {
397
- info(' PM2 was stopped first; if EBUSY persists, another Node/npm process still holds the package directory.');
398
- }
399
- } finally {
400
- if (stopped) {
401
- try {
402
- run(`pm2 restart ${MCP_NAME} --update-env`, { env: { ...process.env, PORT: MCP_PORT } });
403
- ok(`[2.9] MCP pkg — pm2 restarted ${MCP_NAME}${installed ? '' : ' (previous install restored)'}`);
404
- } catch (e) {
405
- warn(`[2.9] MCP pkg — pm2 restart failed (${String(e.message || e).split('\n')[0]})`);
406
- }
407
- }
408
- }
409
- }
410
-
411
- function callLocalMcpTool(name, args = {}) {
412
- return new Promise((resolve, reject) => {
413
- const payload = JSON.stringify({
414
- jsonrpc: '2.0',
415
- id: 1,
416
- method: 'tools/call',
417
- params: { name, arguments: args },
418
- });
419
- const req = http.request({
420
- hostname: '127.0.0.1',
421
- port: Number(MCP_PORT),
422
- path: '/mcp',
423
- method: 'POST',
424
- headers: {
425
- 'Content-Type': 'application/json',
426
- 'Accept': 'application/json, text/event-stream',
427
- 'Content-Length': Buffer.byteLength(payload),
428
- },
429
- timeout: 10000,
430
- }, (res) => {
431
- let body = '';
432
- res.setEncoding('utf8');
433
- res.on('data', (chunk) => { body += chunk; });
434
- res.on('end', () => {
435
- try {
436
- const dataLine = body.split(/\r?\n/).find((line) => line.startsWith('data: '));
437
- const envelope = JSON.parse(dataLine ? dataLine.slice(6) : body);
438
- const text = envelope?.result?.content?.[0]?.text;
439
- resolve(text ? JSON.parse(text) : envelope);
440
- } catch (e) {
441
- reject(new Error(`invalid MCP response: ${e.message}`));
442
- }
443
- });
444
- });
445
- req.on('timeout', () => req.destroy(new Error('MCP request timed out')));
446
- req.on('error', reject);
447
- req.write(payload);
448
- req.end();
449
- });
450
- }
451
-
452
- async function verifyLiveMcpCatalogFresh() {
453
- const proc = getPm2Process(MCP_NAME);
454
- if (!proc) {
455
- warn('[7.5] MCP catalog — skipped (PM2 process not registered)');
456
- return;
457
- }
458
- const sourceSkills = fs.readdirSync(path.join(repoRoot, 'skills'), { withFileTypes: true })
459
- .filter((entry) => entry.isDirectory() && fs.existsSync(path.join(repoRoot, 'skills', entry.name, 'SKILL.md')))
460
- .map((entry) => entry.name)
461
- .sort();
462
- const catalog = await callLocalMcpTool('rdc_skill_list', {});
463
- const rows = Array.isArray(catalog)
464
- ? catalog
465
- : Array.isArray(catalog?.skills) ? catalog.skills
466
- : Array.isArray(catalog?.results) ? catalog.results
467
- : [];
468
- const liveNames = new Set(rows.map((row) => row.name));
469
- const missing = sourceSkills.filter((name) => !liveNames.has(name));
470
- if (missing.length > 0 || rows.length < sourceSkills.length) {
471
- throw new Error(`live MCP catalog stale: source=${sourceSkills.length}, live=${rows.length}, missing=${missing.join(', ') || '(count mismatch)'}`);
472
- }
473
- ok(`[7.5] MCP catalog — ${rows.length} live skill(s), source catalog fresh`);
474
- }
475
-
476
- // ── User-skills cleanup ───────────────────────────────────────────────────────
477
- // Older installer versions wrote skill files directly to ~/.claude/skills/user/.
478
- // Claude Code loads that directory AND the plugin cache, so any rdc skills left
479
- // there produce duplicate registrations and break the resolver.
480
- // This function nukes any entry whose frontmatter name starts with "rdc:".
481
- // Scans BOTH the immediate dir and nested .md files (e.g. `user/skill.md`,
482
- // `user/rdc-build/SKILL.md`) so pre-plugin orphans are caught regardless of
483
- // naming convention.
484
- function cleanUserSkills(userSkillsDir) {
485
- if (!fs.existsSync(userSkillsDir)) return 0;
486
- let removed = 0;
487
- for (const entry of fs.readdirSync(userSkillsDir, { withFileTypes: true })) {
488
- const candidate = path.join(userSkillsDir, entry.name);
489
- if (entry.isDirectory()) {
490
- // Subdir form: <name>/SKILL.md or <name>/skill.md
491
- let skillFile = null;
492
- for (const sf of ['SKILL.md', 'skill.md']) {
493
- const p = path.join(candidate, sf);
494
- if (fs.existsSync(p)) { skillFile = p; break; }
495
- }
496
- if (!skillFile) continue;
497
- const fm = readFrontmatter(skillFile);
498
- if (fm.name && fm.name.startsWith('rdc:')) {
499
- try { fs.rmSync(candidate, { recursive: true, force: true }); removed++; } catch {}
500
- }
501
- } else if (entry.isFile() && entry.name.endsWith('.md')) {
502
- // ANY .md file at this level — including skill.md / SKILL.md / README.md
503
- // if their frontmatter declares an rdc:* skill. A previous version skipped
504
- // those names; that left an orphan rdc:build copy at user/skill.md which
505
- // registered as a duplicate "user" skill.
506
- const fm = readFrontmatter(candidate);
507
- if (fm.name && fm.name.startsWith('rdc:')) {
508
- try { fs.unlinkSync(candidate); removed++; } catch {}
509
- }
510
- }
511
- }
512
- return removed;
513
- }
514
-
515
- // Scrub legacy rdc orphans from ~/.claude/skills/ (top-level), not just user/.
516
- // Some older installs landed flat skill files alongside the plugin tree.
517
- function cleanGlobalSkillsRoot(skillsDir) {
518
- if (!fs.existsSync(skillsDir)) return 0;
519
- let removed = 0;
520
- for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
521
- if (entry.name === 'user') continue; // handled separately
522
- const candidate = path.join(skillsDir, entry.name);
523
- if (entry.isFile() && entry.name.endsWith('.md')) {
524
- const fm = readFrontmatter(candidate);
525
- if (fm.name && fm.name.startsWith('rdc:')) {
526
- try { fs.unlinkSync(candidate); removed++; } catch {}
527
- }
528
- }
529
- }
530
- return removed;
531
- }
532
-
533
- // ── Stale hook cleanup ────────────────────────────────────────────────────────
534
- // Remove ONLY explicitly orphaned hook files — hooks that were previously shipped
535
- // by rdc-skills and have since been removed from the project.
536
- // NEVER use "not in source = remove" logic: most hooks in ~/.claude/hooks/ are
537
- // not managed by rdc-skills (they come from other plugins or were written directly).
538
- function cleanStaleHooks(hooksDstDir) {
539
- if (!fs.existsSync(hooksDstDir)) return 0;
540
- // Explicit orphan list — add entries here when a hook is intentionally removed.
541
- // Format: filename that should be deleted if it still exists.
542
- const ORPHANED_HOOKS = [
543
- 'verify-rdc-skills.js', // removed in v0.9.7 — was checking for old flat-file format
544
- ];
545
- let removed = 0;
546
- for (const f of ORPHANED_HOOKS) {
547
- const p = path.join(hooksDstDir, f);
548
- if (fs.existsSync(p)) {
549
- try { fs.unlinkSync(p); removed++; } catch {}
550
- }
551
- }
552
- return removed;
553
- }
554
-
555
- // Project-local hookify docs were a temporary workaround for work item exit
556
- // enforcement. The plugin hook is now authoritative, so installs clean those
557
- // local shims from known RDC workspaces instead of leaving two gates to drift.
558
- function cleanProjectHookifyShims(projectRoot) {
559
- if (!projectRoot) return 0;
560
- const hookifyDir = path.join(projectRoot, '.claude');
561
- if (!fs.existsSync(hookifyDir)) return 0;
562
- const stale = [
563
- 'hookify.work-item-done-gate-bash.local.md',
564
- 'hookify.work-item-done-gate-mcp.local.md',
565
- 'hookify.work-item-review-gate-bash.local.md',
566
- 'hookify.work-item-review-gate-mcp.local.md',
567
- ];
568
- let removed = 0;
569
- for (const f of stale) {
570
- const p = path.join(hookifyDir, f);
571
- if (fs.existsSync(p)) {
572
- try { fs.unlinkSync(p); removed++; } catch {}
573
- }
574
- }
575
- return removed;
576
- }
577
-
578
- // ── Cache flush helper ────────────────────────────────────────────────────────
579
- // Only `latest/` is ever kept. Earlier versions wrote BOTH `<version>/` and
580
- // `latest/`, which caused the plugin loader to scan and register every rdc:*
581
- // skill twice (once per directory). The fix is permanent single-dir layout.
582
- function flushOldCaches(cacheBase /* keepVersion intentionally unused */) {
583
- if (!fs.existsSync(cacheBase)) return 0;
584
- let flushed = 0;
585
- for (const entry of fs.readdirSync(cacheBase)) {
586
- if (entry === 'latest') continue;
587
- try {
588
- fs.rmSync(path.join(cacheBase, entry), { recursive: true, force: true });
589
- flushed++;
590
- } catch {}
591
- }
592
- return flushed;
593
- }
594
-
595
- // ── Step 2: CLI plugin registration (→ ~/.claude/plugins/) ───────────────────
596
- function registerCLI(version, gitSha) {
597
- const pluginDir = path.join(claudeHome, 'plugins');
598
- const mktDir = path.join(pluginDir, 'marketplaces', MARKETPLACE);
599
- const mktPlugDir = path.join(mktDir, '.claude-plugin');
600
- const cacheBase = path.join(pluginDir, 'cache', MARKETPLACE, 'rdc-skills');
601
- const latestDir = path.join(cacheBase, 'latest');
602
-
603
- // 1. Marketplace manifest
604
- fs.mkdirSync(mktPlugDir, { recursive: true });
605
- fs.copyFileSync(path.join(repoRoot, '.claude-plugin', 'marketplace.json'), path.join(mktPlugDir, 'marketplace.json'));
606
-
607
- // 2. known_marketplaces.json
608
- const kmpPath = path.join(pluginDir, 'known_marketplaces.json');
609
- const knownMp = readJson(kmpPath);
610
- knownMp[MARKETPLACE] = { source: { source: 'github', repo: 'LIFEAI/rdc-skills' }, installLocation: mktDir, lastUpdated: new Date().toISOString() };
611
- writeJson(kmpPath, knownMp, 4);
612
-
613
- // 3. Flush every cache dir except `latest/`, then rewrite `latest/`. We
614
- // intentionally do NOT keep a versioned dir — the plugin loader registers
615
- // every dir it finds, so two dirs = duplicate skills.
616
- const flushed = flushOldCaches(cacheBase);
617
- if (flushed > 0) info(` flushed : ${flushed} stale cache dir(s)`);
618
- if (fs.existsSync(latestDir)) fs.rmSync(latestDir, { recursive: true, force: true });
619
- buildPluginCache(latestDir, version, gitSha);
620
-
621
- // 4. installed_plugins.json — register 'latest/' as stable installPath so
622
- // re-installs overwrite in-place rather than creating orphaned version dirs.
623
- // Open terminals that re-read installed_plugins.json mid-session will pick
624
- // up the updated path; otherwise a terminal restart is needed.
625
- const ipPath = path.join(pluginDir, 'installed_plugins.json');
626
- const installed = readJson(ipPath, { version: 2, plugins: {} });
627
- // Also overwrite whichever installPath the old entry had, so any open
628
- // terminal that already loaded that path sees fresh files.
629
- const oldEntries = installed.plugins[PLUGIN_KEY] || [];
630
- for (const old of oldEntries) {
631
- if (old.installPath && fs.existsSync(old.installPath) && old.installPath !== latestDir) {
632
- try { buildPluginCache(old.installPath, version, gitSha); } catch {}
633
- }
634
- }
635
- for (const key of Object.keys(installed.plugins || {})) {
636
- if (key.startsWith('rdc-skills@')) delete installed.plugins[key];
637
- }
638
- installed.plugins[PLUGIN_KEY] = [{ scope: 'user', installPath: latestDir, version, installedAt: new Date().toISOString(), lastUpdated: new Date().toISOString(), gitCommitSha: gitSha }];
639
- writeJson(ipPath, installed, 4);
640
-
641
- // 5. settings.json enabledPlugins
642
- const settings = readJson(settingsPath);
643
- if (!settings.enabledPlugins) settings.enabledPlugins = {};
644
- for (const key of Object.keys(settings.enabledPlugins)) {
645
- if (key.startsWith('rdc-skills@')) delete settings.enabledPlugins[key];
646
- }
647
- settings.enabledPlugins[PLUGIN_KEY] = true;
648
- writeJson(settingsPath, settings);
649
-
650
- return latestDir;
651
- }
652
-
653
- // ── Step 3: Cowork (Claude Desktop) registration ──────────────────────────────
654
- function findCoworkBases() {
655
- // Cowork stores per-workspace state at:
656
- // %LOCALAPPDATA%/Packages/Claude_*/LocalCache/Roaming/Claude/local-agent-mode-sessions/<workspace>/<device>/
657
- // Each has cowork_settings.json + cowork_plugins/
658
- const results = [];
659
-
660
- // Candidate MSIX package roots
661
- const localAppData = process.env.LOCALAPPDATA || '';
662
- const pkgsDir = path.join(localAppData, 'Packages');
663
- if (!fs.existsSync(pkgsDir)) return results;
664
-
665
- let claudePkg = null;
666
- for (const dir of fs.readdirSync(pkgsDir)) {
667
- if (/^Claude_/i.test(dir)) { claudePkg = path.join(pkgsDir, dir); break; }
668
- }
669
- if (!claudePkg) return results;
670
-
671
- const sessionsRoot = path.join(claudePkg, 'LocalCache', 'Roaming', 'Claude', 'local-agent-mode-sessions');
672
- if (!fs.existsSync(sessionsRoot)) return results;
673
-
674
- // Walk two levels: <workspace>/<device>/cowork_settings.json
675
- for (const ws of fs.readdirSync(sessionsRoot)) {
676
- const wsDir = path.join(sessionsRoot, ws);
677
- if (!fs.statSync(wsDir).isDirectory()) continue;
678
- for (const dev of fs.readdirSync(wsDir)) {
679
- const devDir = path.join(wsDir, dev);
680
- if (!fs.statSync(devDir).isDirectory()) continue;
681
- const settingsFile = path.join(devDir, 'cowork_settings.json');
682
- if (fs.existsSync(settingsFile)) {
683
- results.push({ dir: devDir, settingsFile });
684
- }
685
- }
686
- }
687
- return results;
688
- }
689
-
690
- function registerCowork(version, gitSha) {
691
- const bases = findCoworkBases();
692
- if (bases.length === 0) {
693
- warn('Cowork — Claude Desktop not found (MSIX package missing)');
694
- return 0;
695
- }
696
-
697
- for (const { dir, settingsFile } of bases) {
698
- const pluginsDir = path.join(dir, 'cowork_plugins');
699
- const cacheBase = path.join(pluginsDir, 'cache', MARKETPLACE, 'rdc-skills');
700
- const latestDir = path.join(cacheBase, 'latest');
701
- const mktDir = path.join(pluginsDir, 'marketplaces', MARKETPLACE);
702
- const mktPlugDir = path.join(mktDir, '.claude-plugin');
703
-
704
- // Marketplace manifest
705
- fs.mkdirSync(mktPlugDir, { recursive: true });
706
- fs.copyFileSync(path.join(repoRoot, '.claude-plugin', 'marketplace.json'), path.join(mktPlugDir, 'marketplace.json'));
707
-
708
- // known_marketplaces.json
709
- const kmpPath = path.join(pluginsDir, 'known_marketplaces.json');
710
- const knownMp = readJson(kmpPath);
711
- knownMp[MARKETPLACE] = { source: { source: 'github', repo: 'LIFEAI/rdc-skills' }, installLocation: mktDir, lastUpdated: new Date().toISOString() };
712
- writeJson(kmpPath, knownMp, 4);
713
-
714
- // Flush all non-latest caches and rewrite `latest/` only — single-dir layout
715
- flushOldCaches(cacheBase);
716
- if (fs.existsSync(latestDir)) fs.rmSync(latestDir, { recursive: true, force: true });
717
- buildPluginCache(latestDir, version, gitSha);
718
-
719
- // installed_plugins.json use stable 'latest/' path
720
- const ipPath = path.join(pluginsDir, 'installed_plugins.json');
721
- const installed = readJson(ipPath, { version: 2, plugins: {} });
722
- for (const key of Object.keys(installed.plugins || {})) {
723
- if (key.startsWith('rdc-skills@')) delete installed.plugins[key];
724
- }
725
- installed.plugins[PLUGIN_KEY] = [{ scope: 'user', installPath: latestDir, version, installedAt: new Date().toISOString(), lastUpdated: new Date().toISOString(), gitCommitSha: gitSha }];
726
- writeJson(ipPath, installed, 4);
727
-
728
- // cowork_settings.json enabledPlugins + extraKnownMarketplaces
729
- const settings = readJson(settingsFile);
730
- if (!settings.enabledPlugins) settings.enabledPlugins = {};
731
- for (const key of Object.keys(settings.enabledPlugins)) {
732
- if (key.startsWith('rdc-skills@')) delete settings.enabledPlugins[key];
733
- }
734
- settings.enabledPlugins[PLUGIN_KEY] = true;
735
- if (!settings.extraKnownMarketplaces) settings.extraKnownMarketplaces = {};
736
- settings.extraKnownMarketplaces[MARKETPLACE] = { source: { source: 'github', repo: 'LIFEAI/rdc-skills' } };
737
- writeJson(settingsFile, settings);
738
- }
739
-
740
- return bases.length;
741
- }
742
-
743
- // ── Codex registration ( Codex skill dirs/rdc-*/) ───────────────────────────
744
- function addCodexTarget(targets, label, targetDir) {
745
- if (!targetDir) return;
746
- const resolved = path.resolve(targetDir);
747
- if (targets.some(t => t.targetDir.toLowerCase() === resolved.toLowerCase())) return;
748
- targets.push({ label, targetDir: resolved });
749
- }
750
-
751
- // Register the rdc-skills MCP endpoint at the USER/GLOBAL level for every client
752
- // so all Claude Code projects and all Codex sessions can reach the skills via MCP
753
- // (not just where a project .mcp.json exists). Idempotent + non-fatal. Mirrors the
754
- // existing clauth/codeflow entries exactly. claude.ai web still needs the one-time
755
- // connector add in its UI (no programmatic API).
756
- function registerMcpEndpoints() {
757
- const MCP_URL = 'https://rdc-skills.regendevcorp.com/mcp';
758
- const out = [];
759
-
760
- // Claude Code — user-level ~/.claude.json mcpServers (covers EVERY project).
761
- try {
762
- const claudeJson = path.join(os.homedir(), '.claude.json');
763
- if (fs.existsSync(claudeJson)) {
764
- const data = readJson(claudeJson);
765
- if (!data.mcpServers || typeof data.mcpServers !== 'object') data.mcpServers = {};
766
- const cur = data.mcpServers['rdc-skills'];
767
- if (!cur || cur.url !== MCP_URL) {
768
- data.mcpServers['rdc-skills'] = { type: 'http', url: MCP_URL };
769
- writeJson(claudeJson, data, 2);
770
- out.push('claude(~/.claude.json)');
771
- }
772
- }
773
- } catch (e) { out.push(`claude WARN:${e.message}`); }
774
-
775
- // Codex — ensure [mcp_servers.rdc-skills] exists and points at the live shared endpoint.
776
- try {
777
- const codexToml = path.join(os.homedir(), '.codex', 'config.toml');
778
- if (fs.existsSync(codexToml)) {
779
- const toml = fs.readFileSync(codexToml, 'utf8');
780
- const next = updateCodexMcpToml(toml, MCP_URL);
781
- if (next !== toml) {
782
- fs.writeFileSync(codexToml, next);
783
- out.push('codex(~/.codex/config.toml)');
784
- }
785
- }
786
- } catch (e) { out.push(`codex WARN:${e.message}`); }
787
-
788
- return out;
789
- }
790
-
791
- function findCodexTargets() {
792
- const targets = [];
793
- if (codexRoot) {
794
- addCodexTarget(targets, 'project .agents', path.join(codexRoot, '.agents', 'skills', 'user'));
795
- }
796
- if (explicitCodexSkillDir) {
797
- addCodexTarget(targets, 'explicit', explicitCodexSkillDir);
798
- }
799
-
800
- const codexHomeSkills = path.join(os.homedir(), '.codex', 'skills');
801
- if (fs.existsSync(codexHomeSkills)) {
802
- addCodexTarget(targets, 'global .codex', codexHomeSkills);
803
- }
804
-
805
- const globalAgentSkills = path.join(os.homedir(), '.agents', 'skills');
806
- if (fs.existsSync(globalAgentSkills)) {
807
- addCodexTarget(targets, 'global .agents', globalAgentSkills);
808
- }
809
-
810
- return targets;
811
- }
812
-
813
- function registerCodexTarget(targetDir) {
814
- fs.mkdirSync(targetDir, { recursive: true });
815
-
816
- // Clean: remove dirs that are rdc skills — by prefix OR by frontmatter name
817
- let removed = 0;
818
- for (const entry of fs.readdirSync(targetDir, { withFileTypes: true })) {
819
- if (!entry.isDirectory()) continue;
820
- const candidate = path.join(targetDir, entry.name);
821
- if (/^rdc-/.test(entry.name)) {
822
- fs.rmSync(candidate, { recursive: true, force: true });
823
- removed++;
824
- } else {
825
- const fm = readFrontmatter(path.join(candidate, 'SKILL.md'));
826
- if (fm.name && fm.name.startsWith('rdc:')) {
827
- fs.rmSync(candidate, { recursive: true, force: true });
828
- removed++;
829
- }
830
- }
831
- }
832
-
833
- // Copy: each source skill dir that has a SKILL.md → rdc-<name>/
834
- const skillsSrc = path.join(repoRoot, 'skills');
835
- let copied = 0;
836
- for (const entry of fs.readdirSync(skillsSrc, { withFileTypes: true })) {
837
- if (!entry.isDirectory()) continue;
838
- const skillFile = path.join(skillsSrc, entry.name, 'SKILL.md');
839
- if (!fs.existsSync(skillFile)) continue;
840
- const dst = path.join(targetDir, `rdc-${entry.name}`);
841
- copyDirRecursive(path.join(skillsSrc, entry.name), dst);
842
- copied++;
843
- }
844
-
845
- return { removed, copied };
846
- }
847
-
848
- // ── Step 6: Zip for claude.ai / distribution ─────────────────────────────────
849
- function buildZip(version) {
850
- const distDir = path.join(repoRoot, 'dist');
851
- fs.mkdirSync(distDir, { recursive: true });
852
- const zipPath = path.join(distDir, `rdc-skills-plugin-v${version}.zip`);
853
-
854
- // Remove old zips
855
- if (fs.existsSync(distDir)) {
856
- for (const f of fs.readdirSync(distDir)) {
857
- if (f.startsWith('rdc-skills-plugin') && f.endsWith('.zip')) {
858
- fs.unlinkSync(path.join(distDir, f));
859
- }
860
- }
861
- }
862
-
863
- // Use PowerShell Compress-Archive on Windows, zip on Unix
864
- const items = ['.claude-plugin', 'commands', 'skills', 'guides', 'hooks', 'package.json', 'README.md']
865
- .filter(i => fs.existsSync(path.join(repoRoot, i)));
866
-
867
- try {
868
- if (process.platform === 'win32') {
869
- // Stage to a temp dir so we get a clean zip root
870
- const tmp = path.join(os.tmpdir(), `rdc-skills-zip-${Date.now()}`);
871
- fs.mkdirSync(tmp, { recursive: true });
872
- for (const item of items) {
873
- const src = path.join(repoRoot, item);
874
- const dst = path.join(tmp, item);
875
- if (fs.statSync(src).isDirectory()) copyDirRecursive(src, dst);
876
- else fs.copyFileSync(src, dst);
877
- }
878
- // Try pwsh first (PowerShell 7+), fall back to Windows PowerShell
879
- const psExe = fs.existsSync('C:\\Program Files\\PowerShell\\7\\pwsh.exe')
880
- ? 'C:\\Program Files\\PowerShell\\7\\pwsh.exe'
881
- : 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe';
882
- execSync(
883
- `"${psExe}" -NoProfile -NonInteractive -WindowStyle Hidden -Command "Compress-Archive -Path '${tmp}\\*' -DestinationPath '${zipPath}' -Force"`,
884
- { stdio: 'pipe' }
885
- );
886
- fs.rmSync(tmp, { recursive: true, force: true });
887
- } else {
888
- const itemList = items.join(' ');
889
- execSync(`zip -r "${zipPath}" ${itemList}`, { cwd: repoRoot, stdio: 'pipe' });
890
- }
891
- return zipPath;
892
- } catch (e) {
893
- warn(`Zip failed: ${e.message}`);
894
- return null;
895
- }
896
- }
897
-
898
- // ── Hook config ───────────────────────────────────────────────────────────────
899
- function buildHooksConfig(hooksDir, profile = 'core') {
900
- const base = hooksDir.replace(/\\/g, '/');
901
- const cmd = (file, msg) => {
902
- const command = process.platform === 'win32'
903
- ? `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File "${base}/run-hidden-hook.ps1" "${base}/${file}"`
904
- : `node "${base}/${file}"`;
905
- const entry = { type: 'command', command };
906
- if (msg) entry.statusMessage = msg;
907
- return entry;
908
- };
909
- const config = {
910
- UserPromptExpansion: [{ hooks: [
911
- cmd('rdc-invocation-marker.js', 'Marking RDC slash command...'),
912
- ]}],
913
- UserPromptSubmit: [{ hooks: [
914
- cmd('rdc-invocation-marker.js', 'Marking RDC prompt...'),
915
- ]}],
916
- PreToolUse: [
917
- { hooks: [
918
- cmd('foreground-process-gate.js', 'Checking foreground process policy...'),
919
- ]},
920
- { matcher: 'Bash', hooks: [
921
- cmd('require-work-item-on-commit.js'),
922
- ]},
923
- ],
924
- PostToolUse: [
925
- { hooks: [
926
- cmd('check-services.js'),
927
- ]},
928
- { matcher: 'Bash', hooks: [
929
- cmd('require-work-item-on-commit.js', 'Capturing commit SHA for work item...'),
930
- ]},
931
- ],
932
- Stop: [{ hooks: [
933
- cmd('rdc-output-contract-gate.js', 'Checking RDC output contract...'),
934
- cmd('post-work-check.js', 'Checking for undocumented work...'),
935
- ]}],
936
- };
937
-
938
- if (profile === 'lifeai') {
939
- config.SessionStart = [{ hooks: [
940
- cmd('check-rdc-environment.js', 'Checking RDC skills runtime...'),
941
- cmd('check-cwd.js'),
942
- cmd('check-stale-work-items.js', 'Checking for stale work items...'),
943
- // Truth Gate 3.0 Layer 6 — gate watchdog (ADVISORY; SessionStart cannot block).
944
- cmd('gate-watchdog-selfcheck.js', 'Truth Gate watchdog: verifying gate registration...'),
945
- ]}];
946
- config.PreToolUse[0].hooks.push(
947
- cmd('work-item-exit-gate.js', 'Checking work item exit gates...'),
948
- );
949
- // Truth Gate 3.0 Layer 5 — harness completion gates. Both blocking hooks are
950
- // FLAG-GATED, default OFF (no-op until the env/DB flag is flipped at deploy),
951
- // so registering them does NOT disrupt the in-flight build session.
952
- config.TaskCompleted = [{ hooks: [
953
- cmd('task-completed-gate.js', 'Truth Gate: verifying task closure...'),
954
- ]}];
955
- config.PostToolBatch = [{ hooks: [
956
- cmd('post-tool-batch-gate.js', 'Truth Gate: checking build-wave worktree bases...'),
957
- ]}];
958
- config.PreCompact = [{ hooks: [
959
- cmd('precompact-log.js'),
960
- ]}];
961
- config.PostCompact = [{ hooks: [
962
- cmd('postcompact-log.js'),
963
- cmd('restart-brief.js', 'Writing restart brief...'),
964
- ]}];
965
- config.Stop[0].hooks.unshift(
966
- cmd('rate-limit-retry.js', 'Checking for rate limits...'),
967
- );
968
- config.Stop[0].hooks.push(
969
- cmd('no-stop-open-epics.js', 'Checking for open epics...'),
970
- );
971
- }
972
-
973
- return config;
974
- }
975
-
976
- // ── MCP server registration (non-fatal) ───────────────────────────────────────
977
- // Ensures the rdc-skills MCP deps are installed, registers/starts the local MCP
978
- // under PM2 as `rdc-skills-mcp` on PORT=3110, and prints the claude.ai connector
979
- // line. Every failure here WARNs — it must never abort the installer.
980
- function registerMcpServer() {
981
- const binPath = path.join(repoRoot, 'bin', 'rdc-skills-mcp.mjs');
982
- const connector = 'https://rdc-skills.regendevcorp.com/mcp';
983
-
984
- try {
985
- // (a) ensure MCP runtime deps are present (express, @modelcontextprotocol/sdk, yaml, zod)
986
- const needDeps = ['express', '@modelcontextprotocol/sdk', 'yaml', 'zod'].some((d) => {
987
- try { require.resolve(d, { paths: [repoRoot] }); return false; } catch { return true; }
988
- });
989
- if (needDeps) {
990
- try {
991
- execSync('npm install --no-audit --no-fund', { cwd: repoRoot, stdio: 'pipe' });
992
- ok('[7/7] MCP deps — installed');
993
- } catch (e) {
994
- warn(`[7/7] MCP deps — npm install failed (${e.message.split('\n')[0]}); install manually with \`npm install\``);
995
- }
996
- } else {
997
- ok('[7/7] MCP deps — already present');
998
- }
999
-
1000
- // (b) register/start under PM2 (tolerate pm2 missing)
1001
- let pm2Ok = false;
1002
- try { execSync('pm2 -v', { stdio: 'pipe' }); pm2Ok = true; } catch { pm2Ok = false; }
1003
-
1004
- if (!pm2Ok) {
1005
- warn('[7/7] MCP serverpm2 not found; start manually:');
1006
- info(` PORT=${MCP_PORT} pm2 start ${binPath} --name ${MCP_NAME}`);
1007
- } else {
1008
- let registered = false;
1009
- try {
1010
- const jlist = JSON.parse(execSync('pm2 jlist', { encoding: 'utf8', stdio: 'pipe' }));
1011
- registered = Array.isArray(jlist) && jlist.some((p) => p.name === MCP_NAME);
1012
- } catch {}
1013
- try {
1014
- if (registered) {
1015
- execSync(`pm2 restart ${MCP_NAME} --update-env`, { cwd: repoRoot, stdio: 'pipe', env: { ...process.env, PORT: MCP_PORT } });
1016
- ok(`[7/7] MCP server — pm2 restarted ${MCP_NAME} (PORT=${MCP_PORT})`);
1017
- } else {
1018
- execSync(`pm2 start "${binPath}" --name ${MCP_NAME}`, { cwd: repoRoot, stdio: 'pipe', env: { ...process.env, PORT: MCP_PORT } });
1019
- ok(`[7/7] MCP server — pm2 started ${MCP_NAME} (PORT=${MCP_PORT})`);
1020
- }
1021
- } catch (e) {
1022
- warn(`[7/7] MCP server — pm2 start/restart failed (${e.message.split('\n')[0]})`);
1023
- info(` PORT=${MCP_PORT} pm2 start ${binPath} --name ${MCP_NAME}`);
1024
- }
1025
- }
1026
-
1027
- // (c) print the claude.ai connector line
1028
- info(` claude.ai connector: ${connector} (Auth: none — URL is the shared secret)`);
1029
- } catch (e) {
1030
- warn(`[7/7] MCP server — skipped (${e.message.split('\n')[0]})`);
1031
- }
1032
- }
1033
-
1034
- // ── Preflight ─────────────────────────────────────────────────────────────────
1035
- function runPreflight() {
1036
- const nodeMajor = parseInt(process.versions.node.split('.')[0], 10);
1037
- if (nodeMajor < 18) { fail(`Node.js >= 18 required — found v${process.versions.node}`); process.exit(1); }
1038
- ok(`Node.js v${process.versions.node}`);
1039
- try {
1040
- execSync('curl -s --max-time 2 http://127.0.0.1:52437/ping', { stdio: 'pipe' });
1041
- ok('clauth daemon is running');
1042
- } catch {
1043
- warn('clauth daemon not responding start it before using credential-dependent commands');
1044
- }
1045
- }
1046
-
1047
- // ── Commands listing ──────────────────────────────────────────────────────────
1048
- function listCommands() {
1049
- const cmdsDir = path.join(repoRoot, 'commands');
1050
- if (!fs.existsSync(cmdsDir)) return;
1051
- const files = fs.readdirSync(cmdsDir).filter(f => f.endsWith('.md')).sort();
1052
- const plugin = readJson(path.join(repoRoot, '.claude-plugin', 'plugin.json'), {});
1053
- const skillCount = Array.isArray(plugin.skills_meta)
1054
- ? plugin.skills_meta.length
1055
- : (plugin.skills_meta && typeof plugin.skills_meta === 'object' ? Object.keys(plugin.skills_meta).length : null);
1056
- console.log('');
1057
- if (skillCount !== null) {
1058
- console.log(` \x1b[32mAvailable MCP skills (${skillCount}) and /rdc:* command shorthands (${files.length}):\x1b[0m`);
1059
- console.log(' Use MCP tools rdc_skill_list, rdc_skill_search, and rdc_skill_get for the full skill catalog.');
1060
- } else {
1061
- console.log(` \x1b[32mAvailable /rdc:* command shorthands (${files.length}):\x1b[0m`);
1062
- }
1063
- console.log('');
1064
- const COL = 18;
1065
- for (const f of files) {
1066
- const name = 'rdc:' + f.replace(/\.md$/, '');
1067
- const fm = readFrontmatter(path.join(cmdsDir, f));
1068
- const desc = (fm.description || '').replace(/\n/g, ' ').replace(/\s+/g, ' ').trim();
1069
- const short = desc.length > 70 ? desc.slice(0, 70) + '…' : desc;
1070
- const pad = ' '.repeat(Math.max(1, COL - name.length));
1071
- console.log(` \x1b[36m/${name}\x1b[0m${pad}${short}`);
1072
- }
1073
- console.log('');
1074
- }
1075
-
1076
- // ── Migrate helper ────────────────────────────────────────────────────────────
1077
- async function runMigrate(projectRoot) {
1078
- const absRoot = path.resolve(projectRoot);
1079
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1080
- const ask = q => new Promise(resolve => rl.question(q, resolve));
1081
-
1082
- console.log('\n \x1b[35mrdc-skills — Migrate to .rdc/ Layout\x1b[0m\n');
1083
- console.log(` Project root: ${absRoot}\n`);
1084
-
1085
- const candidates = [
1086
- { src: 'docs/guides', dst: '.rdc/guides' }, { src: 'docs/plans', dst: '.rdc/plans' },
1087
- { src: 'docs/reports', dst: '.rdc/reports' }, { src: 'docs/research', dst: '.rdc/research' },
1088
- ].filter(c => fs.existsSync(path.join(absRoot, c.src)));
1089
-
1090
- if (candidates.length === 0) { info('No migratable directories found.'); rl.close(); return; }
1091
- console.log(' Found:');
1092
- candidates.forEach(c => console.log(` ${c.src}`));
1093
- console.log('');
1094
-
1095
- for (const c of candidates) {
1096
- const srcAbs = path.join(absRoot, c.src);
1097
- const dstAbs = path.join(absRoot, c.dst);
1098
- const ans = await ask(` Move ${c.src} → ${c.dst}? [Y/n]: `);
1099
- if (ans.toLowerCase() === 'n') { info(`Skipped ${c.src}`); continue; }
1100
- if (fs.existsSync(dstAbs)) {
1101
- warn(`${c.dst} exists — merging`);
1102
- for (const f of fs.readdirSync(srcAbs)) {
1103
- const sf = path.join(srcAbs, f), df = path.join(dstAbs, f);
1104
- if (!fs.existsSync(df)) fs.renameSync(sf, df);
1105
- else warn(` Skipped ${f} (exists in dst)`);
1106
- }
1107
- } else {
1108
- fs.mkdirSync(path.dirname(dstAbs), { recursive: true });
1109
- fs.renameSync(srcAbs, dstAbs);
1110
- }
1111
- ok(`Moved ${c.src} ${c.dst}`);
1112
- }
1113
- rl.close();
1114
- }
1115
-
1116
- // ── Main ──────────────────────────────────────────────────────────────────────
1117
- async function main() {
1118
- if (doMigrate) { await runMigrate(migratePath); return; }
1119
-
1120
- const bannerVersion = (readJson(path.join(repoRoot, 'package.json')).version || '?');
1121
- const bannerLine = `║ install-rdc-skills v${bannerVersion}`;
1122
- const bannerPadded = bannerLine.padEnd(41) + '║';
1123
- console.log('');
1124
- console.log(' \x1b[32m╔═══════════════════════════════════════╗\x1b[0m');
1125
- console.log(` \x1b[32m${bannerPadded}\x1b[0m`);
1126
- console.log(' \x1b[32m╚═══════════════════════════════════════╝\x1b[0m');
1127
- console.log('');
1128
- console.log(` CLAUDE_HOME : ${claudeHome}`);
1129
- console.log(` Plugin root : ${repoRoot}`);
1130
- console.log(` Profile : ${installProfile}${profileArg === 'auto' ? ' (auto)' : ''}`);
1131
- console.log('');
1132
-
1133
- if (!fs.existsSync(claudeHome)) {
1134
- fs.mkdirSync(claudeHome, { recursive: true });
1135
- warn(`CLAUDE_HOME did not exist — created ${claudeHome}`);
1136
- }
1137
- if (!fs.existsSync(settingsPath)) {
1138
- writeJson(settingsPath, {});
1139
- info(` created settings.json`);
1140
- }
1141
-
1142
- // 0. Pull latest
1143
- try {
1144
- const before = execSync('git rev-parse HEAD', { cwd: repoRoot, encoding: 'utf8', stdio: 'pipe' }).trim();
1145
- execSync('git pull --ff-only', { cwd: repoRoot, stdio: 'pipe' });
1146
- const after = execSync('git rev-parse HEAD', { cwd: repoRoot, encoding: 'utf8', stdio: 'pipe' }).trim();
1147
- ok(`[0/6] git pull — ${before === after ? after.slice(0, 7) : `${before.slice(0,7)} → ${after.slice(0,7)}`}`);
1148
- } catch {
1149
- warn('[0/6] git pull failed installing from local copy');
1150
- }
1151
-
1152
- // Read version + git SHA after pull so plugin caches and the MCP install do
1153
- // not stamp the pre-update checkout.
1154
- const pkg = readJson(path.join(repoRoot, 'package.json'));
1155
- const version = pkg.version || '0.7.0';
1156
- let gitSha = '';
1157
- try { gitSha = execSync('git rev-parse HEAD', { cwd: repoRoot, encoding: 'utf8', stdio: 'pipe' }).trim(); } catch {}
1158
-
1159
- // 0.5a. User-skills cleanup — remove any rdc: skills from ~/.claude/skills/user/
1160
- // (older installer versions wrote there; plugin cache is the only authoritative source)
1161
- {
1162
- const userSkillsDir = path.join(claudeHome, 'skills', 'user');
1163
- const purged = cleanUserSkills(userSkillsDir);
1164
- if (purged > 0) ok(`[0.5a] Skills cleanup — removed ${purged} stale rdc: skill(s) from skills/user/`);
1165
- // Also scan the parent ~/.claude/skills/ for any flat-file rdc orphans.
1166
- const skillsRoot = path.join(claudeHome, 'skills');
1167
- const rootPurged = cleanGlobalSkillsRoot(skillsRoot);
1168
- if (rootPurged > 0) ok(`[0.5a] Skills cleanup — removed ${rootPurged} stale rdc: file(s) from skills/`);
1169
- }
1170
-
1171
- // 0.5b. Stale hook cleanup — remove hooks we no longer ship
1172
- {
1173
- const staleRemoved = cleanStaleHooks(hooksDst);
1174
- if (staleRemoved > 0) ok(`[0.5b] Hook cleanup — removed ${staleRemoved} orphaned hook file(s)`);
1175
- const shimRemoved = cleanProjectHookifyShims(codexRoot);
1176
- if (shimRemoved > 0) ok(`[0.5b] Hook cleanup — removed ${shimRemoved} project-local work-item hookify shim(s)`);
1177
- }
1178
-
1179
- // 0.5. Legacy cleanup — remove old commands/rdc/ (pre-plugin-system format)
1180
- const legacyCommandsDir = path.join(claudeHome, 'commands', 'rdc');
1181
- if (fs.existsSync(legacyCommandsDir)) {
1182
- fs.rmSync(legacyCommandsDir, { recursive: true, force: true });
1183
- ok('[0.5] Cleanup removed legacy commands/rdc/');
1184
- }
1185
- // Also remove extraKnownMarketplaces.rdc-skills from settings.json to prevent
1186
- // Claude Code from syncing a directory-source marketplace back to known_marketplaces.json
1187
- // (which causes skills to load from both the cache AND the live source directory)
1188
- {
1189
- const st = readJson(settingsPath);
1190
- if (st.extraKnownMarketplaces && st.extraKnownMarketplaces[MARKETPLACE]) {
1191
- delete st.extraKnownMarketplaces[MARKETPLACE];
1192
- if (Object.keys(st.extraKnownMarketplaces).length === 0) delete st.extraKnownMarketplaces;
1193
- writeJson(settingsPath, st);
1194
- ok('[0.5] Cleanup — removed extraKnownMarketplaces.rdc-skills from settings.json');
1195
- }
1196
- }
1197
-
1198
- // 1. CLI registration
1199
- const cliCacheDir = registerCLI(version, gitSha);
1200
- ok(`[1/6] CLI plugin — ${PLUGIN_KEY} v${version}`);
1201
- info(` cache : ${cliCacheDir}`);
1202
-
1203
- // 2. Cowork registration
1204
- const coworkCount = registerCowork(version, gitSha);
1205
- if (coworkCount > 0) {
1206
- ok(`[2/6] Cowork — registered in ${coworkCount} workspace(s)`);
1207
- } else {
1208
- warn('[2/6] Cowork — no Desktop workspaces found (open Claude Desktop once to create them)');
1209
- }
1210
-
1211
- // 2.5. Codex registration
1212
- const codexTargets = findCodexTargets();
1213
- if (codexTargets.length > 0) {
1214
- let copiedTotal = 0;
1215
- let removedTotal = 0;
1216
- for (const target of codexTargets) {
1217
- const { removed, copied } = registerCodexTarget(target.targetDir);
1218
- copiedTotal += copied;
1219
- removedTotal += removed;
1220
- info(` ${target.label.padEnd(15)}: ${target.targetDir} (${copied} installed, ${removed} stale removed)`);
1221
- }
1222
- ok(`[2.5] Codex — ${copiedTotal} skill install(s), ${removedTotal} stale removed across ${codexTargets.length} target(s)`);
1223
- } else {
1224
- info('[2.5] Codex — skipped (no Codex skill dirs found; use --codex-root or --codex-skill-dir)');
1225
- }
1226
-
1227
- // 2.6. Register the rdc-skills MCP endpoint globally (Claude Code + Codex) so
1228
- // EVERY agent can reach the skills via MCP, not only where a project .mcp.json exists.
1229
- const mcpReg = registerMcpEndpoints();
1230
- if (mcpReg.length > 0) {
1231
- ok(`[2.6] MCP — registered rdc-skills endpoint: ${mcpReg.join(', ')}`);
1232
- } else {
1233
- info('[2.6] MCP rdc-skills endpoint already registered (claude + codex)');
1234
- }
1235
-
1236
- // If the live MCP is served from the global npm package, update that exact
1237
- // install before restarting PM2. Windows otherwise holds the package tree open
1238
- // and `npm install -g` can fail with EBUSY.
1239
- syncGlobalMcpInstall(version);
1240
-
1241
- // 2.7. Symlinks in regen-root/.claude/skills/ (FS MCP + claude.ai access)
1242
- if (codexRoot) {
1243
- const skillsLinkDir = path.join(codexRoot, '.claude', 'skills');
1244
- const skillsSrc = path.join(repoRoot, 'skills');
1245
- fs.mkdirSync(skillsLinkDir, { recursive: true });
1246
- let linked = 0;
1247
- for (const entry of fs.readdirSync(skillsSrc, { withFileTypes: true })) {
1248
- if (!entry.isDirectory()) continue;
1249
- if (!fs.existsSync(path.join(skillsSrc, entry.name, 'SKILL.md'))) continue;
1250
- const linkPath = path.join(skillsLinkDir, entry.name);
1251
- const target = path.join(skillsSrc, entry.name);
1252
- try {
1253
- if (fs.existsSync(linkPath) || (() => { try { fs.lstatSync(linkPath); return true; } catch { return false; } })()) {
1254
- fs.rmSync(linkPath, { recursive: true, force: true });
1255
- }
1256
- } catch {}
1257
- try {
1258
- if (process.platform === 'win32') {
1259
- fs.symlinkSync(target, linkPath, 'junction');
1260
- } else {
1261
- fs.symlinkSync(target, linkPath, 'dir');
1262
- }
1263
- linked++;
1264
- } catch {}
1265
- }
1266
- if (linked > 0) {
1267
- ok(`[2.7] Symlinks — ${linked} skill link(s) in ${skillsLinkDir}`);
1268
- } else {
1269
- info('[2.7] Symlinks — no rdc skill links created (skills may already be linked)');
1270
- }
1271
- const projectGuideCount = copyMissingProjectGuides(codexRoot);
1272
- if (projectGuideCount > 0) {
1273
- ok(`[2.8] Guides — ${projectGuideCount} missing guide(s) copied to ${path.join(codexRoot, '.rdc', 'guides')}`);
1274
- } else {
1275
- info('[2.8] Guides — project .rdc/guides already has base guide files or is absent');
1276
- }
1277
- } else {
1278
- info('[2.7] Symlinks — skipped (no codex root found)');
1279
- }
1280
-
1281
- // 3. Hook files
1282
- const hookCount = copyHookFiles(hooksSrc, hooksDst);
1283
- ok(`[3/6] Hook files — ${hookCount} file(s) → ${hooksDst}`);
1284
-
1285
- // 4. Hook wiring
1286
- if (skipHooks) {
1287
- info('[4/6] Hook wiring — skipped (--skip-hooks)');
1288
- } else {
1289
- const settings = readJson(settingsPath);
1290
- settings.hooks = buildHooksConfig(hooksDst, installProfile);
1291
- writeJson(settingsPath, settings);
1292
- ok(`[4/6] Hook wiring — ${settingsPath}`);
1293
- }
1294
-
1295
- // 4.5 Optional startup blocks
1296
- if (shouldWriteStartupBlocks) {
1297
- const startup = writeStartupBlocks(projectRoot, installProfile);
1298
- if (startup.skipped) warn(`[4.5] Startup — skipped (${startup.skipped})`);
1299
- else ok(`[4.5] Startup — wrote managed startup guide/block(s) under ${projectRoot}`);
1300
- } else {
1301
- info('[4.5] Startup — skipped (use --project-root <path> --write-startup-blocks)');
1302
- }
1303
-
1304
- // 5. Zip for claude.ai / distribution
1305
- const zipPath = buildZip(version);
1306
- if (zipPath) {
1307
- ok(`[5/6] Plugin zip — ${zipPath}`);
1308
- info(' claude.ai : optional plugin-upload artifact; MCP callers normally use the connector URL below');
1309
- } else {
1310
- warn('[5/6] Plugin zip — build failed (zip/powershell missing?)');
1311
- }
1312
-
1313
- // 6. Preflight
1314
- console.log('');
1315
- console.log(' \x1b[36mPreflight:\x1b[0m');
1316
- runPreflight();
1317
-
1318
- // 6.5 Post-install verification — duplication guard
1319
- console.log('');
1320
- console.log(' \x1b[36mPost-install verification:\x1b[0m');
1321
- let verifyFailed = false;
1322
- {
1323
- const cacheBase = path.join(claudeHome, 'plugins', 'cache', MARKETPLACE, 'rdc-skills');
1324
- const cacheDirs = fs.existsSync(cacheBase) ? fs.readdirSync(cacheBase) : [];
1325
- if (cacheDirs.length === 1 && cacheDirs[0] === 'latest') {
1326
- ok(`plugin cache : 1 dir (latest/)`);
1327
- } else {
1328
- fail(`plugin cache : expected exactly [latest/], found [${cacheDirs.join(', ')}]`);
1329
- verifyFailed = true;
1330
- }
1331
- const ipPath = path.join(claudeHome, 'plugins', 'installed_plugins.json');
1332
- const installed = readJson(ipPath, { plugins: {} });
1333
- const rdcEntries = installed.plugins[PLUGIN_KEY] || [];
1334
- if (rdcEntries.length === 1) {
1335
- ok(`installed_plugins: 1 entry for ${PLUGIN_KEY}`);
1336
- } else {
1337
- fail(`installed_plugins: expected 1 entry, found ${rdcEntries.length}`);
1338
- verifyFailed = true;
1339
- }
1340
- const userSkillsDir = path.join(claudeHome, 'skills', 'user');
1341
- const stillThere = fs.existsSync(userSkillsDir)
1342
- ? fs.readdirSync(userSkillsDir).filter(f => {
1343
- const p = path.join(userSkillsDir, f);
1344
- const skillFile = fs.statSync(p).isDirectory()
1345
- ? ['SKILL.md','skill.md'].map(s => path.join(p, s)).find(fs.existsSync)
1346
- : (f.endsWith('.md') ? p : null);
1347
- if (!skillFile) return false;
1348
- const fm = readFrontmatter(skillFile);
1349
- return fm.name && fm.name.startsWith('rdc:');
1350
- })
1351
- : [];
1352
- if (stillThere.length === 0) {
1353
- ok(`skills/user/ : no rdc: orphans`);
1354
- } else {
1355
- fail(`skills/user/ : still has rdc: orphans: ${stillThere.join(', ')}`);
1356
- verifyFailed = true;
1357
- }
1358
- }
1359
- if (verifyFailed) {
1360
- console.log('');
1361
- fail('Post-install verification FAILED duplicates or orphans remain. Investigate.');
1362
- process.exit(2);
1363
- }
1364
-
1365
- // 7. MCP server registration (non-fatal WARNs only, never aborts)
1366
- console.log('');
1367
- console.log(' \x1b[36mMCP server:\x1b[0m');
1368
- try { registerMcpServer(); } catch (e) { warn(`[7/7] MCP server — unexpected error (${e.message})`); }
1369
- try {
1370
- await verifyLiveMcpCatalogFresh();
1371
- } catch (e) {
1372
- fail(`[7.5] MCP catalog — ${e.message}`);
1373
- process.exit(2);
1374
- }
1375
-
1376
- // Done
1377
- console.log('');
1378
- console.log(' \x1b[32mDone!\x1b[0m');
1379
- console.log('');
1380
- console.log(' \x1b[33mNext steps:\x1b[0m');
1381
- console.log(' CLI : restart Claude Code — /rdc:status to verify');
1382
- console.log(' Cowork : restart Claude Desktop — /rdc:status in a new Cowork session');
1383
- console.log(' claude.ai : no plugin upload needed for MCP — use the connector URL above');
1384
- console.log(' dist/rdc-skills-plugin-v' + version + '.zip available if needed');
1385
- console.log('');
1386
- console.log(` Profile: ${installProfile}`);
1387
- if (installProfile === 'core') {
1388
- console.log(' Core hooks are portable: RDC output contract + foreground process + commit-message hygiene.');
1389
- console.log(' Project services are not provisioned. Configure work items, credentials, deploys, and project guides before using infrastructure-heavy skills.');
1390
- } else {
1391
- console.log(' LIFEAI hooks are active: regen-root cwd lock, Supabase work-item gates, clauth-aware checks, and overnight queue guard.');
1392
- }
1393
- console.log(' Startup blocks: run with --project-root <path> --write-startup-blocks to add managed CLAUDE.md/AGENTS.md sections.');
1394
-
1395
- listCommands();
1396
-
1397
- console.log(' Docs: https://github.com/LIFEAI/rdc-skills#readme');
1398
- console.log('');
1399
- }
1400
-
1401
- main().catch(e => { fail(e.message); process.exit(1); });
2
+ /**
3
+ * install-rdc-skills — registers rdc-skills on every Claude surface.
4
+ *
5
+ * Usage:
6
+ * node scripts/install-rdc-skills.js ← standard
7
+ * node scripts/install-rdc-skills.js --skip-hooks ← skip hook wiring
8
+ * node scripts/install-rdc-skills.js --profile core ← clean-box portable hooks
9
+ * node scripts/install-rdc-skills.js --profile lifeai ← LIFEAI/regen-root hooks
10
+ * node scripts/install-rdc-skills.js --claude-home <path> ← custom CLI home
11
+ * node scripts/install-rdc-skills.js --codex-root <path> ← also install to .agents/skills/user/
12
+ * node scripts/install-rdc-skills.js --codex-skill-dir <path> ← also install to a Codex skill dir
13
+ * node scripts/install-rdc-skills.js --project-root <path> --write-startup-blocks
14
+ * node scripts/install-rdc-skills.js --migrate <path> ← migrate docs/ → .rdc/
15
+ *
16
+ * What it does:
17
+ * 1. git pull (latest commands + guides)
18
+ * 2. CLI plugin — registers in ~/.claude/plugins/ + settings.json
19
+ * 3. Cowork — registers in Desktop cowork_plugins/ + cowork_settings.json
20
+ * 3.5 Codex — copies skills to detected Codex skill dirs
21
+ * 4. Hook files — copies hooks/*.js → ~/.claude/hooks/
22
+ * 5. Hook wiring — wires hooks into ~/.claude/settings.json
23
+ * 6. Zip — builds dist/rdc-skills-plugin.zip for claude.ai / distribution
24
+ * 7. Preflight — Node version, clauth daemon
25
+ * 8. Commands — lists all /rdc:* commands
26
+ */
27
+
28
+ 'use strict';
29
+ const fs = require('fs');
30
+ const path = require('path');
31
+ const os = require('os');
32
+ const readline = require('readline');
33
+ const http = require('http');
34
+ const crypto = require('crypto');
35
+ const { execSync } = require('child_process');
36
+
37
+ // ── Args ──────────────────────────────────────────────────────────────────────
38
+ const args = process.argv.slice(2);
39
+ const skipHooks = args.includes('--skip-hooks');
40
+ const profileIdx = args.indexOf('--profile');
41
+ const profileArg = profileIdx >= 0 ? String(args[profileIdx + 1] || '').toLowerCase() : 'auto';
42
+ const homeIdx = args.indexOf('--claude-home');
43
+ const claudeHome = homeIdx >= 0 ? args[homeIdx + 1] : path.join(os.homedir(), '.claude');
44
+ const migrateIdx = args.indexOf('--migrate');
45
+ const doMigrate = migrateIdx >= 0;
46
+ const migratePath = doMigrate ? (args[migrateIdx + 1] || process.cwd()) : null;
47
+ const projectIdx = args.indexOf('--project-root');
48
+ const projectRootArg = projectIdx >= 0 ? path.resolve(args[projectIdx + 1]) : null;
49
+ const shouldWriteStartupBlocks = args.includes('--write-startup-blocks');
50
+
51
+ const repoRoot = path.resolve(__dirname, '..');
52
+
53
+ const codexIdx = args.indexOf('--codex-root');
54
+ const codexRoot = codexIdx >= 0
55
+ ? path.resolve(args[codexIdx + 1])
56
+ : (() => {
57
+ // Auto-detect the consuming project's .agents tree so a plain `install`
58
+ // refreshes Codex without needing --codex-root. Check, in order: an
59
+ // explicit --project-root, the current working directory, then the
60
+ // regen-root sibling of this repo. First one with a `.agents` wins.
61
+ const candidates = [
62
+ projectRootArg ? path.resolve(projectRootArg) : null,
63
+ process.cwd(),
64
+ path.resolve(repoRoot, '..', 'regen-root'),
65
+ ].filter(Boolean);
66
+ for (const c of candidates) {
67
+ if (fs.existsSync(path.join(c, '.agents'))) return c;
68
+ }
69
+ return null;
70
+ })();
71
+ const codexSkillDirIdx = args.indexOf('--codex-skill-dir');
72
+ const explicitCodexSkillDir = codexSkillDirIdx >= 0
73
+ ? path.resolve(args[codexSkillDirIdx + 1])
74
+ : null;
75
+ const hooksSrc = path.join(repoRoot, 'hooks');
76
+ const hooksDst = path.join(claudeHome, 'hooks');
77
+ const settingsPath = path.join(claudeHome, 'settings.json');
78
+ const detectedLifeaiRoot = (() => {
79
+ const sibling = path.resolve(repoRoot, '..', 'regen-root');
80
+ return fs.existsSync(path.join(sibling, 'CLAUDE.md')) && fs.existsSync(path.join(sibling, '.rdc')) ? sibling : null;
81
+ })();
82
+ const installProfile = (() => {
83
+ if (profileArg === 'core' || profileArg === 'lifeai') return profileArg;
84
+ if (profileArg !== 'auto') {
85
+ console.log(` \x1b[33m⚠\x1b[0m Unknown --profile "${profileArg}" — using auto`);
86
+ }
87
+ return detectedLifeaiRoot ? 'lifeai' : 'core';
88
+ })();
89
+ const projectRoot = projectRootArg || codexRoot || detectedLifeaiRoot;
90
+
91
+ const PLUGIN_KEY = 'rdc-skills@rdc-skills';
92
+ const MARKETPLACE = 'rdc-skills';
93
+ const NPM_PACKAGE = '@lifeaitools/rdc-skills';
94
+ const MCP_NAME = 'rdc-skills-mcp';
95
+ const MCP_PORT = '3110';
96
+
97
+ // ── Logging ───────────────────────────────────────────────────────────────────
98
+ const ok = msg => console.log(` \x1b[32m✓\x1b[0m ${msg}`);
99
+ const info = msg => console.log(` \x1b[36m→\x1b[0m ${msg}`);
100
+ const warn = msg => console.log(` \x1b[33m⚠\x1b[0m ${msg}`);
101
+ const fail = msg => console.log(` \x1b[31m✗\x1b[0m ${msg}`);
102
+
103
+ function run(cmd, options = {}) {
104
+ return execSync(cmd, { encoding: 'utf8', stdio: 'pipe', ...options }).trim();
105
+ }
106
+
107
+ function shellQuote(s) {
108
+ const raw = String(s);
109
+ if (process.platform === 'win32') return `"${raw.replace(/"/g, '\\"')}"`;
110
+ return `'${raw.replace(/'/g, `'\\''`)}'`;
111
+ }
112
+
113
+ function updateCodexMcpToml(toml, mcpUrl) {
114
+ const blockRe = /(^|\n)(\[mcp_servers\.rdc-skills\]\n)([\s\S]*?)(?=\n\[|\s*$)/;
115
+ const desiredLine = `url = '${mcpUrl}'`;
116
+ if (blockRe.test(toml)) {
117
+ return toml.replace(blockRe, (match, prefix, header, body) => {
118
+ if (/^url\s*=.*$/m.test(body)) {
119
+ return `${prefix}${header}${body.replace(/^url\s*=.*$/m, desiredLine)}`;
120
+ }
121
+ const trimmed = body.replace(/\s*$/, '');
122
+ return `${prefix}${header}${trimmed}${trimmed ? '\n' : ''}${desiredLine}\n`;
123
+ });
124
+ }
125
+ return toml.replace(/\s*$/, '\n') + `\n[mcp_servers.rdc-skills]\n${desiredLine}\n`;
126
+ }
127
+
128
+ function selfTestCodexMcpToml() {
129
+ const url = 'https://rdc-skills.regendevcorp.com/mcp';
130
+ const stale = "[mcp_servers.clauth]\nurl = 'https://clauth.regendevcorp.com/mcp'\n\n[mcp_servers.rdc-skills]\nurl = 'https://rdc-skills.dev.regendevcorp.com/mcp'\n\n[mcp_servers.web-research]\nurl = 'https://research.regendevcorp.com/mcp'\n";
131
+ const updated = updateCodexMcpToml(stale, url);
132
+ if (!updated.includes(`url = '${url}'`)) throw new Error('did not write production rdc-skills URL');
133
+ if (updated.includes('rdc-skills.dev.regendevcorp.com')) throw new Error('stale dev URL survived');
134
+ if (!updated.includes('[mcp_servers.clauth]') || !updated.includes('[mcp_servers.web-research]')) throw new Error('neighbor MCP blocks were damaged');
135
+
136
+ const missing = updateCodexMcpToml("[mcp_servers.clauth]\nurl = 'https://clauth.regendevcorp.com/mcp'\n", url);
137
+ if (!missing.includes('[mcp_servers.rdc-skills]')) throw new Error('missing block was not appended');
138
+
139
+ const noUrl = updateCodexMcpToml('[mcp_servers.rdc-skills]\nstartup_timeout_sec = 30\n', url);
140
+ if (!noUrl.includes("startup_timeout_sec = 30") || !noUrl.includes(`url = '${url}'`)) throw new Error('url-less block was not repaired');
141
+
142
+ console.log('install-rdc-skills codex MCP TOML self-test — PASS');
143
+ }
144
+
145
+ if (args.includes('--self-test-codex-mcp-toml')) {
146
+ try {
147
+ selfTestCodexMcpToml();
148
+ process.exit(0);
149
+ } catch (e) {
150
+ console.error(e.message || e);
151
+ process.exit(1);
152
+ }
153
+ }
154
+
155
+ // ── Filesystem helpers ────────────────────────────────────────────────────────
156
+ function copyDir(src, dst, ext) {
157
+ if (!fs.existsSync(src)) { warn(`Source not found: ${src}`); return 0; }
158
+ fs.mkdirSync(dst, { recursive: true });
159
+ const files = fs.readdirSync(src).filter(f => !ext || f.endsWith(ext));
160
+ let count = 0;
161
+ for (const f of files) {
162
+ const s = path.join(src, f);
163
+ if (fs.statSync(s).isFile()) { fs.copyFileSync(s, path.join(dst, f)); count++; }
164
+ }
165
+ return count;
166
+ }
167
+
168
+ /**
169
+ * Copy hook files, INCLUDING subdirectories.
170
+ *
171
+ * This was flat: readdirSync + a .js/.ps1 filter, skipping anything that was not a
172
+ * file. So `hooks/lib/` was never created at the destination, and a hook that did
173
+ * `require('./lib/box-lock')` died MODULE_NOT_FOUND a SessionStart hook exiting
174
+ * non-zero with EMPTY stdout on every session, before any health check could run,
175
+ * meaning its own repair path could never recover it. The npm tarball shipped the
176
+ * file correctly; it was lost here, in the install step.
177
+ *
178
+ * `.mjs` is included because hooks/lib/run-evidence-gate.mjs has the same shape and
179
+ * was silently dropped by the old filter too.
180
+ */
181
+ function copyHookFiles(src, dst) {
182
+ if (!fs.existsSync(src)) { warn(`Source not found: ${src}`); return 0; }
183
+ fs.mkdirSync(dst, { recursive: true });
184
+ let count = 0;
185
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
186
+ const s = path.join(src, entry.name);
187
+ const d = path.join(dst, entry.name);
188
+ if (entry.isDirectory()) {
189
+ count += copyHookFiles(s, d);
190
+ } else if (/\.(?:js|mjs|ps1)$/i.test(entry.name)) {
191
+ fs.copyFileSync(s, d);
192
+ count++;
193
+ }
194
+ }
195
+ return count;
196
+ }
197
+
198
+ /**
199
+ * A SessionStart hook that cannot even LOAD must never ship.
200
+ *
201
+ * Verifies the installed tree by actually running the hook, because file-presence
202
+ * checks would not have caught this: the source was fine, the tarball was fine, and
203
+ * only the copied result was broken. A MODULE_NOT_FOUND here is fatal on every
204
+ * session, so it is worth one subprocess at install time.
205
+ */
206
+ function assertHooksLoadable(hooksDst) {
207
+ const entry = path.join(hooksDst, 'check-rdc-environment.js');
208
+ if (!fs.existsSync(entry)) { warn(`hook missing after copy: ${entry}`); return; }
209
+ const res = require('child_process').spawnSync(process.execPath, ['--check', entry], {
210
+ encoding: 'utf8', timeout: 20000,
211
+ });
212
+ const combined = `${res.stdout || ''}${res.stderr || ''}`;
213
+ if (res.status !== 0) throw new Error(`installed hook fails to parse: ${combined.trim().slice(0, 300)}`);
214
+
215
+ // --check does not resolve require(), so load it for real with stdin closed.
216
+ const load = require('child_process').spawnSync(process.execPath, ['-e',
217
+ `require(${JSON.stringify(entry.replace(/\\/g, '/'))})`,
218
+ ], { encoding: 'utf8', input: '', timeout: 20000 });
219
+ const loadOut = `${load.stdout || ''}${load.stderr || ''}`;
220
+ if (/MODULE_NOT_FOUND|Cannot find module/.test(loadOut)) {
221
+ throw new Error(`installed hook cannot resolve its imports: ${loadOut.trim().slice(0, 300)}`);
222
+ }
223
+ }
224
+
225
+ function copyDirRecursive(src, dst) {
226
+ if (!fs.existsSync(src)) return;
227
+ fs.mkdirSync(dst, { recursive: true });
228
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
229
+ const s = path.join(src, entry.name);
230
+ const d = path.join(dst, entry.name);
231
+ if (entry.isDirectory()) copyDirRecursive(s, d);
232
+ else {
233
+ // Atomic write (temp + rename) so a live Claude Code / Codex session never
234
+ // reads a half-written skill file when the installer runs over a live box.
235
+ const tmp = `${d}.tmp-${process.pid}`;
236
+ fs.copyFileSync(s, tmp);
237
+ fs.renameSync(tmp, d);
238
+ }
239
+ }
240
+ }
241
+
242
+ function copyMissingProjectGuides(projectRoot) {
243
+ if (!projectRoot) return 0;
244
+ const src = path.join(repoRoot, 'guides');
245
+ const dst = path.join(projectRoot, '.rdc', 'guides');
246
+ if (!fs.existsSync(src) || !fs.existsSync(dst)) return 0;
247
+ let copied = 0;
248
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
249
+ if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
250
+ if (entry.name === 'rdc-skills-startup.md') continue; // installed only by --write-startup-blocks
251
+ const target = path.join(dst, entry.name);
252
+ if (fs.existsSync(target)) continue;
253
+ fs.copyFileSync(path.join(src, entry.name), target);
254
+ copied++;
255
+ }
256
+ return copied;
257
+ }
258
+
259
+ function readJson(p, fallback = {}) {
260
+ if (!fs.existsSync(p)) return fallback;
261
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return fallback; }
262
+ }
263
+
264
+ function writeJson(p, data, indent = 2) {
265
+ fs.mkdirSync(path.dirname(p), { recursive: true });
266
+ fs.writeFileSync(p, JSON.stringify(data, null, indent));
267
+ }
268
+
269
+ function upsertManagedBlock(filePath, title, body) {
270
+ const begin = `<!-- BEGIN RDC-SKILLS:${title} -->`;
271
+ const end = `<!-- END RDC-SKILLS:${title} -->`;
272
+ const block = `${begin}\n${body.trim()}\n${end}\n`;
273
+ const current = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : '';
274
+ const re = new RegExp(`${begin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\s\\S]*?${end.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\n?`, 'm');
275
+ const next = re.test(current)
276
+ ? current.replace(re, block)
277
+ : `${current.replace(/\s*$/, '')}${current.trim() ? '\n\n' : ''}${block}`;
278
+ if (next !== current) {
279
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
280
+ fs.writeFileSync(filePath, next);
281
+ return true;
282
+ }
283
+ return false;
284
+ }
285
+
286
+ function writeStartupBlocks(targetRoot, profile) {
287
+ if (!targetRoot) return { wrote: 0, skipped: 'no-project-root' };
288
+ const startupSrc = path.join(repoRoot, 'guides', 'rdc-skills-startup.md');
289
+ const guidesDir = path.join(targetRoot, '.rdc', 'guides');
290
+ const startupDst = path.join(guidesDir, 'rdc-skills-startup.md');
291
+ fs.mkdirSync(guidesDir, { recursive: true });
292
+ fs.copyFileSync(startupSrc, startupDst);
293
+
294
+ let wrote = 1;
295
+ const claudeBody = [
296
+ '## RDC Skills',
297
+ '',
298
+ '@.rdc/guides/rdc-skills-startup.md',
299
+ '',
300
+ `Installed profile: \`${profile}\`.`,
301
+ 'For `/rdc:*` work, follow `.rdc/guides/output-contract.md` and `.rdc/guides/engineering-behavior.md`.',
302
+ ].join('\n');
303
+ const agentsBody = [
304
+ '## RDC Skills',
305
+ '',
306
+ 'Read `.rdc/guides/rdc-skills-startup.md` before using any `rdc:*` workflow.',
307
+ `Installed profile: \`${profile}\`.`,
308
+ 'For `/rdc:*` work, follow `.rdc/guides/output-contract.md` and `.rdc/guides/engineering-behavior.md`.',
309
+ ].join('\n');
310
+
311
+ if (upsertManagedBlock(path.join(targetRoot, 'CLAUDE.md'), 'STARTUP', claudeBody)) wrote++;
312
+ if (upsertManagedBlock(path.join(targetRoot, 'AGENTS.md'), 'STARTUP', agentsBody)) wrote++;
313
+ return { wrote, skipped: null };
314
+ }
315
+
316
+ // ── Frontmatter parser ────────────────────────────────────────────────────────
317
+ function readFrontmatter(filePath) {
318
+ try {
319
+ const content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
320
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
321
+ if (!match) return {};
322
+ const fm = {};
323
+ let key = null, multiline = false, multilineVal = '';
324
+ for (const line of match[1].split('\n')) {
325
+ if (multiline) {
326
+ if (/^\s+/.test(line)) { multilineVal += ' ' + line.trim(); continue; }
327
+ fm[key] = multilineVal.trim();
328
+ multiline = false;
329
+ }
330
+ const kv = line.match(/^(\w+):\s*(>-|>)?\s*(.*)?$/);
331
+ if (!kv) continue;
332
+ key = kv[1];
333
+ if (kv[2]) { multiline = true; multilineVal = kv[3] || ''; }
334
+ else fm[key] = kv[3] || '';
335
+ }
336
+ if (multiline && key) fm[key] = multilineVal.trim();
337
+ return fm;
338
+ } catch { return {}; }
339
+ }
340
+
341
+ // ── Plugin cache builder (shared between CLI + Cowork) ────────────────────────
342
+ function buildPluginCache(cacheDir, version, gitSha) {
343
+ fs.mkdirSync(cacheDir, { recursive: true });
344
+ for (const item of ['.claude-plugin', 'commands', 'skills', 'guides', 'hooks', 'package.json', 'README.md']) {
345
+ const src = path.join(repoRoot, item);
346
+ if (!fs.existsSync(src)) continue;
347
+ const dst = path.join(cacheDir, item);
348
+ if (fs.statSync(src).isDirectory()) copyDirRecursive(src, dst);
349
+ else fs.copyFileSync(src, dst);
350
+ }
351
+ }
352
+
353
+ function getPm2Process(name) {
354
+ try {
355
+ const list = JSON.parse(run('pm2 jlist'));
356
+ return Array.isArray(list) ? list.find((p) => p.name === name) || null : null;
357
+ } catch {
358
+ return null;
359
+ }
360
+ }
361
+
362
+ function isGlobalNpmRdcSkillsPath(scriptPath) {
363
+ if (!scriptPath) return false;
364
+ const normalized = scriptPath.replace(/\\/g, '/').toLowerCase();
365
+ return normalized.includes('/node_modules/@lifeaitools/rdc-skills/');
366
+ }
367
+
368
+ function packageRootFromMcpScript(scriptPath) {
369
+ return scriptPath ? path.resolve(path.dirname(scriptPath), '..') : null;
370
+ }
371
+
372
+ function readInstalledPackageVersion(scriptPath) {
373
+ const packageRoot = packageRootFromMcpScript(scriptPath);
374
+ if (!packageRoot) return null;
375
+ return readJson(path.join(packageRoot, 'package.json'), {}).version || null;
376
+ }
377
+
378
+ function collectFilesRecursive(baseDir, rel, out) {
379
+ const full = path.join(baseDir, rel);
380
+ if (!fs.existsSync(full)) return;
381
+ const stat = fs.statSync(full);
382
+ if (stat.isDirectory()) {
383
+ for (const entry of fs.readdirSync(full).sort()) {
384
+ collectFilesRecursive(baseDir, path.join(rel, entry), out);
385
+ }
386
+ return;
387
+ }
388
+ if (stat.isFile()) out.push(rel.replace(/\\/g, '/'));
389
+ }
390
+
391
+ function hashInstallSurface(root) {
392
+ if (!root || !fs.existsSync(root)) return null;
393
+ const rels = [];
394
+ for (const rel of ['.claude-plugin', 'commands', 'guides', 'skills', 'package.json', 'README.md']) {
395
+ collectFilesRecursive(root, rel, rels);
396
+ }
397
+ const hash = crypto.createHash('sha256');
398
+ for (const rel of rels.sort()) {
399
+ hash.update(rel);
400
+ hash.update('\0');
401
+ hash.update(fs.readFileSync(path.join(root, rel)));
402
+ hash.update('\0');
403
+ }
404
+ return hash.digest('hex');
405
+ }
406
+
407
+ function syncGlobalMcpInstall(version) {
408
+ const proc = getPm2Process(MCP_NAME);
409
+ if (!proc) {
410
+ info('[2.9] MCP pkg — PM2 process not registered yet; start handled below');
411
+ return;
412
+ }
413
+
414
+ const scriptPath = proc.pm2_env?.pm_exec_path || proc.pm_exec_path || '';
415
+ if (!isGlobalNpmRdcSkillsPath(scriptPath)) {
416
+ info(`[2.9] MCP pkg — PM2 uses source checkout, not global npm (${scriptPath || 'unknown path'})`);
417
+ return;
418
+ }
419
+
420
+ const installedVersion = readInstalledPackageVersion(scriptPath);
421
+ const packageRoot = packageRootFromMcpScript(scriptPath);
422
+ const sourceHash = hashInstallSurface(repoRoot);
423
+ const installedHash = hashInstallSurface(packageRoot);
424
+ if (installedVersion === version && sourceHash && installedHash && sourceHash === installedHash) {
425
+ ok(`[2.9] MCP pkg — global ${NPM_PACKAGE}@${version} already matches source`);
426
+ return;
427
+ }
428
+
429
+ let stopped = false;
430
+ let installed = false;
431
+ try {
432
+ const reason = installedVersion === version ? 'same version, source changed' : `${installedVersion || '?'} → ${version}`;
433
+ info(`[2.9] MCP pkg — updating global ${NPM_PACKAGE} (${reason}) from local source`);
434
+ run(`pm2 stop ${MCP_NAME}`);
435
+ stopped = true;
436
+ run(`npm install -g ${shellQuote(repoRoot)}`);
437
+ installed = true;
438
+ ok(`[2.9] MCP pkg — installed global ${NPM_PACKAGE}@${version}`);
439
+ } catch (e) {
440
+ warn(`[2.9] MCP pkg — global install failed (${String(e.message || e).split('\n')[0]})`);
441
+ if (String(e.message || '').includes('EBUSY')) {
442
+ info(' PM2 was stopped first; if EBUSY persists, another Node/npm process still holds the package directory.');
443
+ }
444
+ } finally {
445
+ if (stopped) {
446
+ try {
447
+ run(`pm2 restart ${MCP_NAME} --update-env`, { env: { ...process.env, PORT: MCP_PORT } });
448
+ ok(`[2.9] MCP pkg — pm2 restarted ${MCP_NAME}${installed ? '' : ' (previous install restored)'}`);
449
+ } catch (e) {
450
+ warn(`[2.9] MCP pkg — pm2 restart failed (${String(e.message || e).split('\n')[0]})`);
451
+ }
452
+ }
453
+ }
454
+ }
455
+
456
+ function callLocalMcpTool(name, args = {}) {
457
+ return new Promise((resolve, reject) => {
458
+ const payload = JSON.stringify({
459
+ jsonrpc: '2.0',
460
+ id: 1,
461
+ method: 'tools/call',
462
+ params: { name, arguments: args },
463
+ });
464
+ const req = http.request({
465
+ hostname: '127.0.0.1',
466
+ port: Number(MCP_PORT),
467
+ path: '/mcp',
468
+ method: 'POST',
469
+ headers: {
470
+ 'Content-Type': 'application/json',
471
+ 'Accept': 'application/json, text/event-stream',
472
+ 'Content-Length': Buffer.byteLength(payload),
473
+ },
474
+ timeout: 10000,
475
+ }, (res) => {
476
+ let body = '';
477
+ res.setEncoding('utf8');
478
+ res.on('data', (chunk) => { body += chunk; });
479
+ res.on('end', () => {
480
+ try {
481
+ const dataLine = body.split(/\r?\n/).find((line) => line.startsWith('data: '));
482
+ const envelope = JSON.parse(dataLine ? dataLine.slice(6) : body);
483
+ const text = envelope?.result?.content?.[0]?.text;
484
+ resolve(text ? JSON.parse(text) : envelope);
485
+ } catch (e) {
486
+ reject(new Error(`invalid MCP response: ${e.message}`));
487
+ }
488
+ });
489
+ });
490
+ req.on('timeout', () => req.destroy(new Error('MCP request timed out')));
491
+ req.on('error', reject);
492
+ req.write(payload);
493
+ req.end();
494
+ });
495
+ }
496
+
497
+ async function verifyLiveMcpCatalogFresh() {
498
+ const proc = getPm2Process(MCP_NAME);
499
+ if (!proc) {
500
+ warn('[7.5] MCP catalog — skipped (PM2 process not registered)');
501
+ return;
502
+ }
503
+ const sourceSkills = fs.readdirSync(path.join(repoRoot, 'skills'), { withFileTypes: true })
504
+ .filter((entry) => entry.isDirectory() && fs.existsSync(path.join(repoRoot, 'skills', entry.name, 'SKILL.md')))
505
+ .map((entry) => entry.name)
506
+ .sort();
507
+ const catalog = await callLocalMcpTool('rdc_skill_list', {});
508
+ const rows = Array.isArray(catalog)
509
+ ? catalog
510
+ : Array.isArray(catalog?.skills) ? catalog.skills
511
+ : Array.isArray(catalog?.results) ? catalog.results
512
+ : [];
513
+ const liveNames = new Set(rows.map((row) => row.name));
514
+ const missing = sourceSkills.filter((name) => !liveNames.has(name));
515
+ if (missing.length > 0 || rows.length < sourceSkills.length) {
516
+ throw new Error(`live MCP catalog stale: source=${sourceSkills.length}, live=${rows.length}, missing=${missing.join(', ') || '(count mismatch)'}`);
517
+ }
518
+ ok(`[7.5] MCP catalog — ${rows.length} live skill(s), source catalog fresh`);
519
+ }
520
+
521
+ // ── User-skills cleanup ───────────────────────────────────────────────────────
522
+ // Older installer versions wrote skill files directly to ~/.claude/skills/user/.
523
+ // Claude Code loads that directory AND the plugin cache, so any rdc skills left
524
+ // there produce duplicate registrations and break the resolver.
525
+ // This function nukes any entry whose frontmatter name starts with "rdc:".
526
+ // Scans BOTH the immediate dir and nested .md files (e.g. `user/skill.md`,
527
+ // `user/rdc-build/SKILL.md`) so pre-plugin orphans are caught regardless of
528
+ // naming convention.
529
+ function cleanUserSkills(userSkillsDir) {
530
+ if (!fs.existsSync(userSkillsDir)) return 0;
531
+ let removed = 0;
532
+ for (const entry of fs.readdirSync(userSkillsDir, { withFileTypes: true })) {
533
+ const candidate = path.join(userSkillsDir, entry.name);
534
+ if (entry.isDirectory()) {
535
+ // Subdir form: <name>/SKILL.md or <name>/skill.md
536
+ let skillFile = null;
537
+ for (const sf of ['SKILL.md', 'skill.md']) {
538
+ const p = path.join(candidate, sf);
539
+ if (fs.existsSync(p)) { skillFile = p; break; }
540
+ }
541
+ if (!skillFile) continue;
542
+ const fm = readFrontmatter(skillFile);
543
+ if (fm.name && fm.name.startsWith('rdc:')) {
544
+ try { fs.rmSync(candidate, { recursive: true, force: true }); removed++; } catch {}
545
+ }
546
+ } else if (entry.isFile() && entry.name.endsWith('.md')) {
547
+ // ANY .md file at this level — including skill.md / SKILL.md / README.md
548
+ // if their frontmatter declares an rdc:* skill. A previous version skipped
549
+ // those names; that left an orphan rdc:build copy at user/skill.md which
550
+ // registered as a duplicate "user" skill.
551
+ const fm = readFrontmatter(candidate);
552
+ if (fm.name && fm.name.startsWith('rdc:')) {
553
+ try { fs.unlinkSync(candidate); removed++; } catch {}
554
+ }
555
+ }
556
+ }
557
+ return removed;
558
+ }
559
+
560
+ // Scrub legacy rdc orphans from ~/.claude/skills/ (top-level), not just user/.
561
+ // Some older installs landed flat skill files alongside the plugin tree.
562
+ function cleanGlobalSkillsRoot(skillsDir) {
563
+ if (!fs.existsSync(skillsDir)) return 0;
564
+ let removed = 0;
565
+ for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
566
+ if (entry.name === 'user') continue; // handled separately
567
+ const candidate = path.join(skillsDir, entry.name);
568
+ if (entry.isFile() && entry.name.endsWith('.md')) {
569
+ const fm = readFrontmatter(candidate);
570
+ if (fm.name && fm.name.startsWith('rdc:')) {
571
+ try { fs.unlinkSync(candidate); removed++; } catch {}
572
+ }
573
+ }
574
+ }
575
+ return removed;
576
+ }
577
+
578
+ // ── Stale hook cleanup ────────────────────────────────────────────────────────
579
+ // Remove ONLY explicitly orphaned hook files hooks that were previously shipped
580
+ // by rdc-skills and have since been removed from the project.
581
+ // NEVER use "not in source = remove" logic: most hooks in ~/.claude/hooks/ are
582
+ // not managed by rdc-skills (they come from other plugins or were written directly).
583
+ function cleanStaleHooks(hooksDstDir) {
584
+ if (!fs.existsSync(hooksDstDir)) return 0;
585
+ // Explicit orphan list — add entries here when a hook is intentionally removed.
586
+ // Format: filename that should be deleted if it still exists.
587
+ const ORPHANED_HOOKS = [
588
+ 'verify-rdc-skills.js', // removed in v0.9.7 was checking for old flat-file format
589
+ ];
590
+ let removed = 0;
591
+ for (const f of ORPHANED_HOOKS) {
592
+ const p = path.join(hooksDstDir, f);
593
+ if (fs.existsSync(p)) {
594
+ try { fs.unlinkSync(p); removed++; } catch {}
595
+ }
596
+ }
597
+ return removed;
598
+ }
599
+
600
+ // Project-local hookify docs were a temporary workaround for work item exit
601
+ // enforcement. The plugin hook is now authoritative, so installs clean those
602
+ // local shims from known RDC workspaces instead of leaving two gates to drift.
603
+ function cleanProjectHookifyShims(projectRoot) {
604
+ if (!projectRoot) return 0;
605
+ const hookifyDir = path.join(projectRoot, '.claude');
606
+ if (!fs.existsSync(hookifyDir)) return 0;
607
+ const stale = [
608
+ 'hookify.work-item-done-gate-bash.local.md',
609
+ 'hookify.work-item-done-gate-mcp.local.md',
610
+ 'hookify.work-item-review-gate-bash.local.md',
611
+ 'hookify.work-item-review-gate-mcp.local.md',
612
+ ];
613
+ let removed = 0;
614
+ for (const f of stale) {
615
+ const p = path.join(hookifyDir, f);
616
+ if (fs.existsSync(p)) {
617
+ try { fs.unlinkSync(p); removed++; } catch {}
618
+ }
619
+ }
620
+ return removed;
621
+ }
622
+
623
+ // ── Cache flush helper ────────────────────────────────────────────────────────
624
+ // Only `latest/` is ever kept. Earlier versions wrote BOTH `<version>/` and
625
+ // `latest/`, which caused the plugin loader to scan and register every rdc:*
626
+ // skill twice (once per directory). The fix is permanent single-dir layout.
627
+ function flushOldCaches(cacheBase /* keepVersion intentionally unused */) {
628
+ if (!fs.existsSync(cacheBase)) return 0;
629
+ let flushed = 0;
630
+ for (const entry of fs.readdirSync(cacheBase)) {
631
+ if (entry === 'latest') continue;
632
+ try {
633
+ fs.rmSync(path.join(cacheBase, entry), { recursive: true, force: true });
634
+ flushed++;
635
+ } catch {}
636
+ }
637
+ return flushed;
638
+ }
639
+
640
+ // ── Step 2: CLI plugin registration (→ ~/.claude/plugins/) ───────────────────
641
+ function registerCLI(version, gitSha) {
642
+ const pluginDir = path.join(claudeHome, 'plugins');
643
+ const mktDir = path.join(pluginDir, 'marketplaces', MARKETPLACE);
644
+ const mktPlugDir = path.join(mktDir, '.claude-plugin');
645
+ const cacheBase = path.join(pluginDir, 'cache', MARKETPLACE, 'rdc-skills');
646
+ const latestDir = path.join(cacheBase, 'latest');
647
+
648
+ // 1. Marketplace manifest
649
+ fs.mkdirSync(mktPlugDir, { recursive: true });
650
+ fs.copyFileSync(path.join(repoRoot, '.claude-plugin', 'marketplace.json'), path.join(mktPlugDir, 'marketplace.json'));
651
+
652
+ // 2. known_marketplaces.json
653
+ const kmpPath = path.join(pluginDir, 'known_marketplaces.json');
654
+ const knownMp = readJson(kmpPath);
655
+ knownMp[MARKETPLACE] = { source: { source: 'github', repo: 'LIFEAI/rdc-skills' }, installLocation: mktDir, lastUpdated: new Date().toISOString() };
656
+ writeJson(kmpPath, knownMp, 4);
657
+
658
+ // 3. Flush every cache dir except `latest/`, then rewrite `latest/`. We
659
+ // intentionally do NOT keep a versioned dir — the plugin loader registers
660
+ // every dir it finds, so two dirs = duplicate skills.
661
+ const flushed = flushOldCaches(cacheBase);
662
+ if (flushed > 0) info(` flushed : ${flushed} stale cache dir(s)`);
663
+ if (fs.existsSync(latestDir)) fs.rmSync(latestDir, { recursive: true, force: true });
664
+ buildPluginCache(latestDir, version, gitSha);
665
+
666
+ // 4. installed_plugins.json register 'latest/' as stable installPath so
667
+ // re-installs overwrite in-place rather than creating orphaned version dirs.
668
+ // Open terminals that re-read installed_plugins.json mid-session will pick
669
+ // up the updated path; otherwise a terminal restart is needed.
670
+ const ipPath = path.join(pluginDir, 'installed_plugins.json');
671
+ const installed = readJson(ipPath, { version: 2, plugins: {} });
672
+ // Also overwrite whichever installPath the old entry had, so any open
673
+ // terminal that already loaded that path sees fresh files.
674
+ const oldEntries = installed.plugins[PLUGIN_KEY] || [];
675
+ for (const old of oldEntries) {
676
+ if (old.installPath && fs.existsSync(old.installPath) && old.installPath !== latestDir) {
677
+ try { buildPluginCache(old.installPath, version, gitSha); } catch {}
678
+ }
679
+ }
680
+ for (const key of Object.keys(installed.plugins || {})) {
681
+ if (key.startsWith('rdc-skills@')) delete installed.plugins[key];
682
+ }
683
+ installed.plugins[PLUGIN_KEY] = [{ scope: 'user', installPath: latestDir, version, installedAt: new Date().toISOString(), lastUpdated: new Date().toISOString(), gitCommitSha: gitSha }];
684
+ writeJson(ipPath, installed, 4);
685
+
686
+ // 5. settings.json enabledPlugins
687
+ const settings = readJson(settingsPath);
688
+ if (!settings.enabledPlugins) settings.enabledPlugins = {};
689
+ for (const key of Object.keys(settings.enabledPlugins)) {
690
+ if (key.startsWith('rdc-skills@')) delete settings.enabledPlugins[key];
691
+ }
692
+ settings.enabledPlugins[PLUGIN_KEY] = true;
693
+ writeJson(settingsPath, settings);
694
+
695
+ return latestDir;
696
+ }
697
+
698
+ // ── Step 3: Cowork (Claude Desktop) registration ──────────────────────────────
699
+ function findCoworkBases() {
700
+ // Cowork stores per-workspace state at:
701
+ // %LOCALAPPDATA%/Packages/Claude_*/LocalCache/Roaming/Claude/local-agent-mode-sessions/<workspace>/<device>/
702
+ // Each has cowork_settings.json + cowork_plugins/
703
+ const results = [];
704
+
705
+ // Candidate MSIX package roots
706
+ const localAppData = process.env.LOCALAPPDATA || '';
707
+ const pkgsDir = path.join(localAppData, 'Packages');
708
+ if (!fs.existsSync(pkgsDir)) return results;
709
+
710
+ let claudePkg = null;
711
+ for (const dir of fs.readdirSync(pkgsDir)) {
712
+ if (/^Claude_/i.test(dir)) { claudePkg = path.join(pkgsDir, dir); break; }
713
+ }
714
+ if (!claudePkg) return results;
715
+
716
+ const sessionsRoot = path.join(claudePkg, 'LocalCache', 'Roaming', 'Claude', 'local-agent-mode-sessions');
717
+ if (!fs.existsSync(sessionsRoot)) return results;
718
+
719
+ // Walk two levels: <workspace>/<device>/cowork_settings.json
720
+ for (const ws of fs.readdirSync(sessionsRoot)) {
721
+ const wsDir = path.join(sessionsRoot, ws);
722
+ if (!fs.statSync(wsDir).isDirectory()) continue;
723
+ for (const dev of fs.readdirSync(wsDir)) {
724
+ const devDir = path.join(wsDir, dev);
725
+ if (!fs.statSync(devDir).isDirectory()) continue;
726
+ const settingsFile = path.join(devDir, 'cowork_settings.json');
727
+ if (fs.existsSync(settingsFile)) {
728
+ results.push({ dir: devDir, settingsFile });
729
+ }
730
+ }
731
+ }
732
+ return results;
733
+ }
734
+
735
+ function registerCowork(version, gitSha) {
736
+ const bases = findCoworkBases();
737
+ if (bases.length === 0) {
738
+ warn('Cowork — Claude Desktop not found (MSIX package missing)');
739
+ return 0;
740
+ }
741
+
742
+ for (const { dir, settingsFile } of bases) {
743
+ const pluginsDir = path.join(dir, 'cowork_plugins');
744
+ const cacheBase = path.join(pluginsDir, 'cache', MARKETPLACE, 'rdc-skills');
745
+ const latestDir = path.join(cacheBase, 'latest');
746
+ const mktDir = path.join(pluginsDir, 'marketplaces', MARKETPLACE);
747
+ const mktPlugDir = path.join(mktDir, '.claude-plugin');
748
+
749
+ // Marketplace manifest
750
+ fs.mkdirSync(mktPlugDir, { recursive: true });
751
+ fs.copyFileSync(path.join(repoRoot, '.claude-plugin', 'marketplace.json'), path.join(mktPlugDir, 'marketplace.json'));
752
+
753
+ // known_marketplaces.json
754
+ const kmpPath = path.join(pluginsDir, 'known_marketplaces.json');
755
+ const knownMp = readJson(kmpPath);
756
+ knownMp[MARKETPLACE] = { source: { source: 'github', repo: 'LIFEAI/rdc-skills' }, installLocation: mktDir, lastUpdated: new Date().toISOString() };
757
+ writeJson(kmpPath, knownMp, 4);
758
+
759
+ // Flush all non-latest caches and rewrite `latest/` only — single-dir layout
760
+ flushOldCaches(cacheBase);
761
+ if (fs.existsSync(latestDir)) fs.rmSync(latestDir, { recursive: true, force: true });
762
+ buildPluginCache(latestDir, version, gitSha);
763
+
764
+ // installed_plugins.json use stable 'latest/' path
765
+ const ipPath = path.join(pluginsDir, 'installed_plugins.json');
766
+ const installed = readJson(ipPath, { version: 2, plugins: {} });
767
+ for (const key of Object.keys(installed.plugins || {})) {
768
+ if (key.startsWith('rdc-skills@')) delete installed.plugins[key];
769
+ }
770
+ installed.plugins[PLUGIN_KEY] = [{ scope: 'user', installPath: latestDir, version, installedAt: new Date().toISOString(), lastUpdated: new Date().toISOString(), gitCommitSha: gitSha }];
771
+ writeJson(ipPath, installed, 4);
772
+
773
+ // cowork_settings.json enabledPlugins + extraKnownMarketplaces
774
+ const settings = readJson(settingsFile);
775
+ if (!settings.enabledPlugins) settings.enabledPlugins = {};
776
+ for (const key of Object.keys(settings.enabledPlugins)) {
777
+ if (key.startsWith('rdc-skills@')) delete settings.enabledPlugins[key];
778
+ }
779
+ settings.enabledPlugins[PLUGIN_KEY] = true;
780
+ if (!settings.extraKnownMarketplaces) settings.extraKnownMarketplaces = {};
781
+ settings.extraKnownMarketplaces[MARKETPLACE] = { source: { source: 'github', repo: 'LIFEAI/rdc-skills' } };
782
+ writeJson(settingsFile, settings);
783
+ }
784
+
785
+ return bases.length;
786
+ }
787
+
788
+ // ── Codex registration (→ Codex skill dirs/rdc-*/) ───────────────────────────
789
+ function addCodexTarget(targets, label, targetDir) {
790
+ if (!targetDir) return;
791
+ const resolved = path.resolve(targetDir);
792
+ if (targets.some(t => t.targetDir.toLowerCase() === resolved.toLowerCase())) return;
793
+ targets.push({ label, targetDir: resolved });
794
+ }
795
+
796
+ // Register the rdc-skills MCP endpoint at the USER/GLOBAL level for every client
797
+ // so all Claude Code projects and all Codex sessions can reach the skills via MCP
798
+ // (not just where a project .mcp.json exists). Idempotent + non-fatal. Mirrors the
799
+ // existing clauth/codeflow entries exactly. claude.ai web still needs the one-time
800
+ // connector add in its UI (no programmatic API).
801
+ function registerMcpEndpoints() {
802
+ const MCP_URL = 'https://rdc-skills.regendevcorp.com/mcp';
803
+ const out = [];
804
+
805
+ // Claude Code — user-level ~/.claude.json mcpServers (covers EVERY project).
806
+ try {
807
+ const claudeJson = path.join(os.homedir(), '.claude.json');
808
+ if (fs.existsSync(claudeJson)) {
809
+ const data = readJson(claudeJson);
810
+ if (!data.mcpServers || typeof data.mcpServers !== 'object') data.mcpServers = {};
811
+ const cur = data.mcpServers['rdc-skills'];
812
+ if (!cur || cur.url !== MCP_URL) {
813
+ data.mcpServers['rdc-skills'] = { type: 'http', url: MCP_URL };
814
+ writeJson(claudeJson, data, 2);
815
+ out.push('claude(~/.claude.json)');
816
+ }
817
+ }
818
+ } catch (e) { out.push(`claude WARN:${e.message}`); }
819
+
820
+ // Codex ensure [mcp_servers.rdc-skills] exists and points at the live shared endpoint.
821
+ try {
822
+ const codexToml = path.join(os.homedir(), '.codex', 'config.toml');
823
+ if (fs.existsSync(codexToml)) {
824
+ const toml = fs.readFileSync(codexToml, 'utf8');
825
+ const next = updateCodexMcpToml(toml, MCP_URL);
826
+ if (next !== toml) {
827
+ fs.writeFileSync(codexToml, next);
828
+ out.push('codex(~/.codex/config.toml)');
829
+ }
830
+ }
831
+ } catch (e) { out.push(`codex WARN:${e.message}`); }
832
+
833
+ return out;
834
+ }
835
+
836
+ function findCodexTargets() {
837
+ const targets = [];
838
+ if (codexRoot) {
839
+ addCodexTarget(targets, 'project .agents', path.join(codexRoot, '.agents', 'skills', 'user'));
840
+ }
841
+ if (explicitCodexSkillDir) {
842
+ addCodexTarget(targets, 'explicit', explicitCodexSkillDir);
843
+ }
844
+
845
+ const codexHomeSkills = path.join(os.homedir(), '.codex', 'skills');
846
+ if (fs.existsSync(codexHomeSkills)) {
847
+ addCodexTarget(targets, 'global .codex', codexHomeSkills);
848
+ }
849
+
850
+ const globalAgentSkills = path.join(os.homedir(), '.agents', 'skills');
851
+ if (fs.existsSync(globalAgentSkills)) {
852
+ addCodexTarget(targets, 'global .agents', globalAgentSkills);
853
+ }
854
+
855
+ return targets;
856
+ }
857
+
858
+ function registerCodexTarget(targetDir) {
859
+ fs.mkdirSync(targetDir, { recursive: true });
860
+
861
+ // Clean: remove dirs that are rdc skills — by prefix OR by frontmatter name
862
+ let removed = 0;
863
+ for (const entry of fs.readdirSync(targetDir, { withFileTypes: true })) {
864
+ if (!entry.isDirectory()) continue;
865
+ const candidate = path.join(targetDir, entry.name);
866
+ if (/^rdc-/.test(entry.name)) {
867
+ fs.rmSync(candidate, { recursive: true, force: true });
868
+ removed++;
869
+ } else {
870
+ const fm = readFrontmatter(path.join(candidate, 'SKILL.md'));
871
+ if (fm.name && fm.name.startsWith('rdc:')) {
872
+ fs.rmSync(candidate, { recursive: true, force: true });
873
+ removed++;
874
+ }
875
+ }
876
+ }
877
+
878
+ // Copy: each source skill dir that has a SKILL.md → rdc-<name>/
879
+ const skillsSrc = path.join(repoRoot, 'skills');
880
+ let copied = 0;
881
+ for (const entry of fs.readdirSync(skillsSrc, { withFileTypes: true })) {
882
+ if (!entry.isDirectory()) continue;
883
+ const skillFile = path.join(skillsSrc, entry.name, 'SKILL.md');
884
+ if (!fs.existsSync(skillFile)) continue;
885
+ const dst = path.join(targetDir, `rdc-${entry.name}`);
886
+ copyDirRecursive(path.join(skillsSrc, entry.name), dst);
887
+ copied++;
888
+ }
889
+
890
+ return { removed, copied };
891
+ }
892
+
893
+ // ── Step 6: Zip for claude.ai / distribution ─────────────────────────────────
894
+ function buildZip(version) {
895
+ const distDir = path.join(repoRoot, 'dist');
896
+ fs.mkdirSync(distDir, { recursive: true });
897
+ const zipPath = path.join(distDir, `rdc-skills-plugin-v${version}.zip`);
898
+
899
+ // Remove old zips
900
+ if (fs.existsSync(distDir)) {
901
+ for (const f of fs.readdirSync(distDir)) {
902
+ if (f.startsWith('rdc-skills-plugin') && f.endsWith('.zip')) {
903
+ fs.unlinkSync(path.join(distDir, f));
904
+ }
905
+ }
906
+ }
907
+
908
+ // Use PowerShell Compress-Archive on Windows, zip on Unix
909
+ const items = ['.claude-plugin', 'commands', 'skills', 'guides', 'hooks', 'package.json', 'README.md']
910
+ .filter(i => fs.existsSync(path.join(repoRoot, i)));
911
+
912
+ try {
913
+ if (process.platform === 'win32') {
914
+ // Stage to a temp dir so we get a clean zip root
915
+ const tmp = path.join(os.tmpdir(), `rdc-skills-zip-${Date.now()}`);
916
+ fs.mkdirSync(tmp, { recursive: true });
917
+ for (const item of items) {
918
+ const src = path.join(repoRoot, item);
919
+ const dst = path.join(tmp, item);
920
+ if (fs.statSync(src).isDirectory()) copyDirRecursive(src, dst);
921
+ else fs.copyFileSync(src, dst);
922
+ }
923
+ // Try pwsh first (PowerShell 7+), fall back to Windows PowerShell
924
+ const psExe = fs.existsSync('C:\\Program Files\\PowerShell\\7\\pwsh.exe')
925
+ ? 'C:\\Program Files\\PowerShell\\7\\pwsh.exe'
926
+ : 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe';
927
+ execSync(
928
+ `"${psExe}" -NoProfile -NonInteractive -WindowStyle Hidden -Command "Compress-Archive -Path '${tmp}\\*' -DestinationPath '${zipPath}' -Force"`,
929
+ { stdio: 'pipe' }
930
+ );
931
+ fs.rmSync(tmp, { recursive: true, force: true });
932
+ } else {
933
+ const itemList = items.join(' ');
934
+ execSync(`zip -r "${zipPath}" ${itemList}`, { cwd: repoRoot, stdio: 'pipe' });
935
+ }
936
+ return zipPath;
937
+ } catch (e) {
938
+ warn(`Zip failed: ${e.message}`);
939
+ return null;
940
+ }
941
+ }
942
+
943
+ // ── Hook config ───────────────────────────────────────────────────────────────
944
+
945
+ /**
946
+ * SessionStart budget for check-rdc-environment.js, DERIVED from the per-step
947
+ * timeouts in hooks/check-rdc-environment.js repair() rather than restated, so the
948
+ * two cannot drift apart.
949
+ *
950
+ * At the harness default (60s) a genuine cold repair was killed mid-install — the
951
+ * leader's own steps alone exceed it and every follower read that as a crash.
952
+ * The +60s headroom matters: a budget equal to the exact sum kills a leader that
953
+ * legitimately uses its full per-step allowance right at the boundary.
954
+ */
955
+ const REPAIR_NPM_SEC = 120; // npm install -g
956
+ const REPAIR_INSTALL_SEC = 180; // rdc-skills-install
957
+ const REPAIR_PM2_SEC = 60; // pm2 stop + restart
958
+ const RDC_ENV_HOOK_TIMEOUT_SEC = REPAIR_NPM_SEC + REPAIR_INSTALL_SEC + REPAIR_PM2_SEC + 60;
959
+
960
+ function buildHooksConfig(hooksDir, profile = 'core') {
961
+ const base = hooksDir.replace(/\\/g, '/');
962
+ const cmd = (file, msg, timeoutSec) => {
963
+ const command = process.platform === 'win32'
964
+ ? `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File "${base}/run-hidden-hook.ps1" "${base}/${file}"`
965
+ : `node "${base}/${file}"`;
966
+ const entry = { type: 'command', command };
967
+ if (msg) entry.statusMessage = msg;
968
+ if (timeoutSec) entry.timeout = timeoutSec;
969
+ return entry;
970
+ };
971
+ const config = {
972
+ UserPromptExpansion: [{ hooks: [
973
+ cmd('rdc-invocation-marker.js', 'Marking RDC slash command...'),
974
+ ]}],
975
+ UserPromptSubmit: [{ hooks: [
976
+ cmd('rdc-invocation-marker.js', 'Marking RDC prompt...'),
977
+ ]}],
978
+ PreToolUse: [
979
+ { hooks: [
980
+ cmd('foreground-process-gate.js', 'Checking foreground process policy...'),
981
+ ]},
982
+ { matcher: 'Bash', hooks: [
983
+ cmd('require-work-item-on-commit.js'),
984
+ ]},
985
+ ],
986
+ PostToolUse: [
987
+ { hooks: [
988
+ cmd('check-services.js'),
989
+ ]},
990
+ { matcher: 'Bash', hooks: [
991
+ cmd('require-work-item-on-commit.js', 'Capturing commit SHA for work item...'),
992
+ ]},
993
+ ],
994
+ Stop: [{ hooks: [
995
+ cmd('rdc-output-contract-gate.js', 'Checking RDC output contract...'),
996
+ cmd('post-work-check.js', 'Checking for undocumented work...'),
997
+ ]}],
998
+ };
999
+
1000
+ if (profile === 'lifeai') {
1001
+ config.SessionStart = [{ hooks: [
1002
+ cmd('check-rdc-environment.js', 'Checking RDC skills runtime...', RDC_ENV_HOOK_TIMEOUT_SEC),
1003
+ cmd('check-cwd.js'),
1004
+ cmd('check-stale-work-items.js', 'Checking for stale work items...'),
1005
+ // Truth Gate 3.0 Layer 6 gate watchdog (ADVISORY; SessionStart cannot block).
1006
+ cmd('gate-watchdog-selfcheck.js', 'Truth Gate watchdog: verifying gate registration...'),
1007
+ ]}];
1008
+ config.PreToolUse[0].hooks.push(
1009
+ cmd('work-item-exit-gate.js', 'Checking work item exit gates...'),
1010
+ );
1011
+ // Truth Gate 3.0 Layer 5 harness completion gates. Both blocking hooks are
1012
+ // FLAG-GATED, default OFF (no-op until the env/DB flag is flipped at deploy),
1013
+ // so registering them does NOT disrupt the in-flight build session.
1014
+ config.TaskCompleted = [{ hooks: [
1015
+ cmd('task-completed-gate.js', 'Truth Gate: verifying task closure...'),
1016
+ ]}];
1017
+ config.PostToolBatch = [{ hooks: [
1018
+ cmd('post-tool-batch-gate.js', 'Truth Gate: checking build-wave worktree bases...'),
1019
+ ]}];
1020
+ config.PreCompact = [{ hooks: [
1021
+ cmd('precompact-log.js'),
1022
+ ]}];
1023
+ config.PostCompact = [{ hooks: [
1024
+ cmd('postcompact-log.js'),
1025
+ cmd('restart-brief.js', 'Writing restart brief...'),
1026
+ ]}];
1027
+ config.Stop[0].hooks.unshift(
1028
+ cmd('rate-limit-retry.js', 'Checking for rate limits...'),
1029
+ );
1030
+ config.Stop[0].hooks.push(
1031
+ cmd('no-stop-open-epics.js', 'Checking for open epics...'),
1032
+ );
1033
+ }
1034
+
1035
+ return config;
1036
+ }
1037
+
1038
+ // ── MCP server registration (non-fatal) ───────────────────────────────────────
1039
+ // Ensures the rdc-skills MCP deps are installed, registers/starts the local MCP
1040
+ // under PM2 as `rdc-skills-mcp` on PORT=3110, and prints the claude.ai connector
1041
+ // line. Every failure here WARNs — it must never abort the installer.
1042
+ function registerMcpServer() {
1043
+ const binPath = path.join(repoRoot, 'bin', 'rdc-skills-mcp.mjs');
1044
+ const connector = 'https://rdc-skills.regendevcorp.com/mcp';
1045
+
1046
+ try {
1047
+ // (a) ensure MCP runtime deps are present (express, @modelcontextprotocol/sdk, yaml, zod)
1048
+ const needDeps = ['express', '@modelcontextprotocol/sdk', 'yaml', 'zod'].some((d) => {
1049
+ try { require.resolve(d, { paths: [repoRoot] }); return false; } catch { return true; }
1050
+ });
1051
+ if (needDeps) {
1052
+ try {
1053
+ execSync('npm install --no-audit --no-fund', { cwd: repoRoot, stdio: 'pipe' });
1054
+ ok('[7/7] MCP deps — installed');
1055
+ } catch (e) {
1056
+ warn(`[7/7] MCP deps — npm install failed (${e.message.split('\n')[0]}); install manually with \`npm install\``);
1057
+ }
1058
+ } else {
1059
+ ok('[7/7] MCP deps — already present');
1060
+ }
1061
+
1062
+ // (b) register/start under PM2 (tolerate pm2 missing)
1063
+ let pm2Ok = false;
1064
+ try { execSync('pm2 -v', { stdio: 'pipe' }); pm2Ok = true; } catch { pm2Ok = false; }
1065
+
1066
+ if (!pm2Ok) {
1067
+ warn('[7/7] MCP server — pm2 not found; start manually:');
1068
+ info(` PORT=${MCP_PORT} pm2 start ${binPath} --name ${MCP_NAME}`);
1069
+ } else {
1070
+ let registered = false;
1071
+ try {
1072
+ const jlist = JSON.parse(execSync('pm2 jlist', { encoding: 'utf8', stdio: 'pipe' }));
1073
+ registered = Array.isArray(jlist) && jlist.some((p) => p.name === MCP_NAME);
1074
+ } catch {}
1075
+ try {
1076
+ if (registered) {
1077
+ execSync(`pm2 restart ${MCP_NAME} --update-env`, { cwd: repoRoot, stdio: 'pipe', env: { ...process.env, PORT: MCP_PORT } });
1078
+ ok(`[7/7] MCP server — pm2 restarted ${MCP_NAME} (PORT=${MCP_PORT})`);
1079
+ } else {
1080
+ execSync(`pm2 start "${binPath}" --name ${MCP_NAME}`, { cwd: repoRoot, stdio: 'pipe', env: { ...process.env, PORT: MCP_PORT } });
1081
+ ok(`[7/7] MCP server — pm2 started ${MCP_NAME} (PORT=${MCP_PORT})`);
1082
+ }
1083
+ } catch (e) {
1084
+ warn(`[7/7] MCP server — pm2 start/restart failed (${e.message.split('\n')[0]})`);
1085
+ info(` PORT=${MCP_PORT} pm2 start ${binPath} --name ${MCP_NAME}`);
1086
+ }
1087
+ }
1088
+
1089
+ // (c) print the claude.ai connector line
1090
+ info(` claude.ai connector: ${connector} (Auth: none URL is the shared secret)`);
1091
+ } catch (e) {
1092
+ warn(`[7/7] MCP server — skipped (${e.message.split('\n')[0]})`);
1093
+ }
1094
+ }
1095
+
1096
+ // ── Preflight ─────────────────────────────────────────────────────────────────
1097
+ function runPreflight() {
1098
+ const nodeMajor = parseInt(process.versions.node.split('.')[0], 10);
1099
+ if (nodeMajor < 18) { fail(`Node.js >= 18 required — found v${process.versions.node}`); process.exit(1); }
1100
+ ok(`Node.js v${process.versions.node}`);
1101
+ try {
1102
+ execSync('curl -s --max-time 2 http://127.0.0.1:52437/ping', { stdio: 'pipe' });
1103
+ ok('clauth daemon is running');
1104
+ } catch {
1105
+ warn('clauth daemon not responding — start it before using credential-dependent commands');
1106
+ }
1107
+ }
1108
+
1109
+ // ── Commands listing ──────────────────────────────────────────────────────────
1110
+ function listCommands() {
1111
+ const cmdsDir = path.join(repoRoot, 'commands');
1112
+ if (!fs.existsSync(cmdsDir)) return;
1113
+ const files = fs.readdirSync(cmdsDir).filter(f => f.endsWith('.md')).sort();
1114
+ const plugin = readJson(path.join(repoRoot, '.claude-plugin', 'plugin.json'), {});
1115
+ const skillCount = Array.isArray(plugin.skills_meta)
1116
+ ? plugin.skills_meta.length
1117
+ : (plugin.skills_meta && typeof plugin.skills_meta === 'object' ? Object.keys(plugin.skills_meta).length : null);
1118
+ console.log('');
1119
+ if (skillCount !== null) {
1120
+ console.log(` \x1b[32mAvailable MCP skills (${skillCount}) and /rdc:* command shorthands (${files.length}):\x1b[0m`);
1121
+ console.log(' Use MCP tools rdc_skill_list, rdc_skill_search, and rdc_skill_get for the full skill catalog.');
1122
+ } else {
1123
+ console.log(` \x1b[32mAvailable /rdc:* command shorthands (${files.length}):\x1b[0m`);
1124
+ }
1125
+ console.log('');
1126
+ const COL = 18;
1127
+ for (const f of files) {
1128
+ const name = 'rdc:' + f.replace(/\.md$/, '');
1129
+ const fm = readFrontmatter(path.join(cmdsDir, f));
1130
+ const desc = (fm.description || '').replace(/\n/g, ' ').replace(/\s+/g, ' ').trim();
1131
+ const short = desc.length > 70 ? desc.slice(0, 70) + '' : desc;
1132
+ const pad = ' '.repeat(Math.max(1, COL - name.length));
1133
+ console.log(` \x1b[36m/${name}\x1b[0m${pad}${short}`);
1134
+ }
1135
+ console.log('');
1136
+ }
1137
+
1138
+ // ── Migrate helper ────────────────────────────────────────────────────────────
1139
+ async function runMigrate(projectRoot) {
1140
+ const absRoot = path.resolve(projectRoot);
1141
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1142
+ const ask = q => new Promise(resolve => rl.question(q, resolve));
1143
+
1144
+ console.log('\n \x1b[35mrdc-skills Migrate to .rdc/ Layout\x1b[0m\n');
1145
+ console.log(` Project root: ${absRoot}\n`);
1146
+
1147
+ const candidates = [
1148
+ { src: 'docs/guides', dst: '.rdc/guides' }, { src: 'docs/plans', dst: '.rdc/plans' },
1149
+ { src: 'docs/reports', dst: '.rdc/reports' }, { src: 'docs/research', dst: '.rdc/research' },
1150
+ ].filter(c => fs.existsSync(path.join(absRoot, c.src)));
1151
+
1152
+ if (candidates.length === 0) { info('No migratable directories found.'); rl.close(); return; }
1153
+ console.log(' Found:');
1154
+ candidates.forEach(c => console.log(` ${c.src}`));
1155
+ console.log('');
1156
+
1157
+ for (const c of candidates) {
1158
+ const srcAbs = path.join(absRoot, c.src);
1159
+ const dstAbs = path.join(absRoot, c.dst);
1160
+ const ans = await ask(` Move ${c.src} ${c.dst}? [Y/n]: `);
1161
+ if (ans.toLowerCase() === 'n') { info(`Skipped ${c.src}`); continue; }
1162
+ if (fs.existsSync(dstAbs)) {
1163
+ warn(`${c.dst} exists merging`);
1164
+ for (const f of fs.readdirSync(srcAbs)) {
1165
+ const sf = path.join(srcAbs, f), df = path.join(dstAbs, f);
1166
+ if (!fs.existsSync(df)) fs.renameSync(sf, df);
1167
+ else warn(` Skipped ${f} (exists in dst)`);
1168
+ }
1169
+ } else {
1170
+ fs.mkdirSync(path.dirname(dstAbs), { recursive: true });
1171
+ fs.renameSync(srcAbs, dstAbs);
1172
+ }
1173
+ ok(`Moved ${c.src} ${c.dst}`);
1174
+ }
1175
+ rl.close();
1176
+ }
1177
+
1178
+ // ── Main ──────────────────────────────────────────────────────────────────────
1179
+ async function main() {
1180
+ if (doMigrate) { await runMigrate(migratePath); return; }
1181
+
1182
+ const bannerVersion = (readJson(path.join(repoRoot, 'package.json')).version || '?');
1183
+ const bannerLine = `║ install-rdc-skills v${bannerVersion}`;
1184
+ const bannerPadded = bannerLine.padEnd(41) + '║';
1185
+ console.log('');
1186
+ console.log(' \x1b[32m╔═══════════════════════════════════════╗\x1b[0m');
1187
+ console.log(` \x1b[32m${bannerPadded}\x1b[0m`);
1188
+ console.log(' \x1b[32m╚═══════════════════════════════════════╝\x1b[0m');
1189
+ console.log('');
1190
+ console.log(` CLAUDE_HOME : ${claudeHome}`);
1191
+ console.log(` Plugin root : ${repoRoot}`);
1192
+ console.log(` Profile : ${installProfile}${profileArg === 'auto' ? ' (auto)' : ''}`);
1193
+ console.log('');
1194
+
1195
+ if (!fs.existsSync(claudeHome)) {
1196
+ fs.mkdirSync(claudeHome, { recursive: true });
1197
+ warn(`CLAUDE_HOME did not exist — created ${claudeHome}`);
1198
+ }
1199
+ if (!fs.existsSync(settingsPath)) {
1200
+ writeJson(settingsPath, {});
1201
+ info(` created settings.json`);
1202
+ }
1203
+
1204
+ // 0. Pull latest
1205
+ try {
1206
+ const before = execSync('git rev-parse HEAD', { cwd: repoRoot, encoding: 'utf8', stdio: 'pipe' }).trim();
1207
+ execSync('git pull --ff-only', { cwd: repoRoot, stdio: 'pipe' });
1208
+ const after = execSync('git rev-parse HEAD', { cwd: repoRoot, encoding: 'utf8', stdio: 'pipe' }).trim();
1209
+ ok(`[0/6] git pull — ${before === after ? after.slice(0, 7) : `${before.slice(0,7)} → ${after.slice(0,7)}`}`);
1210
+ } catch {
1211
+ warn('[0/6] git pull failed — installing from local copy');
1212
+ }
1213
+
1214
+ // Read version + git SHA after pull so plugin caches and the MCP install do
1215
+ // not stamp the pre-update checkout.
1216
+ const pkg = readJson(path.join(repoRoot, 'package.json'));
1217
+ const version = pkg.version || '0.7.0';
1218
+ let gitSha = '';
1219
+ try { gitSha = execSync('git rev-parse HEAD', { cwd: repoRoot, encoding: 'utf8', stdio: 'pipe' }).trim(); } catch {}
1220
+
1221
+ // 0.5a. User-skills cleanup — remove any rdc: skills from ~/.claude/skills/user/
1222
+ // (older installer versions wrote there; plugin cache is the only authoritative source)
1223
+ {
1224
+ const userSkillsDir = path.join(claudeHome, 'skills', 'user');
1225
+ const purged = cleanUserSkills(userSkillsDir);
1226
+ if (purged > 0) ok(`[0.5a] Skills cleanup — removed ${purged} stale rdc: skill(s) from skills/user/`);
1227
+ // Also scan the parent ~/.claude/skills/ for any flat-file rdc orphans.
1228
+ const skillsRoot = path.join(claudeHome, 'skills');
1229
+ const rootPurged = cleanGlobalSkillsRoot(skillsRoot);
1230
+ if (rootPurged > 0) ok(`[0.5a] Skills cleanup — removed ${rootPurged} stale rdc: file(s) from skills/`);
1231
+ }
1232
+
1233
+ // 0.5b. Stale hook cleanup remove hooks we no longer ship
1234
+ {
1235
+ const staleRemoved = cleanStaleHooks(hooksDst);
1236
+ if (staleRemoved > 0) ok(`[0.5b] Hook cleanup removed ${staleRemoved} orphaned hook file(s)`);
1237
+ const shimRemoved = cleanProjectHookifyShims(codexRoot);
1238
+ if (shimRemoved > 0) ok(`[0.5b] Hook cleanup — removed ${shimRemoved} project-local work-item hookify shim(s)`);
1239
+ }
1240
+
1241
+ // 0.5. Legacy cleanup — remove old commands/rdc/ (pre-plugin-system format)
1242
+ const legacyCommandsDir = path.join(claudeHome, 'commands', 'rdc');
1243
+ if (fs.existsSync(legacyCommandsDir)) {
1244
+ fs.rmSync(legacyCommandsDir, { recursive: true, force: true });
1245
+ ok('[0.5] Cleanup — removed legacy commands/rdc/');
1246
+ }
1247
+ // Also remove extraKnownMarketplaces.rdc-skills from settings.json to prevent
1248
+ // Claude Code from syncing a directory-source marketplace back to known_marketplaces.json
1249
+ // (which causes skills to load from both the cache AND the live source directory)
1250
+ {
1251
+ const st = readJson(settingsPath);
1252
+ if (st.extraKnownMarketplaces && st.extraKnownMarketplaces[MARKETPLACE]) {
1253
+ delete st.extraKnownMarketplaces[MARKETPLACE];
1254
+ if (Object.keys(st.extraKnownMarketplaces).length === 0) delete st.extraKnownMarketplaces;
1255
+ writeJson(settingsPath, st);
1256
+ ok('[0.5] Cleanup — removed extraKnownMarketplaces.rdc-skills from settings.json');
1257
+ }
1258
+ }
1259
+
1260
+ // 1. CLI registration
1261
+ const cliCacheDir = registerCLI(version, gitSha);
1262
+ ok(`[1/6] CLI plugin — ${PLUGIN_KEY} v${version}`);
1263
+ info(` cache : ${cliCacheDir}`);
1264
+
1265
+ // 2. Cowork registration
1266
+ const coworkCount = registerCowork(version, gitSha);
1267
+ if (coworkCount > 0) {
1268
+ ok(`[2/6] Cowork — registered in ${coworkCount} workspace(s)`);
1269
+ } else {
1270
+ warn('[2/6] Cowork — no Desktop workspaces found (open Claude Desktop once to create them)');
1271
+ }
1272
+
1273
+ // 2.5. Codex registration
1274
+ const codexTargets = findCodexTargets();
1275
+ if (codexTargets.length > 0) {
1276
+ let copiedTotal = 0;
1277
+ let removedTotal = 0;
1278
+ for (const target of codexTargets) {
1279
+ const { removed, copied } = registerCodexTarget(target.targetDir);
1280
+ copiedTotal += copied;
1281
+ removedTotal += removed;
1282
+ info(` ${target.label.padEnd(15)}: ${target.targetDir} (${copied} installed, ${removed} stale removed)`);
1283
+ }
1284
+ ok(`[2.5] Codex — ${copiedTotal} skill install(s), ${removedTotal} stale removed across ${codexTargets.length} target(s)`);
1285
+ } else {
1286
+ info('[2.5] Codex — skipped (no Codex skill dirs found; use --codex-root or --codex-skill-dir)');
1287
+ }
1288
+
1289
+ // 2.6. Register the rdc-skills MCP endpoint globally (Claude Code + Codex) so
1290
+ // EVERY agent can reach the skills via MCP, not only where a project .mcp.json exists.
1291
+ const mcpReg = registerMcpEndpoints();
1292
+ if (mcpReg.length > 0) {
1293
+ ok(`[2.6] MCP — registered rdc-skills endpoint: ${mcpReg.join(', ')}`);
1294
+ } else {
1295
+ info('[2.6] MCP — rdc-skills endpoint already registered (claude + codex)');
1296
+ }
1297
+
1298
+ // If the live MCP is served from the global npm package, update that exact
1299
+ // install before restarting PM2. Windows otherwise holds the package tree open
1300
+ // and `npm install -g` can fail with EBUSY.
1301
+ syncGlobalMcpInstall(version);
1302
+
1303
+ // 2.7. Symlinks in regen-root/.claude/skills/ (FS MCP + claude.ai access)
1304
+ if (codexRoot) {
1305
+ const skillsLinkDir = path.join(codexRoot, '.claude', 'skills');
1306
+ const skillsSrc = path.join(repoRoot, 'skills');
1307
+ fs.mkdirSync(skillsLinkDir, { recursive: true });
1308
+ let linked = 0;
1309
+ for (const entry of fs.readdirSync(skillsSrc, { withFileTypes: true })) {
1310
+ if (!entry.isDirectory()) continue;
1311
+ if (!fs.existsSync(path.join(skillsSrc, entry.name, 'SKILL.md'))) continue;
1312
+ const linkPath = path.join(skillsLinkDir, entry.name);
1313
+ const target = path.join(skillsSrc, entry.name);
1314
+ try {
1315
+ if (fs.existsSync(linkPath) || (() => { try { fs.lstatSync(linkPath); return true; } catch { return false; } })()) {
1316
+ fs.rmSync(linkPath, { recursive: true, force: true });
1317
+ }
1318
+ } catch {}
1319
+ try {
1320
+ if (process.platform === 'win32') {
1321
+ fs.symlinkSync(target, linkPath, 'junction');
1322
+ } else {
1323
+ fs.symlinkSync(target, linkPath, 'dir');
1324
+ }
1325
+ linked++;
1326
+ } catch {}
1327
+ }
1328
+ if (linked > 0) {
1329
+ ok(`[2.7] Symlinks — ${linked} skill link(s) in ${skillsLinkDir}`);
1330
+ } else {
1331
+ info('[2.7] Symlinks — no rdc skill links created (skills may already be linked)');
1332
+ }
1333
+ const projectGuideCount = copyMissingProjectGuides(codexRoot);
1334
+ if (projectGuideCount > 0) {
1335
+ ok(`[2.8] Guides — ${projectGuideCount} missing guide(s) copied to ${path.join(codexRoot, '.rdc', 'guides')}`);
1336
+ } else {
1337
+ info('[2.8] Guides — project .rdc/guides already has base guide files or is absent');
1338
+ }
1339
+ } else {
1340
+ info('[2.7] Symlinks — skipped (no codex root found)');
1341
+ }
1342
+
1343
+ // 3. Hook files
1344
+ const hookCount = copyHookFiles(hooksSrc, hooksDst);
1345
+ // Fail the INSTALL rather than every future session: a hook that cannot load
1346
+ // exits non-zero with empty stdout on SessionStart, before its own repair path
1347
+ // can run.
1348
+ assertHooksLoadable(hooksDst);
1349
+ ok(`[3/6] Hook files — ${hookCount} file(s) → ${hooksDst} (load-verified)`);
1350
+
1351
+ // 4. Hook wiring
1352
+ if (skipHooks) {
1353
+ info('[4/6] Hook wiring — skipped (--skip-hooks)');
1354
+ } else {
1355
+ const settings = readJson(settingsPath);
1356
+ settings.hooks = buildHooksConfig(hooksDst, installProfile);
1357
+ writeJson(settingsPath, settings);
1358
+ ok(`[4/6] Hook wiring — ${settingsPath}`);
1359
+ }
1360
+
1361
+ // 4.5 Optional startup blocks
1362
+ if (shouldWriteStartupBlocks) {
1363
+ const startup = writeStartupBlocks(projectRoot, installProfile);
1364
+ if (startup.skipped) warn(`[4.5] Startup — skipped (${startup.skipped})`);
1365
+ else ok(`[4.5] Startup — wrote managed startup guide/block(s) under ${projectRoot}`);
1366
+ } else {
1367
+ info('[4.5] Startup — skipped (use --project-root <path> --write-startup-blocks)');
1368
+ }
1369
+
1370
+ // 5. Zip for claude.ai / distribution
1371
+ const zipPath = buildZip(version);
1372
+ if (zipPath) {
1373
+ ok(`[5/6] Plugin zip — ${zipPath}`);
1374
+ info(' claude.ai : optional plugin-upload artifact; MCP callers normally use the connector URL below');
1375
+ } else {
1376
+ warn('[5/6] Plugin zip build failed (zip/powershell missing?)');
1377
+ }
1378
+
1379
+ // 6. Preflight
1380
+ console.log('');
1381
+ console.log(' \x1b[36mPreflight:\x1b[0m');
1382
+ runPreflight();
1383
+
1384
+ // 6.5 Post-install verification duplication guard
1385
+ console.log('');
1386
+ console.log(' \x1b[36mPost-install verification:\x1b[0m');
1387
+ let verifyFailed = false;
1388
+ {
1389
+ const cacheBase = path.join(claudeHome, 'plugins', 'cache', MARKETPLACE, 'rdc-skills');
1390
+ const cacheDirs = fs.existsSync(cacheBase) ? fs.readdirSync(cacheBase) : [];
1391
+ if (cacheDirs.length === 1 && cacheDirs[0] === 'latest') {
1392
+ ok(`plugin cache : 1 dir (latest/)`);
1393
+ } else {
1394
+ fail(`plugin cache : expected exactly [latest/], found [${cacheDirs.join(', ')}]`);
1395
+ verifyFailed = true;
1396
+ }
1397
+ const ipPath = path.join(claudeHome, 'plugins', 'installed_plugins.json');
1398
+ const installed = readJson(ipPath, { plugins: {} });
1399
+ const rdcEntries = installed.plugins[PLUGIN_KEY] || [];
1400
+ if (rdcEntries.length === 1) {
1401
+ ok(`installed_plugins: 1 entry for ${PLUGIN_KEY}`);
1402
+ } else {
1403
+ fail(`installed_plugins: expected 1 entry, found ${rdcEntries.length}`);
1404
+ verifyFailed = true;
1405
+ }
1406
+ const userSkillsDir = path.join(claudeHome, 'skills', 'user');
1407
+ const stillThere = fs.existsSync(userSkillsDir)
1408
+ ? fs.readdirSync(userSkillsDir).filter(f => {
1409
+ const p = path.join(userSkillsDir, f);
1410
+ const skillFile = fs.statSync(p).isDirectory()
1411
+ ? ['SKILL.md','skill.md'].map(s => path.join(p, s)).find(fs.existsSync)
1412
+ : (f.endsWith('.md') ? p : null);
1413
+ if (!skillFile) return false;
1414
+ const fm = readFrontmatter(skillFile);
1415
+ return fm.name && fm.name.startsWith('rdc:');
1416
+ })
1417
+ : [];
1418
+ if (stillThere.length === 0) {
1419
+ ok(`skills/user/ : no rdc: orphans`);
1420
+ } else {
1421
+ fail(`skills/user/ : still has rdc: orphans: ${stillThere.join(', ')}`);
1422
+ verifyFailed = true;
1423
+ }
1424
+ }
1425
+ if (verifyFailed) {
1426
+ console.log('');
1427
+ fail('Post-install verification FAILED — duplicates or orphans remain. Investigate.');
1428
+ process.exit(2);
1429
+ }
1430
+
1431
+ // 7. MCP server registration (non-fatal — WARNs only, never aborts)
1432
+ console.log('');
1433
+ console.log(' \x1b[36mMCP server:\x1b[0m');
1434
+ try { registerMcpServer(); } catch (e) { warn(`[7/7] MCP server — unexpected error (${e.message})`); }
1435
+ try {
1436
+ await verifyLiveMcpCatalogFresh();
1437
+ } catch (e) {
1438
+ fail(`[7.5] MCP catalog — ${e.message}`);
1439
+ process.exit(2);
1440
+ }
1441
+
1442
+ // Done
1443
+ console.log('');
1444
+ console.log(' \x1b[32mDone!\x1b[0m');
1445
+ console.log('');
1446
+ console.log(' \x1b[33mNext steps:\x1b[0m');
1447
+ console.log(' CLI : restart Claude Code — /rdc:status to verify');
1448
+ console.log(' Cowork : restart Claude Desktop — /rdc:status in a new Cowork session');
1449
+ console.log(' claude.ai : no plugin upload needed for MCP — use the connector URL above');
1450
+ console.log(' dist/rdc-skills-plugin-v' + version + '.zip available if needed');
1451
+ console.log('');
1452
+ console.log(` Profile: ${installProfile}`);
1453
+ if (installProfile === 'core') {
1454
+ console.log(' Core hooks are portable: RDC output contract + foreground process + commit-message hygiene.');
1455
+ console.log(' Project services are not provisioned. Configure work items, credentials, deploys, and project guides before using infrastructure-heavy skills.');
1456
+ } else {
1457
+ console.log(' LIFEAI hooks are active: regen-root cwd lock, Supabase work-item gates, clauth-aware checks, and overnight queue guard.');
1458
+ }
1459
+ console.log(' Startup blocks: run with --project-root <path> --write-startup-blocks to add managed CLAUDE.md/AGENTS.md sections.');
1460
+
1461
+ listCommands();
1462
+
1463
+ console.log(' Docs: https://github.com/LIFEAI/rdc-skills#readme');
1464
+ console.log('');
1465
+ }
1466
+
1467
+ // Run ONLY when invoked directly. Exporting the copy helpers lets
1468
+ // scripts/probe-installed-hooks.mjs verify the real install path instead of
1469
+ // re-implementing it — a probe that copies its own way proves nothing about what
1470
+ // ships. Without this guard, requiring the module would run a full install.
1471
+ module.exports = { copyHookFiles, assertHooksLoadable, RDC_ENV_HOOK_TIMEOUT_SEC };
1472
+
1473
+ if (require.main === module) {
1474
+ main().catch(e => { fail(e.message); process.exit(1); });
1475
+ }