@lifeaitools/rdc-skills 0.25.2 → 0.25.3

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 (61) hide show
  1. package/.claude-plugin/plugin.json +1560 -1560
  2. package/.github/workflows/self-test.yml +34 -34
  3. package/CHANGELOG.md +322 -322
  4. package/MANIFEST.md +224 -224
  5. package/README.md +379 -379
  6. package/RELEASE.md +49 -0
  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/package.json +57 -57
  33. package/scripts/install-rdc-skills.js +1401 -1401
  34. package/scripts/self-test.mjs +1460 -1460
  35. package/scripts/validate-publish-manifests.js +502 -502
  36. package/skills/build/SKILL.md +559 -559
  37. package/skills/channel-formatter/SKILL.md +538 -538
  38. package/skills/collab/SKILL.md +239 -239
  39. package/skills/convert/SKILL.md +167 -167
  40. package/skills/deploy/SKILL.md +541 -541
  41. package/skills/design/SKILL.md +205 -205
  42. package/skills/env/SKILL.md +139 -141
  43. package/skills/fixit/SKILL.md +203 -203
  44. package/skills/handoff/SKILL.md +236 -236
  45. package/skills/housekeeping/SKILL.md +1 -1
  46. package/skills/onramp/SKILL.md +1459 -1459
  47. package/skills/overnight/SKILL.md +251 -251
  48. package/skills/plan/SKILL.md +345 -345
  49. package/skills/preplan/SKILL.md +90 -90
  50. package/skills/prototype/SKILL.md +150 -150
  51. package/skills/regen-media/SKILL.md +94 -94
  52. package/skills/release/SKILL.md +140 -140
  53. package/skills/report/SKILL.md +100 -100
  54. package/skills/review/SKILL.md +151 -151
  55. package/skills/self-test/SKILL.md +108 -108
  56. package/skills/status/SKILL.md +99 -99
  57. package/skills/tests/MATRIX.md +55 -55
  58. package/skills/tests/onramp.test.json +87 -87
  59. package/skills/tests/rdc-regen-media.test.json +29 -29
  60. package/skills/watch/SKILL.md +84 -84
  61. package/skills/workitems/SKILL.md +151 -151
@@ -1,1401 +1,1401 @@
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 server — pm2 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); });
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 server — pm2 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); });