@hanzlaa/rcode 3.4.23 → 3.4.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli/install.js CHANGED
@@ -55,6 +55,10 @@ const path = require('path');
55
55
  const crypto = require('crypto');
56
56
  const os = require('os');
57
57
 
58
+ // Atomic write helper (#687) + symlink-safe rmSync (#688) — protect against
59
+ // Ctrl+C mid-write and malicious symlink-traversal during dedup/cleanup.
60
+ const { writeFileAtomic, safeRmSync } = require(path.join(__dirname, 'lib', 'fsutil.cjs'));
61
+
58
62
  // Bundled packages — devDeps inlined by esbuild, loaded from node_modules in dev.
59
63
  const pc = require('picocolors');
60
64
  const { createSpinner } = require('nanospinner');
@@ -320,6 +324,11 @@ function detectIdeSignals(target) {
320
324
  * actually wanted cursor or gemini.
321
325
  */
322
326
  async function resolveIde(opts) {
327
+ // Issue #692: when the wizard has already collected opts.ides (interactive
328
+ // run from main()), resolveIde was re-prompting because it only checked
329
+ // opts.ideProvided (set by --ide flag, not by the wizard). Honor any
330
+ // pre-existing array result so we don't double-prompt.
331
+ if (Array.isArray(opts.ides) && opts.ides.length > 0) return opts.ides;
323
332
  if (opts.ideProvided) return [opts.ide]; // user passed --ide, respect it
324
333
  if (opts.noPrompt || opts.global) return ['claude']; // auto-install: always claude
325
334
  if (opts.yes || !process.stdin.isTTY) {
@@ -649,7 +658,7 @@ function seedStarterPlanning(target, projectName) {
649
658
  velocity_history: [],
650
659
  };
651
660
  fs.mkdirSync(path.dirname(rihalStateJson), { recursive: true });
652
- fs.writeFileSync(rihalStateJson, JSON.stringify(state, null, 2) + '\n');
661
+ writeFileAtomic(rihalStateJson, JSON.stringify(state, null, 2) + '\n');
653
662
  }
654
663
 
655
664
  return true;
@@ -724,7 +733,7 @@ function ensureRcodeGitignore(target, options = {}) {
724
733
  const gitignorePath = path.join(target, '.gitignore');
725
734
  try {
726
735
  if (!fs.existsSync(gitignorePath)) {
727
- fs.writeFileSync(gitignorePath, BLOCK);
736
+ writeFileAtomic(gitignorePath, BLOCK);
728
737
  return { action: 'created' };
729
738
  }
730
739
  const existing = fs.readFileSync(gitignorePath, 'utf8');
@@ -750,12 +759,12 @@ function ensureRcodeGitignore(target, options = {}) {
750
759
  if (existing.includes(BEGIN)) {
751
760
  const rewritten = spliceBlock(existing, BLOCK);
752
761
  if (rewritten !== null && rewritten !== existing) {
753
- fs.writeFileSync(gitignorePath, rewritten);
762
+ writeFileAtomic(gitignorePath, rewritten);
754
763
  return { action: 'updated' };
755
764
  }
756
765
  return { action: 'already-present' };
757
766
  }
758
- fs.writeFileSync(gitignorePath, existing + BLOCK);
767
+ writeFileAtomic(gitignorePath, existing + BLOCK);
759
768
  return { action: 'appended' };
760
769
  } catch (err) {
761
770
  return { action: 'skipped-error', error: err.message };
@@ -804,8 +813,7 @@ function ensureRcodePreCommitHook(target, options = {}) {
804
813
  fs.mkdirSync(hooksDir, { recursive: true });
805
814
 
806
815
  if (!fs.existsSync(hookPath)) {
807
- fs.writeFileSync(hookPath, `#!/bin/sh\n${BLOCK}`);
808
- fs.chmodSync(hookPath, 0o755);
816
+ writeFileAtomic(hookPath, `#!/bin/sh\n${BLOCK}`, { mode: 0o755 });
809
817
  return { action: 'created' };
810
818
  }
811
819
 
@@ -831,15 +839,13 @@ function ensureRcodePreCommitHook(target, options = {}) {
831
839
  if (existing.includes(BEGIN)) {
832
840
  const rewritten = spliceBlock(existing, BLOCK);
833
841
  if (rewritten !== null && rewritten !== existing) {
834
- fs.writeFileSync(hookPath, rewritten);
835
- fs.chmodSync(hookPath, 0o755);
842
+ writeFileAtomic(hookPath, rewritten, { mode: 0o755 });
836
843
  return { action: 'updated' };
837
844
  }
838
845
  return { action: 'already-present' };
839
846
  }
840
847
 
841
- fs.writeFileSync(hookPath, existing + BLOCK);
842
- fs.chmodSync(hookPath, 0o755);
848
+ writeFileAtomic(hookPath, existing + BLOCK, { mode: 0o755 });
843
849
  return { action: 'appended' };
844
850
  } catch (err) {
845
851
  return { action: 'skipped-error', error: err.message };
@@ -951,7 +957,8 @@ function installSkills(packageRoot, target, options = {}) {
951
957
  // Also remove the existing project copy (left over from previous
952
958
  // installs that didn't dedup) so it stops showing in the picker.
953
959
  if (fs.existsSync(dest)) {
954
- try { fs.rmSync(dest, { recursive: true, force: true }); } catch { /* non-fatal */ }
960
+ // #688 safeRmSync refuses to traverse symlinks pointing outside target.
961
+ try { safeRmSync(dest, target); } catch { /* non-fatal */ }
955
962
  }
956
963
  skippedGlobal++;
957
964
  continue;
@@ -1489,6 +1496,86 @@ async function install(opts) {
1489
1496
  return 2;
1490
1497
  }
1491
1498
 
1499
+ // Issue #691: file lock prevents concurrent installs from racing on the
1500
+ // same .rihal/_config/manifest.yaml + files-manifest.csv. Without it, two
1501
+ // parallel runs (two terminals, postinstall + manual install, etc.) can
1502
+ // each write a manifest the OTHER doesn't see → orphan sweep on the next
1503
+ // install deletes files the other run considered legit.
1504
+ let releaseLock = () => {};
1505
+ if (!opts.global) {
1506
+ const lockResult = acquireInstallLock(opts.target);
1507
+ if (!lockResult.ok) {
1508
+ console.log('');
1509
+ console.log(' ' + warn(`Another install is already running here (PID ${lockResult.pid}).`));
1510
+ console.log(' ' + dim(` Lock: ${lockResult.lockPath}`));
1511
+ console.log(' ' + dim(' If the other process crashed, delete the lock file and retry:'));
1512
+ console.log(' ' + dim(` rm ${lockResult.lockPath}`));
1513
+ console.log('');
1514
+ return 3;
1515
+ }
1516
+ releaseLock = lockResult.release;
1517
+ // Make sure the lock is released even if install throws unexpectedly.
1518
+ process.on('exit', releaseLock);
1519
+ }
1520
+
1521
+ try {
1522
+ return await installInner(opts);
1523
+ } finally {
1524
+ releaseLock();
1525
+ process.removeListener('exit', releaseLock);
1526
+ }
1527
+ }
1528
+
1529
+ /**
1530
+ * Acquire an exclusive install lock at .rihal/.install.lock (issue #691).
1531
+ *
1532
+ * Returns:
1533
+ * { ok: true, release: () => void } lock acquired
1534
+ * { ok: false, pid: number, lockPath: string } another process holds it
1535
+ *
1536
+ * Stale-lock detection: if the recorded PID is not alive, the lock is
1537
+ * reclaimed automatically.
1538
+ */
1539
+ function acquireInstallLock(target) {
1540
+ const lockDir = path.join(target, '.rihal');
1541
+ const lockPath = path.join(lockDir, '.install.lock');
1542
+ try {
1543
+ fs.mkdirSync(lockDir, { recursive: true });
1544
+ } catch { /* fall through; openSync will fail with a clearer error */ }
1545
+
1546
+ function isAlive(pid) {
1547
+ try { process.kill(pid, 0); return true; } catch { return false; }
1548
+ }
1549
+
1550
+ for (let attempt = 0; attempt < 2; attempt++) {
1551
+ try {
1552
+ const fd = fs.openSync(lockPath, 'wx'); // exclusive create
1553
+ fs.writeSync(fd, String(process.pid));
1554
+ fs.closeSync(fd);
1555
+ return {
1556
+ ok: true,
1557
+ release: () => {
1558
+ try { fs.unlinkSync(lockPath); } catch {}
1559
+ },
1560
+ };
1561
+ } catch (err) {
1562
+ if (err.code !== 'EEXIST') throw err;
1563
+ // Lock exists — check if holder is alive.
1564
+ let pid = 0;
1565
+ try { pid = parseInt(fs.readFileSync(lockPath, 'utf8'), 10); } catch {}
1566
+ if (pid && !isAlive(pid)) {
1567
+ // Stale lock — remove and retry once.
1568
+ try { fs.unlinkSync(lockPath); } catch {}
1569
+ continue;
1570
+ }
1571
+ return { ok: false, pid, lockPath };
1572
+ }
1573
+ }
1574
+ // Should be unreachable, but degrade gracefully.
1575
+ return { ok: false, pid: 0, lockPath };
1576
+ }
1577
+
1578
+ async function installInner(opts) {
1492
1579
  const pkgVersion = readPackageVersion();
1493
1580
 
1494
1581
  // Header banner — only shown for interactive runs to keep CI/non-TTY logs terse.
@@ -1875,10 +1962,11 @@ async function install(opts) {
1875
1962
  for (const f of projectCommandFiles) {
1876
1963
  fs.unlinkSync(path.join(projectClaudeCommands, f));
1877
1964
  }
1878
- // Remove rihal/ subdirectory (vscode-style commands)
1965
+ // Remove rihal/ subdirectory (vscode-style commands).
1966
+ // #688 — safeRmSync refuses to traverse out-of-target symlinks.
1879
1967
  const rihalSubdir = path.join(projectClaudeCommands, 'rihal');
1880
1968
  if (fs.existsSync(rihalSubdir)) {
1881
- fs.rmSync(rihalSubdir, { recursive: true, force: true });
1969
+ safeRmSync(rihalSubdir, opts.target);
1882
1970
  }
1883
1971
  const projectAgentsDir = path.join(opts.target, '.claude', 'agents');
1884
1972
  if (fs.existsSync(projectAgentsDir)) {
@@ -1927,7 +2015,7 @@ async function install(opts) {
1927
2015
  // Write .rihal/config.yaml (user_name, project_name, language, mode)
1928
2016
  // Note: config.yaml is user data and should NOT be overwritten on --force (unless --reset)
1929
2017
  if (!fs.existsSync(configPath)) {
1930
- fs.writeFileSync(configPath, generateConfigYaml(opts));
2018
+ writeFileAtomic(configPath, generateConfigYaml(opts));
1931
2019
  } else {
1932
2020
  // Issue #685: re-install path. config.yaml is preserved BUT if the user
1933
2021
  // just changed commit_planning via the prompt/flag, .gitignore will be
@@ -1942,12 +2030,12 @@ async function install(opts) {
1942
2030
  const currentInFile = match ? match[1] === 'true' : null;
1943
2031
  if (match && currentInFile !== desired) {
1944
2032
  const updated = before.replace(re, `commit_planning: ${desired}`);
1945
- fs.writeFileSync(configPath, updated);
2033
+ writeFileAtomic(configPath, updated);
1946
2034
  console.log(' ' + dim(`Updated commit_planning in config.yaml (${currentInFile} → ${desired}) — closes #685.`));
1947
2035
  } else if (!match) {
1948
2036
  // Older config without the key — append it so the next read finds it.
1949
2037
  const appended = before.replace(/\n*$/, '') + `\ncommit_planning: ${desired}\n`;
1950
- fs.writeFileSync(configPath, appended);
2038
+ writeFileAtomic(configPath, appended);
1951
2039
  }
1952
2040
  } catch { /* best-effort — never fail install on this */ }
1953
2041
  }
@@ -1973,7 +2061,7 @@ async function install(opts) {
1973
2061
  .replace(/__PROJECT_NAME__/g, opts.projectName)
1974
2062
  .replace(/__INSTALL_DATE__/g, now);
1975
2063
  ensureDir(path.dirname(stateDest));
1976
- fs.writeFileSync(stateDest, stateContent);
2064
+ writeFileAtomic(stateDest, stateContent);
1977
2065
  }
1978
2066
  }
1979
2067
 
@@ -2158,10 +2246,13 @@ async function install(opts) {
2158
2246
  // Issue #669 — when global precedence applied (project copies were
2159
2247
  // intentionally removed), count from ~/.claude/ instead so the summary
2160
2248
  // doesn't lie about the install state.
2161
- if (agentCount === 0 || commandCount === 0) {
2162
- const os = require('os');
2249
+ // Issue #689: skills count gets the same fallback. After dedup (#679)
2250
+ // the project skills folder may have only sidebar stubs while ~/.claude/
2251
+ // has the real skills — health check should see those.
2252
+ if (agentCount === 0 || commandCount === 0 || skillsInstalled < 20) {
2163
2253
  const homeAgents = path.join(os.homedir(), '.claude/agents');
2164
2254
  const homeCommands = path.join(os.homedir(), '.claude/commands');
2255
+ const homeSkills = path.join(os.homedir(), '.claude/skills');
2165
2256
  if (agentCount === 0 && fs.existsSync(homeAgents)) {
2166
2257
  const n = fs.readdirSync(homeAgents).filter(f => f.startsWith('rihal-') && f.endsWith('.md')).length;
2167
2258
  if (n > 0) { agentCount = n; agentsFromGlobal = true; }
@@ -2170,6 +2261,13 @@ async function install(opts) {
2170
2261
  const n = fs.readdirSync(homeCommands).filter(f => f.startsWith('rihal-') && f.endsWith('.md')).length;
2171
2262
  if (n > 0) { commandCount = n; commandsFromGlobal = true; }
2172
2263
  }
2264
+ if (skillsInstalled < 20 && fs.existsSync(homeSkills)) {
2265
+ try {
2266
+ const globalSkillCount = fs.readdirSync(homeSkills, { withFileTypes: true })
2267
+ .filter(d => d.isDirectory() && d.name.startsWith('rihal-')).length;
2268
+ if (globalSkillCount > skillsInstalled) skillsInstalled = globalSkillCount;
2269
+ } catch { /* non-fatal */ }
2270
+ }
2173
2271
  }
2174
2272
  } catch {}
2175
2273
 
@@ -2250,6 +2348,36 @@ function runInstallHealthCheck(target, counts) {
2250
2348
  const { execFileSync } = require('child_process');
2251
2349
  let fails = 0;
2252
2350
 
2351
+ // Issue #689: thresholds were hardcoded at 20 ("expected ≥ 20 agents",
2352
+ // "expected ≥ 20 skills", "expected ≥ 20 commands"). If the package ever
2353
+ // ships fewer than 20 of any kind, the health check fails on every install
2354
+ // even when the install actually succeeded. Worse: if the package ships
2355
+ // 22 agents and an install lands 21 (one corrupt copy), the >= 20 threshold
2356
+ // passes — false green.
2357
+ //
2358
+ // Source the expected counts from the package manifest itself. The verifier
2359
+ // in cli/lib/manifest.cjs already does this; we mirror its result here.
2360
+ let expected = { agents: 20, skills: 20, commands: 20 };
2361
+ try {
2362
+ const { readPackageManifest } = require(path.join(__dirname, 'lib', 'manifest.cjs'));
2363
+ const pkgManifest = readPackageManifest(PACKAGE_ROOT);
2364
+ if (pkgManifest && pkgManifest.agents instanceof Set && pkgManifest.actions instanceof Set) {
2365
+ // Tolerate ~10% loss vs source — global precedence, .local.md
2366
+ // overrides, and intentionally-skipped sidebar stubs all reduce the
2367
+ // count without indicating a failure.
2368
+ const tolerate = (n) => Math.max(1, Math.floor(n * 0.9));
2369
+ expected.agents = tolerate(pkgManifest.agents.size);
2370
+ expected.skills = tolerate(pkgManifest.actions.size);
2371
+ // Commands count comes from rihal/commands/. No bundled enumerator
2372
+ // exists; reuse the agents threshold as a proxy floor.
2373
+ const commandsDir = path.join(PACKAGE_ROOT, 'rihal', 'commands');
2374
+ if (fs.existsSync(commandsDir)) {
2375
+ const cmdCount = fs.readdirSync(commandsDir).filter(f => f.endsWith('.md') && !f.startsWith('_')).length;
2376
+ expected.commands = tolerate(cmdCount);
2377
+ }
2378
+ }
2379
+ } catch { /* keep hardcoded fallback */ }
2380
+
2253
2381
  function check(label, fn) {
2254
2382
  try {
2255
2383
  const out = fn();
@@ -2283,14 +2411,16 @@ function runInstallHealthCheck(target, counts) {
2283
2411
  });
2284
2412
 
2285
2413
  check('agents installed', () => {
2286
- if ((counts.agentCount || 0) < 20) throw new Error(`only ${counts.agentCount} agents (expected ≥ 20)`);
2414
+ if ((counts.agentCount || 0) < expected.agents) {
2415
+ throw new Error(`only ${counts.agentCount} agents (expected ≥ ${expected.agents})`);
2416
+ }
2287
2417
  return `${counts.agentCount}`;
2288
2418
  });
2289
2419
 
2290
2420
  check('skills + commands installed', () => {
2291
2421
  const issues = [];
2292
- if ((counts.skillsInstalled || 0) < 20) issues.push(`${counts.skillsInstalled} skills`);
2293
- if ((counts.commandCount || 0) < 20) issues.push(`${counts.commandCount} commands`);
2422
+ if ((counts.skillsInstalled || 0) < expected.skills) issues.push(`${counts.skillsInstalled} skills (expected ≥ ${expected.skills})`);
2423
+ if ((counts.commandCount || 0) < expected.commands) issues.push(`${counts.commandCount} commands (expected ≥ ${expected.commands})`);
2294
2424
  if (issues.length) throw new Error(`low count: ${issues.join(', ')}`);
2295
2425
  return `${counts.skillsInstalled} skills + ${counts.commandCount} commands`;
2296
2426
  });
@@ -2378,6 +2508,11 @@ async function runInstallWizard(opts) {
2378
2508
  });
2379
2509
  if (isCancel(editorChoices)) { cancel('Installation cancelled.'); process.exit(0); }
2380
2510
  opts.ides = editorChoices;
2511
+ // Issue #692: keep opts.ide and opts.ides consistent so downstream callers
2512
+ // that historically read either field see the same answer. Mark provided
2513
+ // so any later resolveIde call exits early.
2514
+ opts.ide = editorChoices[0];
2515
+ opts.ideProvided = true;
2381
2516
 
2382
2517
  // ── 3. Communication language ─────────────────────────────────────────
2383
2518
  const langChoice = await select({
@@ -71,7 +71,73 @@ function writeJsonAtomic(filePath, obj, opts = {}) {
71
71
  writeFileAtomic(filePath, content, opts);
72
72
  }
73
73
 
74
+ /**
75
+ * Safe recursive remove (issue #688).
76
+ *
77
+ * `fs.rmSync(path, { recursive: true, force: true })` is fine when `path`
78
+ * is a directory we control, but if it has been replaced with a symlink to
79
+ * `/`, `~/`, or any directory outside the project root, the recursive walk
80
+ * follows it and deletes outside the intended scope. Three sites in the
81
+ * installer / uninstaller pass user-controlled paths to that pattern.
82
+ *
83
+ * This wrapper:
84
+ * 1. lstats the path. If it is a symlink, unlinks the link only — never
85
+ * traverses it.
86
+ * 2. realpaths it and asserts the resolved path is INSIDE `projectRoot`.
87
+ * If not, refuses and returns { ok: false, reason: 'outside-root' }.
88
+ * 3. otherwise calls fs.rmSync recursively.
89
+ *
90
+ * Symlinks INSIDE the directory are still followed by Node's rmSync — that
91
+ * is unavoidable with the recursive flag. The threat model addressed here
92
+ * is a single top-level symlink swap (e.g. `.rihal -> /`), not deep nested
93
+ * symlinks. Defense in depth, not a sandbox.
94
+ *
95
+ * @param {string} targetPath path to remove
96
+ * @param {string} projectRoot absolute path that the target must be inside
97
+ * @returns {{ok: boolean, reason?: string}}
98
+ */
99
+ function safeRmSync(targetPath, projectRoot) {
100
+ let stats;
101
+ try {
102
+ stats = fs.lstatSync(targetPath);
103
+ } catch (err) {
104
+ if (err.code === 'ENOENT') return { ok: true, reason: 'missing' };
105
+ return { ok: false, reason: `lstat: ${err.message}` };
106
+ }
107
+
108
+ // Top-level symlink? Just unlink the link, never traverse.
109
+ if (stats.isSymbolicLink()) {
110
+ try {
111
+ fs.unlinkSync(targetPath);
112
+ return { ok: true, reason: 'symlink-unlinked' };
113
+ } catch (err) {
114
+ return { ok: false, reason: `unlink: ${err.message}` };
115
+ }
116
+ }
117
+
118
+ // Real path must stay inside the project root.
119
+ const root = path.resolve(projectRoot);
120
+ let resolved;
121
+ try {
122
+ resolved = fs.realpathSync(targetPath);
123
+ } catch (err) {
124
+ return { ok: false, reason: `realpath: ${err.message}` };
125
+ }
126
+ const relative = path.relative(root, resolved);
127
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
128
+ return { ok: false, reason: 'outside-root' };
129
+ }
130
+
131
+ try {
132
+ fs.rmSync(resolved, { recursive: true, force: true });
133
+ return { ok: true };
134
+ } catch (err) {
135
+ return { ok: false, reason: `rmSync: ${err.message}` };
136
+ }
137
+ }
138
+
74
139
  module.exports = {
75
140
  writeFileAtomic,
76
141
  writeJsonAtomic,
142
+ safeRmSync,
77
143
  };
package/cli/uninstall.js CHANGED
@@ -28,7 +28,7 @@ const fs = require('fs');
28
28
  const path = require('path');
29
29
  const { spawnSync } = require('child_process');
30
30
  const { askConfirm, PromptAbortError } = require('./lib/prompts.cjs');
31
- const { writeFileAtomic } = require('./lib/fsutil.cjs');
31
+ const { writeFileAtomic, safeRmSync } = require('./lib/fsutil.cjs');
32
32
 
33
33
  function parseArgs(args) {
34
34
  const opts = {
@@ -75,11 +75,18 @@ function isLocalOverride(name) {
75
75
  function removeMatching(dir, predicate) {
76
76
  if (!fs.existsSync(dir)) return 0;
77
77
  let count = 0;
78
+ // Issue #688: project root for symlink-traversal guard. Anything we
79
+ // remove from inside the project must resolve to within the project.
80
+ const projectRoot = path.resolve(process.cwd());
78
81
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
79
82
  if (isLocalOverride(entry.name)) continue; // #382 — never remove user overrides
80
83
  if (!predicate(entry.name)) continue;
81
84
  const full = path.join(dir, entry.name);
82
- fs.rmSync(full, { recursive: true, force: true });
85
+ const result = safeRmSync(full, projectRoot);
86
+ if (!result.ok && result.reason === 'outside-root') {
87
+ console.log(` ⚠ refused to remove ${full} — symlink resolves outside project root`);
88
+ continue;
89
+ }
83
90
  count++;
84
91
  }
85
92
  return count;
@@ -213,35 +220,29 @@ function buildPlan(cwd, editors) {
213
220
  }
214
221
 
215
222
  /**
216
- * List of action-skill names the installer places in .claude/skills/.
217
- * These do NOT start with `rihal-` (e.g., `rihal-domain-research` does, but
218
- * for safety we also keep a known list).
223
+ * Names of action-skill directories the installer places under .claude/skills/.
224
+ *
225
+ * Issue #693: this used to be a hardcoded array of 23 names that drifted from
226
+ * the source the moment anyone added or removed a skill in `rihal/skills/`.
227
+ * We now derive it from the package's own manifest (cli/lib/manifest.cjs)
228
+ * with a static fallback for the rare case where the manifest module isn't
229
+ * resolvable from the uninstall context.
219
230
  */
220
- const KNOWN_ACTION_SKILLS = [
221
- 'rihal-check-implementation-readiness',
222
- 'rihal-code-review',
223
- 'rihal-correct-course',
224
- 'rihal-create-architecture',
225
- 'rihal-create-epics-and-stories',
226
- 'rihal-create-prd',
227
- 'rihal-create-story',
228
- 'rihal-create-ux-design',
229
- 'rihal-dev-story',
230
- 'rihal-document-project',
231
- 'rihal-domain-research',
232
- 'rihal-edit-prd',
233
- 'rihal-frontend-design',
234
- 'rihal-generate-project-context',
235
- 'rihal-market-research',
236
- 'rihal-product-brief',
237
- 'rihal-qa-generate-e2e-tests',
238
- 'rihal-retrospective',
239
- 'rihal-sprint-planning',
240
- 'rihal-sprint-status',
241
- 'rihal-technical-research',
242
- 'rihal-validate-prd',
243
- 'rihal-clone-website',
244
- ];
231
+ function discoverKnownActionSkills() {
232
+ try {
233
+ const { readPackageManifest } = require(path.join(__dirname, 'lib', 'manifest.cjs'));
234
+ const packageRoot = path.resolve(__dirname, '..');
235
+ const pkg = readPackageManifest(packageRoot);
236
+ if (pkg && pkg.actions instanceof Set && pkg.actions.size > 0) {
237
+ return Array.from(pkg.actions);
238
+ }
239
+ } catch { /* fall through to static list */ }
240
+ // Static fallback — kept minimal, only the names that don't start with
241
+ // 'rihal-' would actually need this list since we already match
242
+ // 'rihal-*' via prefix. This is defensive only.
243
+ return [];
244
+ }
245
+ const KNOWN_ACTION_SKILLS = discoverKnownActionSkills();
245
246
 
246
247
  function isKnownSkillName(name) {
247
248
  return KNOWN_ACTION_SKILLS.includes(name);
@@ -418,9 +419,15 @@ async function runUninstall(args) {
418
419
  const opts = parseArgs(args);
419
420
  const cwd = process.cwd();
420
421
 
422
+ // Issue #693: keep the IDE list in sync with the installer. The installer
423
+ // ships claude/cursor/gemini/vscode/antigravity. The previous uninstaller
424
+ // list (claude/cursor/windsurf/antigravity) was missing gemini + vscode
425
+ // and included windsurf (which the installer never writes). Result: a
426
+ // user with vscode-style commands could never `rcode uninstall`.
427
+ const SUPPORTED_EDITORS = ['claude', 'cursor', 'gemini', 'vscode', 'antigravity'];
421
428
  const editors = opts.editor
422
- ? (opts.editor === 'all' ? ['claude', 'cursor', 'windsurf', 'antigravity'] : [opts.editor])
423
- : ['claude', 'cursor', 'windsurf', 'antigravity'];
429
+ ? (opts.editor === 'all' ? SUPPORTED_EDITORS : [opts.editor])
430
+ : SUPPORTED_EDITORS;
424
431
 
425
432
  console.log(`\n🕌 Rihal Code — Uninstall\n`);
426
433
  console.log(` Project: ${cwd}`);
@@ -548,7 +555,10 @@ async function runUninstall(args) {
548
555
  // Remove vscode-style subdir .claude/commands/rihal/
549
556
  const commandsDir = path.join(cwd, '.claude/commands/rihal');
550
557
  if (fs.existsSync(commandsDir)) {
551
- fs.rmSync(commandsDir, { recursive: true, force: true });
558
+ const r = safeRmSync(commandsDir, path.resolve(cwd));
559
+ if (!r.ok && r.reason === 'outside-root') {
560
+ console.log(` ⚠ refused to remove ${commandsDir} — symlink resolves outside project root`);
561
+ }
552
562
  }
553
563
  // Remove claude-style root-level rihal-*.md files
554
564
  const commandsRoot = path.join(cwd, '.claude/commands');
@@ -641,8 +651,12 @@ async function runUninstall(args) {
641
651
  // not user data. Refreshed by `brain pull` on next install.
642
652
  const brainDir = path.join(cwd, '.rihal', 'brain');
643
653
  if (fs.existsSync(brainDir)) {
644
- fs.rmSync(brainDir, { recursive: true, force: true });
645
- console.log(` ✓ removed .rihal/brain/ (pulled content, will refresh on reinstall)`);
654
+ const r = safeRmSync(brainDir, path.resolve(cwd));
655
+ if (r.ok) {
656
+ console.log(` ✓ removed .rihal/brain/ (pulled content, will refresh on reinstall)`);
657
+ } else if (r.reason === 'outside-root') {
658
+ console.log(` ⚠ refused to remove .rihal/brain/ — symlink resolves outside project root`);
659
+ }
646
660
  }
647
661
 
648
662
  // Handle .rihal/ state directory
@@ -668,8 +682,14 @@ async function runUninstall(args) {
668
682
  }
669
683
 
670
684
  if (shouldDeleteState) {
671
- fs.rmSync(rihalDir, { recursive: true, force: true });
672
- console.log(` ✓ removed .rihal/ state directory`);
685
+ const r = safeRmSync(rihalDir, path.resolve(cwd));
686
+ if (r.ok) {
687
+ console.log(` ✓ removed .rihal/ state directory`);
688
+ } else if (r.reason === 'outside-root') {
689
+ console.log(` ⚠ refused to remove .rihal/ — symlink resolves outside project root`);
690
+ } else {
691
+ console.log(` ⚠ could not remove .rihal/: ${r.reason}`);
692
+ }
673
693
  } else {
674
694
  console.log(` ℹ kept .rihal/ state directory (your project data is preserved)`);
675
695
  }
@@ -681,8 +701,14 @@ async function runUninstall(args) {
681
701
  if (opts.purge) {
682
702
  const planningDir = path.join(cwd, '.planning');
683
703
  if (fs.existsSync(planningDir)) {
684
- fs.rmSync(planningDir, { recursive: true, force: true });
685
- console.log(` ✓ removed .planning/ (--purge)`);
704
+ const r = safeRmSync(planningDir, path.resolve(cwd));
705
+ if (r.ok) {
706
+ console.log(` ✓ removed .planning/ (--purge)`);
707
+ } else if (r.reason === 'outside-root') {
708
+ console.log(` ⚠ refused to remove .planning/ — symlink resolves outside project root`);
709
+ } else {
710
+ console.log(` ⚠ could not remove .planning/: ${r.reason}`);
711
+ }
686
712
  }
687
713
 
688
714
  // Strip the rcode-managed block from .gitignore. The installer writes
package/dist/rcode.js CHANGED
@@ -14946,6 +14946,7 @@ var require_install = __commonJS({
14946
14946
  var path2 = require("path");
14947
14947
  var crypto = require("crypto");
14948
14948
  var os = require("os");
14949
+ var { writeFileAtomic, safeRmSync } = require(path2.join(__dirname, "lib", "fsutil.cjs"));
14949
14950
  var pc = require_picocolors();
14950
14951
  var { createSpinner } = require_dist();
14951
14952
  var fg = require_out4();
@@ -15157,6 +15158,7 @@ var require_install = __commonJS({
15157
15158
  return signals;
15158
15159
  }
15159
15160
  async function resolveIde(opts) {
15161
+ if (Array.isArray(opts.ides) && opts.ides.length > 0) return opts.ides;
15160
15162
  if (opts.ideProvided) return [opts.ide];
15161
15163
  if (opts.noPrompt || opts.global) return ["claude"];
15162
15164
  if (opts.yes || !process.stdin.isTTY) {
@@ -15419,7 +15421,7 @@ Run \`/rihal-new-project <description>\` to bootstrap, or \`/rihal-sprint-planni
15419
15421
  velocity_history: []
15420
15422
  };
15421
15423
  fs2.mkdirSync(path2.dirname(rihalStateJson), { recursive: true });
15422
- fs2.writeFileSync(rihalStateJson, JSON.stringify(state, null, 2) + "\n");
15424
+ writeFileAtomic(rihalStateJson, JSON.stringify(state, null, 2) + "\n");
15423
15425
  }
15424
15426
  return true;
15425
15427
  }
@@ -15488,19 +15490,19 @@ Run \`/rihal-new-project <description>\` to bootstrap, or \`/rihal-sprint-planni
15488
15490
  };
15489
15491
  var spliceBlock = spliceBlock2;
15490
15492
  if (!fs2.existsSync(gitignorePath)) {
15491
- fs2.writeFileSync(gitignorePath, BLOCK);
15493
+ writeFileAtomic(gitignorePath, BLOCK);
15492
15494
  return { action: "created" };
15493
15495
  }
15494
15496
  const existing = fs2.readFileSync(gitignorePath, "utf8");
15495
15497
  if (existing.includes(BEGIN)) {
15496
15498
  const rewritten = spliceBlock2(existing, BLOCK);
15497
15499
  if (rewritten !== null && rewritten !== existing) {
15498
- fs2.writeFileSync(gitignorePath, rewritten);
15500
+ writeFileAtomic(gitignorePath, rewritten);
15499
15501
  return { action: "updated" };
15500
15502
  }
15501
15503
  return { action: "already-present" };
15502
15504
  }
15503
- fs2.writeFileSync(gitignorePath, existing + BLOCK);
15505
+ writeFileAtomic(gitignorePath, existing + BLOCK);
15504
15506
  return { action: "appended" };
15505
15507
  } catch (err) {
15506
15508
  return { action: "skipped-error", error: err.message };
@@ -15549,23 +15551,20 @@ Run \`/rihal-new-project <description>\` to bootstrap, or \`/rihal-sprint-planni
15549
15551
  var spliceBlock = spliceBlock2;
15550
15552
  fs2.mkdirSync(hooksDir, { recursive: true });
15551
15553
  if (!fs2.existsSync(hookPath)) {
15552
- fs2.writeFileSync(hookPath, `#!/bin/sh
15553
- ${BLOCK}`);
15554
- fs2.chmodSync(hookPath, 493);
15554
+ writeFileAtomic(hookPath, `#!/bin/sh
15555
+ ${BLOCK}`, { mode: 493 });
15555
15556
  return { action: "created" };
15556
15557
  }
15557
15558
  const existing = fs2.readFileSync(hookPath, "utf8");
15558
15559
  if (existing.includes(BEGIN)) {
15559
15560
  const rewritten = spliceBlock2(existing, BLOCK);
15560
15561
  if (rewritten !== null && rewritten !== existing) {
15561
- fs2.writeFileSync(hookPath, rewritten);
15562
- fs2.chmodSync(hookPath, 493);
15562
+ writeFileAtomic(hookPath, rewritten, { mode: 493 });
15563
15563
  return { action: "updated" };
15564
15564
  }
15565
15565
  return { action: "already-present" };
15566
15566
  }
15567
- fs2.writeFileSync(hookPath, existing + BLOCK);
15568
- fs2.chmodSync(hookPath, 493);
15567
+ writeFileAtomic(hookPath, existing + BLOCK, { mode: 493 });
15569
15568
  return { action: "appended" };
15570
15569
  } catch (err) {
15571
15570
  return { action: "skipped-error", error: err.message };
@@ -15638,7 +15637,7 @@ ${BLOCK}`);
15638
15637
  if (!internal && globalRihalSkills.has(destName) && !hasLocalOverride(dest)) {
15639
15638
  if (fs2.existsSync(dest)) {
15640
15639
  try {
15641
- fs2.rmSync(dest, { recursive: true, force: true });
15640
+ safeRmSync(dest, target);
15642
15641
  } catch {
15643
15642
  }
15644
15643
  }
@@ -16048,6 +16047,78 @@ ${BLOCK}`);
16048
16047
  console.log("");
16049
16048
  return 2;
16050
16049
  }
16050
+ let releaseLock = () => {
16051
+ };
16052
+ if (!opts.global) {
16053
+ const lockResult = acquireInstallLock(opts.target);
16054
+ if (!lockResult.ok) {
16055
+ console.log("");
16056
+ console.log(" " + warn(`Another install is already running here (PID ${lockResult.pid}).`));
16057
+ console.log(" " + dim(` Lock: ${lockResult.lockPath}`));
16058
+ console.log(" " + dim(" If the other process crashed, delete the lock file and retry:"));
16059
+ console.log(" " + dim(` rm ${lockResult.lockPath}`));
16060
+ console.log("");
16061
+ return 3;
16062
+ }
16063
+ releaseLock = lockResult.release;
16064
+ process.on("exit", releaseLock);
16065
+ }
16066
+ try {
16067
+ return await installInner(opts);
16068
+ } finally {
16069
+ releaseLock();
16070
+ process.removeListener("exit", releaseLock);
16071
+ }
16072
+ }
16073
+ function acquireInstallLock(target) {
16074
+ const lockDir = path2.join(target, ".rihal");
16075
+ const lockPath = path2.join(lockDir, ".install.lock");
16076
+ try {
16077
+ fs2.mkdirSync(lockDir, { recursive: true });
16078
+ } catch {
16079
+ }
16080
+ function isAlive(pid) {
16081
+ try {
16082
+ process.kill(pid, 0);
16083
+ return true;
16084
+ } catch {
16085
+ return false;
16086
+ }
16087
+ }
16088
+ for (let attempt = 0; attempt < 2; attempt++) {
16089
+ try {
16090
+ const fd = fs2.openSync(lockPath, "wx");
16091
+ fs2.writeSync(fd, String(process.pid));
16092
+ fs2.closeSync(fd);
16093
+ return {
16094
+ ok: true,
16095
+ release: () => {
16096
+ try {
16097
+ fs2.unlinkSync(lockPath);
16098
+ } catch {
16099
+ }
16100
+ }
16101
+ };
16102
+ } catch (err) {
16103
+ if (err.code !== "EEXIST") throw err;
16104
+ let pid = 0;
16105
+ try {
16106
+ pid = parseInt(fs2.readFileSync(lockPath, "utf8"), 10);
16107
+ } catch {
16108
+ }
16109
+ if (pid && !isAlive(pid)) {
16110
+ try {
16111
+ fs2.unlinkSync(lockPath);
16112
+ } catch {
16113
+ }
16114
+ continue;
16115
+ }
16116
+ return { ok: false, pid, lockPath };
16117
+ }
16118
+ }
16119
+ return { ok: false, pid: 0, lockPath };
16120
+ }
16121
+ async function installInner(opts) {
16051
16122
  const pkgVersion = readPackageVersion();
16052
16123
  const isInteractive = process.stdin.isTTY && !opts.yes;
16053
16124
  if (isInteractive) printInstallHeader(pkgVersion);
@@ -16360,7 +16431,7 @@ ${BLOCK}`);
16360
16431
  }
16361
16432
  const rihalSubdir = path2.join(projectClaudeCommands, "rihal");
16362
16433
  if (fs2.existsSync(rihalSubdir)) {
16363
- fs2.rmSync(rihalSubdir, { recursive: true, force: true });
16434
+ safeRmSync(rihalSubdir, opts.target);
16364
16435
  }
16365
16436
  const projectAgentsDir = path2.join(opts.target, ".claude", "agents");
16366
16437
  if (fs2.existsSync(projectAgentsDir)) {
@@ -16400,7 +16471,7 @@ ${BLOCK}`);
16400
16471
  existedBefore = true;
16401
16472
  }
16402
16473
  if (!fs2.existsSync(configPath)) {
16403
- fs2.writeFileSync(configPath, generateConfigYaml(opts));
16474
+ writeFileAtomic(configPath, generateConfigYaml(opts));
16404
16475
  } else {
16405
16476
  try {
16406
16477
  const before = fs2.readFileSync(configPath, "utf8");
@@ -16410,13 +16481,13 @@ ${BLOCK}`);
16410
16481
  const currentInFile = match ? match[1] === "true" : null;
16411
16482
  if (match && currentInFile !== desired) {
16412
16483
  const updated = before.replace(re, `commit_planning: ${desired}`);
16413
- fs2.writeFileSync(configPath, updated);
16484
+ writeFileAtomic(configPath, updated);
16414
16485
  console.log(" " + dim(`Updated commit_planning in config.yaml (${currentInFile} \u2192 ${desired}) \u2014 closes #685.`));
16415
16486
  } else if (!match) {
16416
16487
  const appended = before.replace(/\n*$/, "") + `
16417
16488
  commit_planning: ${desired}
16418
16489
  `;
16419
- fs2.writeFileSync(configPath, appended);
16490
+ writeFileAtomic(configPath, appended);
16420
16491
  }
16421
16492
  } catch {
16422
16493
  }
@@ -16439,7 +16510,7 @@ commit_planning: ${desired}
16439
16510
  const now = (/* @__PURE__ */ new Date()).toISOString();
16440
16511
  const stateContent = fs2.readFileSync(stateSrc, "utf8").replace(/__PROJECT_NAME__/g, opts.projectName).replace(/__INSTALL_DATE__/g, now);
16441
16512
  ensureDir(path2.dirname(stateDest));
16442
- fs2.writeFileSync(stateDest, stateContent);
16513
+ writeFileAtomic(stateDest, stateContent);
16443
16514
  }
16444
16515
  }
16445
16516
  ensureDir(path2.join(opts.target, ".planning", "council-sessions"));
@@ -16572,10 +16643,10 @@ commit_planning: ${desired}
16572
16643
  const commandFilter = primaryIde === "claude" ? (f) => f.startsWith("rihal-") && (f.endsWith(".md") || f.endsWith(".mdc")) : (f) => f.endsWith(".md") || f.endsWith(".mdc");
16573
16644
  commandCount = fs2.readdirSync(commandsDir).filter(commandFilter).length;
16574
16645
  }
16575
- if (agentCount === 0 || commandCount === 0) {
16576
- const os2 = require("os");
16577
- const homeAgents = path2.join(os2.homedir(), ".claude/agents");
16578
- const homeCommands = path2.join(os2.homedir(), ".claude/commands");
16646
+ if (agentCount === 0 || commandCount === 0 || skillsInstalled < 20) {
16647
+ const homeAgents = path2.join(os.homedir(), ".claude/agents");
16648
+ const homeCommands = path2.join(os.homedir(), ".claude/commands");
16649
+ const homeSkills = path2.join(os.homedir(), ".claude/skills");
16579
16650
  if (agentCount === 0 && fs2.existsSync(homeAgents)) {
16580
16651
  const n = fs2.readdirSync(homeAgents).filter((f) => f.startsWith("rihal-") && f.endsWith(".md")).length;
16581
16652
  if (n > 0) {
@@ -16590,6 +16661,13 @@ commit_planning: ${desired}
16590
16661
  commandsFromGlobal = true;
16591
16662
  }
16592
16663
  }
16664
+ if (skillsInstalled < 20 && fs2.existsSync(homeSkills)) {
16665
+ try {
16666
+ const globalSkillCount = fs2.readdirSync(homeSkills, { withFileTypes: true }).filter((d) => d.isDirectory() && d.name.startsWith("rihal-")).length;
16667
+ if (globalSkillCount > skillsInstalled) skillsInstalled = globalSkillCount;
16668
+ } catch {
16669
+ }
16670
+ }
16593
16671
  }
16594
16672
  } catch {
16595
16673
  }
@@ -16658,6 +16736,22 @@ commit_planning: ${desired}
16658
16736
  console.log(` ${bold("Health check:")}`);
16659
16737
  const { execFileSync } = require("child_process");
16660
16738
  let fails = 0;
16739
+ let expected = { agents: 20, skills: 20, commands: 20 };
16740
+ try {
16741
+ const { readPackageManifest } = require(path2.join(__dirname, "lib", "manifest.cjs"));
16742
+ const pkgManifest = readPackageManifest(PACKAGE_ROOT2);
16743
+ if (pkgManifest && pkgManifest.agents instanceof Set && pkgManifest.actions instanceof Set) {
16744
+ const tolerate = (n) => Math.max(1, Math.floor(n * 0.9));
16745
+ expected.agents = tolerate(pkgManifest.agents.size);
16746
+ expected.skills = tolerate(pkgManifest.actions.size);
16747
+ const commandsDir = path2.join(PACKAGE_ROOT2, "rihal", "commands");
16748
+ if (fs2.existsSync(commandsDir)) {
16749
+ const cmdCount = fs2.readdirSync(commandsDir).filter((f) => f.endsWith(".md") && !f.startsWith("_")).length;
16750
+ expected.commands = tolerate(cmdCount);
16751
+ }
16752
+ }
16753
+ } catch {
16754
+ }
16661
16755
  function check(label, fn) {
16662
16756
  try {
16663
16757
  const out = fn();
@@ -16687,13 +16781,15 @@ commit_planning: ${desired}
16687
16781
  return "valid JSON";
16688
16782
  });
16689
16783
  check("agents installed", () => {
16690
- if ((counts.agentCount || 0) < 20) throw new Error(`only ${counts.agentCount} agents (expected \u2265 20)`);
16784
+ if ((counts.agentCount || 0) < expected.agents) {
16785
+ throw new Error(`only ${counts.agentCount} agents (expected \u2265 ${expected.agents})`);
16786
+ }
16691
16787
  return `${counts.agentCount}`;
16692
16788
  });
16693
16789
  check("skills + commands installed", () => {
16694
16790
  const issues = [];
16695
- if ((counts.skillsInstalled || 0) < 20) issues.push(`${counts.skillsInstalled} skills`);
16696
- if ((counts.commandCount || 0) < 20) issues.push(`${counts.commandCount} commands`);
16791
+ if ((counts.skillsInstalled || 0) < expected.skills) issues.push(`${counts.skillsInstalled} skills (expected \u2265 ${expected.skills})`);
16792
+ if ((counts.commandCount || 0) < expected.commands) issues.push(`${counts.commandCount} commands (expected \u2265 ${expected.commands})`);
16697
16793
  if (issues.length) throw new Error(`low count: ${issues.join(", ")}`);
16698
16794
  return `${counts.skillsInstalled} skills + ${counts.commandCount} commands`;
16699
16795
  });
@@ -16773,6 +16869,8 @@ commit_planning: ${desired}
16773
16869
  process.exit(0);
16774
16870
  }
16775
16871
  opts.ides = editorChoices;
16872
+ opts.ide = editorChoices[0];
16873
+ opts.ideProvided = true;
16776
16874
  const langChoice = await select({
16777
16875
  message: "Communication language?",
16778
16876
  options: [
@@ -17097,9 +17195,44 @@ var require_fsutil = __commonJS({
17097
17195
  const content = JSON.stringify(obj, null, 2) + "\n";
17098
17196
  writeFileAtomic(filePath, content, opts);
17099
17197
  }
17198
+ function safeRmSync(targetPath, projectRoot) {
17199
+ let stats;
17200
+ try {
17201
+ stats = fs2.lstatSync(targetPath);
17202
+ } catch (err) {
17203
+ if (err.code === "ENOENT") return { ok: true, reason: "missing" };
17204
+ return { ok: false, reason: `lstat: ${err.message}` };
17205
+ }
17206
+ if (stats.isSymbolicLink()) {
17207
+ try {
17208
+ fs2.unlinkSync(targetPath);
17209
+ return { ok: true, reason: "symlink-unlinked" };
17210
+ } catch (err) {
17211
+ return { ok: false, reason: `unlink: ${err.message}` };
17212
+ }
17213
+ }
17214
+ const root = path2.resolve(projectRoot);
17215
+ let resolved;
17216
+ try {
17217
+ resolved = fs2.realpathSync(targetPath);
17218
+ } catch (err) {
17219
+ return { ok: false, reason: `realpath: ${err.message}` };
17220
+ }
17221
+ const relative = path2.relative(root, resolved);
17222
+ if (relative.startsWith("..") || path2.isAbsolute(relative)) {
17223
+ return { ok: false, reason: "outside-root" };
17224
+ }
17225
+ try {
17226
+ fs2.rmSync(resolved, { recursive: true, force: true });
17227
+ return { ok: true };
17228
+ } catch (err) {
17229
+ return { ok: false, reason: `rmSync: ${err.message}` };
17230
+ }
17231
+ }
17100
17232
  module2.exports = {
17101
17233
  writeFileAtomic,
17102
- writeJsonAtomic
17234
+ writeJsonAtomic,
17235
+ safeRmSync
17103
17236
  };
17104
17237
  }
17105
17238
  });
@@ -17580,7 +17713,7 @@ var require_uninstall = __commonJS({
17580
17713
  var path2 = require("path");
17581
17714
  var { spawnSync } = require("child_process");
17582
17715
  var { askConfirm, PromptAbortError } = require_prompts();
17583
- var { writeFileAtomic } = require_fsutil();
17716
+ var { writeFileAtomic, safeRmSync } = require_fsutil();
17584
17717
  function parseArgs(args) {
17585
17718
  const opts = {
17586
17719
  editor: null,
@@ -17616,11 +17749,16 @@ var require_uninstall = __commonJS({
17616
17749
  function removeMatching(dir, predicate) {
17617
17750
  if (!fs2.existsSync(dir)) return 0;
17618
17751
  let count = 0;
17752
+ const projectRoot = path2.resolve(process.cwd());
17619
17753
  for (const entry of fs2.readdirSync(dir, { withFileTypes: true })) {
17620
17754
  if (isLocalOverride(entry.name)) continue;
17621
17755
  if (!predicate(entry.name)) continue;
17622
17756
  const full = path2.join(dir, entry.name);
17623
- fs2.rmSync(full, { recursive: true, force: true });
17757
+ const result = safeRmSync(full, projectRoot);
17758
+ if (!result.ok && result.reason === "outside-root") {
17759
+ console.log(` \u26A0 refused to remove ${full} \u2014 symlink resolves outside project root`);
17760
+ continue;
17761
+ }
17624
17762
  count++;
17625
17763
  }
17626
17764
  return count;
@@ -17716,31 +17854,19 @@ var require_uninstall = __commonJS({
17716
17854
  }
17717
17855
  return plan;
17718
17856
  }
17719
- var KNOWN_ACTION_SKILLS = [
17720
- "rihal-check-implementation-readiness",
17721
- "rihal-code-review",
17722
- "rihal-correct-course",
17723
- "rihal-create-architecture",
17724
- "rihal-create-epics-and-stories",
17725
- "rihal-create-prd",
17726
- "rihal-create-story",
17727
- "rihal-create-ux-design",
17728
- "rihal-dev-story",
17729
- "rihal-document-project",
17730
- "rihal-domain-research",
17731
- "rihal-edit-prd",
17732
- "rihal-frontend-design",
17733
- "rihal-generate-project-context",
17734
- "rihal-market-research",
17735
- "rihal-product-brief",
17736
- "rihal-qa-generate-e2e-tests",
17737
- "rihal-retrospective",
17738
- "rihal-sprint-planning",
17739
- "rihal-sprint-status",
17740
- "rihal-technical-research",
17741
- "rihal-validate-prd",
17742
- "rihal-clone-website"
17743
- ];
17857
+ function discoverKnownActionSkills() {
17858
+ try {
17859
+ const { readPackageManifest } = require(path2.join(__dirname, "lib", "manifest.cjs"));
17860
+ const packageRoot = path2.resolve(__dirname, "..");
17861
+ const pkg = readPackageManifest(packageRoot);
17862
+ if (pkg && pkg.actions instanceof Set && pkg.actions.size > 0) {
17863
+ return Array.from(pkg.actions);
17864
+ }
17865
+ } catch {
17866
+ }
17867
+ return [];
17868
+ }
17869
+ var KNOWN_ACTION_SKILLS = discoverKnownActionSkills();
17744
17870
  function isKnownSkillName(name) {
17745
17871
  return KNOWN_ACTION_SKILLS.includes(name);
17746
17872
  }
@@ -17855,7 +17981,8 @@ var require_uninstall = __commonJS({
17855
17981
  async function runUninstall(args) {
17856
17982
  const opts = parseArgs(args);
17857
17983
  const cwd = process.cwd();
17858
- const editors = opts.editor ? opts.editor === "all" ? ["claude", "cursor", "windsurf", "antigravity"] : [opts.editor] : ["claude", "cursor", "windsurf", "antigravity"];
17984
+ const SUPPORTED_EDITORS = ["claude", "cursor", "gemini", "vscode", "antigravity"];
17985
+ const editors = opts.editor ? opts.editor === "all" ? SUPPORTED_EDITORS : [opts.editor] : SUPPORTED_EDITORS;
17859
17986
  console.log(`
17860
17987
  \u{1F54C} Rihal Code \u2014 Uninstall
17861
17988
  `);
@@ -17956,7 +18083,10 @@ var require_uninstall = __commonJS({
17956
18083
  if (n > 0) console.log(` \u2713 removed ${n} Claude skills`);
17957
18084
  const commandsDir = path2.join(cwd, ".claude/commands/rihal");
17958
18085
  if (fs2.existsSync(commandsDir)) {
17959
- fs2.rmSync(commandsDir, { recursive: true, force: true });
18086
+ const r = safeRmSync(commandsDir, path2.resolve(cwd));
18087
+ if (!r.ok && r.reason === "outside-root") {
18088
+ console.log(` \u26A0 refused to remove ${commandsDir} \u2014 symlink resolves outside project root`);
18089
+ }
17960
18090
  }
17961
18091
  const commandsRoot = path2.join(cwd, ".claude/commands");
17962
18092
  let commandsRemoved = 0;
@@ -18034,8 +18164,12 @@ var require_uninstall = __commonJS({
18034
18164
  ]);
18035
18165
  const brainDir = path2.join(cwd, ".rihal", "brain");
18036
18166
  if (fs2.existsSync(brainDir)) {
18037
- fs2.rmSync(brainDir, { recursive: true, force: true });
18038
- console.log(` \u2713 removed .rihal/brain/ (pulled content, will refresh on reinstall)`);
18167
+ const r = safeRmSync(brainDir, path2.resolve(cwd));
18168
+ if (r.ok) {
18169
+ console.log(` \u2713 removed .rihal/brain/ (pulled content, will refresh on reinstall)`);
18170
+ } else if (r.reason === "outside-root") {
18171
+ console.log(` \u26A0 refused to remove .rihal/brain/ \u2014 symlink resolves outside project root`);
18172
+ }
18039
18173
  }
18040
18174
  if (plan.stateDir) {
18041
18175
  const rihalDir = path2.join(cwd, ".rihal");
@@ -18057,8 +18191,14 @@ var require_uninstall = __commonJS({
18057
18191
  );
18058
18192
  }
18059
18193
  if (shouldDeleteState) {
18060
- fs2.rmSync(rihalDir, { recursive: true, force: true });
18061
- console.log(` \u2713 removed .rihal/ state directory`);
18194
+ const r = safeRmSync(rihalDir, path2.resolve(cwd));
18195
+ if (r.ok) {
18196
+ console.log(` \u2713 removed .rihal/ state directory`);
18197
+ } else if (r.reason === "outside-root") {
18198
+ console.log(` \u26A0 refused to remove .rihal/ \u2014 symlink resolves outside project root`);
18199
+ } else {
18200
+ console.log(` \u26A0 could not remove .rihal/: ${r.reason}`);
18201
+ }
18062
18202
  } else {
18063
18203
  console.log(` \u2139 kept .rihal/ state directory (your project data is preserved)`);
18064
18204
  }
@@ -18066,8 +18206,14 @@ var require_uninstall = __commonJS({
18066
18206
  if (opts.purge) {
18067
18207
  const planningDir = path2.join(cwd, ".planning");
18068
18208
  if (fs2.existsSync(planningDir)) {
18069
- fs2.rmSync(planningDir, { recursive: true, force: true });
18070
- console.log(` \u2713 removed .planning/ (--purge)`);
18209
+ const r = safeRmSync(planningDir, path2.resolve(cwd));
18210
+ if (r.ok) {
18211
+ console.log(` \u2713 removed .planning/ (--purge)`);
18212
+ } else if (r.reason === "outside-root") {
18213
+ console.log(` \u26A0 refused to remove .planning/ \u2014 symlink resolves outside project root`);
18214
+ } else {
18215
+ console.log(` \u26A0 could not remove .planning/: ${r.reason}`);
18216
+ }
18071
18217
  }
18072
18218
  const gitignorePath = path2.join(cwd, ".gitignore");
18073
18219
  if (fs2.existsSync(gitignorePath)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanzlaa/rcode",
3
- "version": "3.4.23",
3
+ "version": "3.4.25",
4
4
  "description": "rcode — the memory bank for AI-driven SaaS teams. Persistent project context, distinctive engineering personas, and phase-based workflows. Built by Rihal. Works in Claude Code, Cursor, Gemini, VS Code, and Antigravity.",
5
5
  "main": "cli/index.js",
6
6
  "bin": {