@hanzlaa/rcode 3.4.23 → 3.4.24

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');
@@ -649,7 +653,7 @@ function seedStarterPlanning(target, projectName) {
649
653
  velocity_history: [],
650
654
  };
651
655
  fs.mkdirSync(path.dirname(rihalStateJson), { recursive: true });
652
- fs.writeFileSync(rihalStateJson, JSON.stringify(state, null, 2) + '\n');
656
+ writeFileAtomic(rihalStateJson, JSON.stringify(state, null, 2) + '\n');
653
657
  }
654
658
 
655
659
  return true;
@@ -724,7 +728,7 @@ function ensureRcodeGitignore(target, options = {}) {
724
728
  const gitignorePath = path.join(target, '.gitignore');
725
729
  try {
726
730
  if (!fs.existsSync(gitignorePath)) {
727
- fs.writeFileSync(gitignorePath, BLOCK);
731
+ writeFileAtomic(gitignorePath, BLOCK);
728
732
  return { action: 'created' };
729
733
  }
730
734
  const existing = fs.readFileSync(gitignorePath, 'utf8');
@@ -750,12 +754,12 @@ function ensureRcodeGitignore(target, options = {}) {
750
754
  if (existing.includes(BEGIN)) {
751
755
  const rewritten = spliceBlock(existing, BLOCK);
752
756
  if (rewritten !== null && rewritten !== existing) {
753
- fs.writeFileSync(gitignorePath, rewritten);
757
+ writeFileAtomic(gitignorePath, rewritten);
754
758
  return { action: 'updated' };
755
759
  }
756
760
  return { action: 'already-present' };
757
761
  }
758
- fs.writeFileSync(gitignorePath, existing + BLOCK);
762
+ writeFileAtomic(gitignorePath, existing + BLOCK);
759
763
  return { action: 'appended' };
760
764
  } catch (err) {
761
765
  return { action: 'skipped-error', error: err.message };
@@ -804,8 +808,7 @@ function ensureRcodePreCommitHook(target, options = {}) {
804
808
  fs.mkdirSync(hooksDir, { recursive: true });
805
809
 
806
810
  if (!fs.existsSync(hookPath)) {
807
- fs.writeFileSync(hookPath, `#!/bin/sh\n${BLOCK}`);
808
- fs.chmodSync(hookPath, 0o755);
811
+ writeFileAtomic(hookPath, `#!/bin/sh\n${BLOCK}`, { mode: 0o755 });
809
812
  return { action: 'created' };
810
813
  }
811
814
 
@@ -831,15 +834,13 @@ function ensureRcodePreCommitHook(target, options = {}) {
831
834
  if (existing.includes(BEGIN)) {
832
835
  const rewritten = spliceBlock(existing, BLOCK);
833
836
  if (rewritten !== null && rewritten !== existing) {
834
- fs.writeFileSync(hookPath, rewritten);
835
- fs.chmodSync(hookPath, 0o755);
837
+ writeFileAtomic(hookPath, rewritten, { mode: 0o755 });
836
838
  return { action: 'updated' };
837
839
  }
838
840
  return { action: 'already-present' };
839
841
  }
840
842
 
841
- fs.writeFileSync(hookPath, existing + BLOCK);
842
- fs.chmodSync(hookPath, 0o755);
843
+ writeFileAtomic(hookPath, existing + BLOCK, { mode: 0o755 });
843
844
  return { action: 'appended' };
844
845
  } catch (err) {
845
846
  return { action: 'skipped-error', error: err.message };
@@ -951,7 +952,8 @@ function installSkills(packageRoot, target, options = {}) {
951
952
  // Also remove the existing project copy (left over from previous
952
953
  // installs that didn't dedup) so it stops showing in the picker.
953
954
  if (fs.existsSync(dest)) {
954
- try { fs.rmSync(dest, { recursive: true, force: true }); } catch { /* non-fatal */ }
955
+ // #688 safeRmSync refuses to traverse symlinks pointing outside target.
956
+ try { safeRmSync(dest, target); } catch { /* non-fatal */ }
955
957
  }
956
958
  skippedGlobal++;
957
959
  continue;
@@ -1875,10 +1877,11 @@ async function install(opts) {
1875
1877
  for (const f of projectCommandFiles) {
1876
1878
  fs.unlinkSync(path.join(projectClaudeCommands, f));
1877
1879
  }
1878
- // Remove rihal/ subdirectory (vscode-style commands)
1880
+ // Remove rihal/ subdirectory (vscode-style commands).
1881
+ // #688 — safeRmSync refuses to traverse out-of-target symlinks.
1879
1882
  const rihalSubdir = path.join(projectClaudeCommands, 'rihal');
1880
1883
  if (fs.existsSync(rihalSubdir)) {
1881
- fs.rmSync(rihalSubdir, { recursive: true, force: true });
1884
+ safeRmSync(rihalSubdir, opts.target);
1882
1885
  }
1883
1886
  const projectAgentsDir = path.join(opts.target, '.claude', 'agents');
1884
1887
  if (fs.existsSync(projectAgentsDir)) {
@@ -1927,7 +1930,7 @@ async function install(opts) {
1927
1930
  // Write .rihal/config.yaml (user_name, project_name, language, mode)
1928
1931
  // Note: config.yaml is user data and should NOT be overwritten on --force (unless --reset)
1929
1932
  if (!fs.existsSync(configPath)) {
1930
- fs.writeFileSync(configPath, generateConfigYaml(opts));
1933
+ writeFileAtomic(configPath, generateConfigYaml(opts));
1931
1934
  } else {
1932
1935
  // Issue #685: re-install path. config.yaml is preserved BUT if the user
1933
1936
  // just changed commit_planning via the prompt/flag, .gitignore will be
@@ -1942,12 +1945,12 @@ async function install(opts) {
1942
1945
  const currentInFile = match ? match[1] === 'true' : null;
1943
1946
  if (match && currentInFile !== desired) {
1944
1947
  const updated = before.replace(re, `commit_planning: ${desired}`);
1945
- fs.writeFileSync(configPath, updated);
1948
+ writeFileAtomic(configPath, updated);
1946
1949
  console.log(' ' + dim(`Updated commit_planning in config.yaml (${currentInFile} → ${desired}) — closes #685.`));
1947
1950
  } else if (!match) {
1948
1951
  // Older config without the key — append it so the next read finds it.
1949
1952
  const appended = before.replace(/\n*$/, '') + `\ncommit_planning: ${desired}\n`;
1950
- fs.writeFileSync(configPath, appended);
1953
+ writeFileAtomic(configPath, appended);
1951
1954
  }
1952
1955
  } catch { /* best-effort — never fail install on this */ }
1953
1956
  }
@@ -1973,7 +1976,7 @@ async function install(opts) {
1973
1976
  .replace(/__PROJECT_NAME__/g, opts.projectName)
1974
1977
  .replace(/__INSTALL_DATE__/g, now);
1975
1978
  ensureDir(path.dirname(stateDest));
1976
- fs.writeFileSync(stateDest, stateContent);
1979
+ writeFileAtomic(stateDest, stateContent);
1977
1980
  }
1978
1981
  }
1979
1982
 
@@ -2158,10 +2161,13 @@ async function install(opts) {
2158
2161
  // Issue #669 — when global precedence applied (project copies were
2159
2162
  // intentionally removed), count from ~/.claude/ instead so the summary
2160
2163
  // doesn't lie about the install state.
2161
- if (agentCount === 0 || commandCount === 0) {
2162
- const os = require('os');
2164
+ // Issue #689: skills count gets the same fallback. After dedup (#679)
2165
+ // the project skills folder may have only sidebar stubs while ~/.claude/
2166
+ // has the real skills — health check should see those.
2167
+ if (agentCount === 0 || commandCount === 0 || skillsInstalled < 20) {
2163
2168
  const homeAgents = path.join(os.homedir(), '.claude/agents');
2164
2169
  const homeCommands = path.join(os.homedir(), '.claude/commands');
2170
+ const homeSkills = path.join(os.homedir(), '.claude/skills');
2165
2171
  if (agentCount === 0 && fs.existsSync(homeAgents)) {
2166
2172
  const n = fs.readdirSync(homeAgents).filter(f => f.startsWith('rihal-') && f.endsWith('.md')).length;
2167
2173
  if (n > 0) { agentCount = n; agentsFromGlobal = true; }
@@ -2170,6 +2176,13 @@ async function install(opts) {
2170
2176
  const n = fs.readdirSync(homeCommands).filter(f => f.startsWith('rihal-') && f.endsWith('.md')).length;
2171
2177
  if (n > 0) { commandCount = n; commandsFromGlobal = true; }
2172
2178
  }
2179
+ if (skillsInstalled < 20 && fs.existsSync(homeSkills)) {
2180
+ try {
2181
+ const globalSkillCount = fs.readdirSync(homeSkills, { withFileTypes: true })
2182
+ .filter(d => d.isDirectory() && d.name.startsWith('rihal-')).length;
2183
+ if (globalSkillCount > skillsInstalled) skillsInstalled = globalSkillCount;
2184
+ } catch { /* non-fatal */ }
2185
+ }
2173
2186
  }
2174
2187
  } catch {}
2175
2188
 
@@ -2250,6 +2263,36 @@ function runInstallHealthCheck(target, counts) {
2250
2263
  const { execFileSync } = require('child_process');
2251
2264
  let fails = 0;
2252
2265
 
2266
+ // Issue #689: thresholds were hardcoded at 20 ("expected ≥ 20 agents",
2267
+ // "expected ≥ 20 skills", "expected ≥ 20 commands"). If the package ever
2268
+ // ships fewer than 20 of any kind, the health check fails on every install
2269
+ // even when the install actually succeeded. Worse: if the package ships
2270
+ // 22 agents and an install lands 21 (one corrupt copy), the >= 20 threshold
2271
+ // passes — false green.
2272
+ //
2273
+ // Source the expected counts from the package manifest itself. The verifier
2274
+ // in cli/lib/manifest.cjs already does this; we mirror its result here.
2275
+ let expected = { agents: 20, skills: 20, commands: 20 };
2276
+ try {
2277
+ const { readPackageManifest } = require(path.join(__dirname, 'lib', 'manifest.cjs'));
2278
+ const pkgManifest = readPackageManifest(PACKAGE_ROOT);
2279
+ if (pkgManifest && pkgManifest.agents instanceof Set && pkgManifest.actions instanceof Set) {
2280
+ // Tolerate ~10% loss vs source — global precedence, .local.md
2281
+ // overrides, and intentionally-skipped sidebar stubs all reduce the
2282
+ // count without indicating a failure.
2283
+ const tolerate = (n) => Math.max(1, Math.floor(n * 0.9));
2284
+ expected.agents = tolerate(pkgManifest.agents.size);
2285
+ expected.skills = tolerate(pkgManifest.actions.size);
2286
+ // Commands count comes from rihal/commands/. No bundled enumerator
2287
+ // exists; reuse the agents threshold as a proxy floor.
2288
+ const commandsDir = path.join(PACKAGE_ROOT, 'rihal', 'commands');
2289
+ if (fs.existsSync(commandsDir)) {
2290
+ const cmdCount = fs.readdirSync(commandsDir).filter(f => f.endsWith('.md') && !f.startsWith('_')).length;
2291
+ expected.commands = tolerate(cmdCount);
2292
+ }
2293
+ }
2294
+ } catch { /* keep hardcoded fallback */ }
2295
+
2253
2296
  function check(label, fn) {
2254
2297
  try {
2255
2298
  const out = fn();
@@ -2283,14 +2326,16 @@ function runInstallHealthCheck(target, counts) {
2283
2326
  });
2284
2327
 
2285
2328
  check('agents installed', () => {
2286
- if ((counts.agentCount || 0) < 20) throw new Error(`only ${counts.agentCount} agents (expected ≥ 20)`);
2329
+ if ((counts.agentCount || 0) < expected.agents) {
2330
+ throw new Error(`only ${counts.agentCount} agents (expected ≥ ${expected.agents})`);
2331
+ }
2287
2332
  return `${counts.agentCount}`;
2288
2333
  });
2289
2334
 
2290
2335
  check('skills + commands installed', () => {
2291
2336
  const issues = [];
2292
- if ((counts.skillsInstalled || 0) < 20) issues.push(`${counts.skillsInstalled} skills`);
2293
- if ((counts.commandCount || 0) < 20) issues.push(`${counts.commandCount} commands`);
2337
+ if ((counts.skillsInstalled || 0) < expected.skills) issues.push(`${counts.skillsInstalled} skills (expected ≥ ${expected.skills})`);
2338
+ if ((counts.commandCount || 0) < expected.commands) issues.push(`${counts.commandCount} commands (expected ≥ ${expected.commands})`);
2294
2339
  if (issues.length) throw new Error(`low count: ${issues.join(', ')}`);
2295
2340
  return `${counts.skillsInstalled} skills + ${counts.commandCount} commands`;
2296
2341
  });
@@ -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;
@@ -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();
@@ -15419,7 +15420,7 @@ Run \`/rihal-new-project <description>\` to bootstrap, or \`/rihal-sprint-planni
15419
15420
  velocity_history: []
15420
15421
  };
15421
15422
  fs2.mkdirSync(path2.dirname(rihalStateJson), { recursive: true });
15422
- fs2.writeFileSync(rihalStateJson, JSON.stringify(state, null, 2) + "\n");
15423
+ writeFileAtomic(rihalStateJson, JSON.stringify(state, null, 2) + "\n");
15423
15424
  }
15424
15425
  return true;
15425
15426
  }
@@ -15488,19 +15489,19 @@ Run \`/rihal-new-project <description>\` to bootstrap, or \`/rihal-sprint-planni
15488
15489
  };
15489
15490
  var spliceBlock = spliceBlock2;
15490
15491
  if (!fs2.existsSync(gitignorePath)) {
15491
- fs2.writeFileSync(gitignorePath, BLOCK);
15492
+ writeFileAtomic(gitignorePath, BLOCK);
15492
15493
  return { action: "created" };
15493
15494
  }
15494
15495
  const existing = fs2.readFileSync(gitignorePath, "utf8");
15495
15496
  if (existing.includes(BEGIN)) {
15496
15497
  const rewritten = spliceBlock2(existing, BLOCK);
15497
15498
  if (rewritten !== null && rewritten !== existing) {
15498
- fs2.writeFileSync(gitignorePath, rewritten);
15499
+ writeFileAtomic(gitignorePath, rewritten);
15499
15500
  return { action: "updated" };
15500
15501
  }
15501
15502
  return { action: "already-present" };
15502
15503
  }
15503
- fs2.writeFileSync(gitignorePath, existing + BLOCK);
15504
+ writeFileAtomic(gitignorePath, existing + BLOCK);
15504
15505
  return { action: "appended" };
15505
15506
  } catch (err) {
15506
15507
  return { action: "skipped-error", error: err.message };
@@ -15549,23 +15550,20 @@ Run \`/rihal-new-project <description>\` to bootstrap, or \`/rihal-sprint-planni
15549
15550
  var spliceBlock = spliceBlock2;
15550
15551
  fs2.mkdirSync(hooksDir, { recursive: true });
15551
15552
  if (!fs2.existsSync(hookPath)) {
15552
- fs2.writeFileSync(hookPath, `#!/bin/sh
15553
- ${BLOCK}`);
15554
- fs2.chmodSync(hookPath, 493);
15553
+ writeFileAtomic(hookPath, `#!/bin/sh
15554
+ ${BLOCK}`, { mode: 493 });
15555
15555
  return { action: "created" };
15556
15556
  }
15557
15557
  const existing = fs2.readFileSync(hookPath, "utf8");
15558
15558
  if (existing.includes(BEGIN)) {
15559
15559
  const rewritten = spliceBlock2(existing, BLOCK);
15560
15560
  if (rewritten !== null && rewritten !== existing) {
15561
- fs2.writeFileSync(hookPath, rewritten);
15562
- fs2.chmodSync(hookPath, 493);
15561
+ writeFileAtomic(hookPath, rewritten, { mode: 493 });
15563
15562
  return { action: "updated" };
15564
15563
  }
15565
15564
  return { action: "already-present" };
15566
15565
  }
15567
- fs2.writeFileSync(hookPath, existing + BLOCK);
15568
- fs2.chmodSync(hookPath, 493);
15566
+ writeFileAtomic(hookPath, existing + BLOCK, { mode: 493 });
15569
15567
  return { action: "appended" };
15570
15568
  } catch (err) {
15571
15569
  return { action: "skipped-error", error: err.message };
@@ -15638,7 +15636,7 @@ ${BLOCK}`);
15638
15636
  if (!internal && globalRihalSkills.has(destName) && !hasLocalOverride(dest)) {
15639
15637
  if (fs2.existsSync(dest)) {
15640
15638
  try {
15641
- fs2.rmSync(dest, { recursive: true, force: true });
15639
+ safeRmSync(dest, target);
15642
15640
  } catch {
15643
15641
  }
15644
15642
  }
@@ -16360,7 +16358,7 @@ ${BLOCK}`);
16360
16358
  }
16361
16359
  const rihalSubdir = path2.join(projectClaudeCommands, "rihal");
16362
16360
  if (fs2.existsSync(rihalSubdir)) {
16363
- fs2.rmSync(rihalSubdir, { recursive: true, force: true });
16361
+ safeRmSync(rihalSubdir, opts.target);
16364
16362
  }
16365
16363
  const projectAgentsDir = path2.join(opts.target, ".claude", "agents");
16366
16364
  if (fs2.existsSync(projectAgentsDir)) {
@@ -16400,7 +16398,7 @@ ${BLOCK}`);
16400
16398
  existedBefore = true;
16401
16399
  }
16402
16400
  if (!fs2.existsSync(configPath)) {
16403
- fs2.writeFileSync(configPath, generateConfigYaml(opts));
16401
+ writeFileAtomic(configPath, generateConfigYaml(opts));
16404
16402
  } else {
16405
16403
  try {
16406
16404
  const before = fs2.readFileSync(configPath, "utf8");
@@ -16410,13 +16408,13 @@ ${BLOCK}`);
16410
16408
  const currentInFile = match ? match[1] === "true" : null;
16411
16409
  if (match && currentInFile !== desired) {
16412
16410
  const updated = before.replace(re, `commit_planning: ${desired}`);
16413
- fs2.writeFileSync(configPath, updated);
16411
+ writeFileAtomic(configPath, updated);
16414
16412
  console.log(" " + dim(`Updated commit_planning in config.yaml (${currentInFile} \u2192 ${desired}) \u2014 closes #685.`));
16415
16413
  } else if (!match) {
16416
16414
  const appended = before.replace(/\n*$/, "") + `
16417
16415
  commit_planning: ${desired}
16418
16416
  `;
16419
- fs2.writeFileSync(configPath, appended);
16417
+ writeFileAtomic(configPath, appended);
16420
16418
  }
16421
16419
  } catch {
16422
16420
  }
@@ -16439,7 +16437,7 @@ commit_planning: ${desired}
16439
16437
  const now = (/* @__PURE__ */ new Date()).toISOString();
16440
16438
  const stateContent = fs2.readFileSync(stateSrc, "utf8").replace(/__PROJECT_NAME__/g, opts.projectName).replace(/__INSTALL_DATE__/g, now);
16441
16439
  ensureDir(path2.dirname(stateDest));
16442
- fs2.writeFileSync(stateDest, stateContent);
16440
+ writeFileAtomic(stateDest, stateContent);
16443
16441
  }
16444
16442
  }
16445
16443
  ensureDir(path2.join(opts.target, ".planning", "council-sessions"));
@@ -16572,10 +16570,10 @@ commit_planning: ${desired}
16572
16570
  const commandFilter = primaryIde === "claude" ? (f) => f.startsWith("rihal-") && (f.endsWith(".md") || f.endsWith(".mdc")) : (f) => f.endsWith(".md") || f.endsWith(".mdc");
16573
16571
  commandCount = fs2.readdirSync(commandsDir).filter(commandFilter).length;
16574
16572
  }
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");
16573
+ if (agentCount === 0 || commandCount === 0 || skillsInstalled < 20) {
16574
+ const homeAgents = path2.join(os.homedir(), ".claude/agents");
16575
+ const homeCommands = path2.join(os.homedir(), ".claude/commands");
16576
+ const homeSkills = path2.join(os.homedir(), ".claude/skills");
16579
16577
  if (agentCount === 0 && fs2.existsSync(homeAgents)) {
16580
16578
  const n = fs2.readdirSync(homeAgents).filter((f) => f.startsWith("rihal-") && f.endsWith(".md")).length;
16581
16579
  if (n > 0) {
@@ -16590,6 +16588,13 @@ commit_planning: ${desired}
16590
16588
  commandsFromGlobal = true;
16591
16589
  }
16592
16590
  }
16591
+ if (skillsInstalled < 20 && fs2.existsSync(homeSkills)) {
16592
+ try {
16593
+ const globalSkillCount = fs2.readdirSync(homeSkills, { withFileTypes: true }).filter((d) => d.isDirectory() && d.name.startsWith("rihal-")).length;
16594
+ if (globalSkillCount > skillsInstalled) skillsInstalled = globalSkillCount;
16595
+ } catch {
16596
+ }
16597
+ }
16593
16598
  }
16594
16599
  } catch {
16595
16600
  }
@@ -16658,6 +16663,22 @@ commit_planning: ${desired}
16658
16663
  console.log(` ${bold("Health check:")}`);
16659
16664
  const { execFileSync } = require("child_process");
16660
16665
  let fails = 0;
16666
+ let expected = { agents: 20, skills: 20, commands: 20 };
16667
+ try {
16668
+ const { readPackageManifest } = require(path2.join(__dirname, "lib", "manifest.cjs"));
16669
+ const pkgManifest = readPackageManifest(PACKAGE_ROOT2);
16670
+ if (pkgManifest && pkgManifest.agents instanceof Set && pkgManifest.actions instanceof Set) {
16671
+ const tolerate = (n) => Math.max(1, Math.floor(n * 0.9));
16672
+ expected.agents = tolerate(pkgManifest.agents.size);
16673
+ expected.skills = tolerate(pkgManifest.actions.size);
16674
+ const commandsDir = path2.join(PACKAGE_ROOT2, "rihal", "commands");
16675
+ if (fs2.existsSync(commandsDir)) {
16676
+ const cmdCount = fs2.readdirSync(commandsDir).filter((f) => f.endsWith(".md") && !f.startsWith("_")).length;
16677
+ expected.commands = tolerate(cmdCount);
16678
+ }
16679
+ }
16680
+ } catch {
16681
+ }
16661
16682
  function check(label, fn) {
16662
16683
  try {
16663
16684
  const out = fn();
@@ -16687,13 +16708,15 @@ commit_planning: ${desired}
16687
16708
  return "valid JSON";
16688
16709
  });
16689
16710
  check("agents installed", () => {
16690
- if ((counts.agentCount || 0) < 20) throw new Error(`only ${counts.agentCount} agents (expected \u2265 20)`);
16711
+ if ((counts.agentCount || 0) < expected.agents) {
16712
+ throw new Error(`only ${counts.agentCount} agents (expected \u2265 ${expected.agents})`);
16713
+ }
16691
16714
  return `${counts.agentCount}`;
16692
16715
  });
16693
16716
  check("skills + commands installed", () => {
16694
16717
  const issues = [];
16695
- if ((counts.skillsInstalled || 0) < 20) issues.push(`${counts.skillsInstalled} skills`);
16696
- if ((counts.commandCount || 0) < 20) issues.push(`${counts.commandCount} commands`);
16718
+ if ((counts.skillsInstalled || 0) < expected.skills) issues.push(`${counts.skillsInstalled} skills (expected \u2265 ${expected.skills})`);
16719
+ if ((counts.commandCount || 0) < expected.commands) issues.push(`${counts.commandCount} commands (expected \u2265 ${expected.commands})`);
16697
16720
  if (issues.length) throw new Error(`low count: ${issues.join(", ")}`);
16698
16721
  return `${counts.skillsInstalled} skills + ${counts.commandCount} commands`;
16699
16722
  });
@@ -17097,9 +17120,44 @@ var require_fsutil = __commonJS({
17097
17120
  const content = JSON.stringify(obj, null, 2) + "\n";
17098
17121
  writeFileAtomic(filePath, content, opts);
17099
17122
  }
17123
+ function safeRmSync(targetPath, projectRoot) {
17124
+ let stats;
17125
+ try {
17126
+ stats = fs2.lstatSync(targetPath);
17127
+ } catch (err) {
17128
+ if (err.code === "ENOENT") return { ok: true, reason: "missing" };
17129
+ return { ok: false, reason: `lstat: ${err.message}` };
17130
+ }
17131
+ if (stats.isSymbolicLink()) {
17132
+ try {
17133
+ fs2.unlinkSync(targetPath);
17134
+ return { ok: true, reason: "symlink-unlinked" };
17135
+ } catch (err) {
17136
+ return { ok: false, reason: `unlink: ${err.message}` };
17137
+ }
17138
+ }
17139
+ const root = path2.resolve(projectRoot);
17140
+ let resolved;
17141
+ try {
17142
+ resolved = fs2.realpathSync(targetPath);
17143
+ } catch (err) {
17144
+ return { ok: false, reason: `realpath: ${err.message}` };
17145
+ }
17146
+ const relative = path2.relative(root, resolved);
17147
+ if (relative.startsWith("..") || path2.isAbsolute(relative)) {
17148
+ return { ok: false, reason: "outside-root" };
17149
+ }
17150
+ try {
17151
+ fs2.rmSync(resolved, { recursive: true, force: true });
17152
+ return { ok: true };
17153
+ } catch (err) {
17154
+ return { ok: false, reason: `rmSync: ${err.message}` };
17155
+ }
17156
+ }
17100
17157
  module2.exports = {
17101
17158
  writeFileAtomic,
17102
- writeJsonAtomic
17159
+ writeJsonAtomic,
17160
+ safeRmSync
17103
17161
  };
17104
17162
  }
17105
17163
  });
@@ -17580,7 +17638,7 @@ var require_uninstall = __commonJS({
17580
17638
  var path2 = require("path");
17581
17639
  var { spawnSync } = require("child_process");
17582
17640
  var { askConfirm, PromptAbortError } = require_prompts();
17583
- var { writeFileAtomic } = require_fsutil();
17641
+ var { writeFileAtomic, safeRmSync } = require_fsutil();
17584
17642
  function parseArgs(args) {
17585
17643
  const opts = {
17586
17644
  editor: null,
@@ -17616,11 +17674,16 @@ var require_uninstall = __commonJS({
17616
17674
  function removeMatching(dir, predicate) {
17617
17675
  if (!fs2.existsSync(dir)) return 0;
17618
17676
  let count = 0;
17677
+ const projectRoot = path2.resolve(process.cwd());
17619
17678
  for (const entry of fs2.readdirSync(dir, { withFileTypes: true })) {
17620
17679
  if (isLocalOverride(entry.name)) continue;
17621
17680
  if (!predicate(entry.name)) continue;
17622
17681
  const full = path2.join(dir, entry.name);
17623
- fs2.rmSync(full, { recursive: true, force: true });
17682
+ const result = safeRmSync(full, projectRoot);
17683
+ if (!result.ok && result.reason === "outside-root") {
17684
+ console.log(` \u26A0 refused to remove ${full} \u2014 symlink resolves outside project root`);
17685
+ continue;
17686
+ }
17624
17687
  count++;
17625
17688
  }
17626
17689
  return count;
@@ -17956,7 +18019,10 @@ var require_uninstall = __commonJS({
17956
18019
  if (n > 0) console.log(` \u2713 removed ${n} Claude skills`);
17957
18020
  const commandsDir = path2.join(cwd, ".claude/commands/rihal");
17958
18021
  if (fs2.existsSync(commandsDir)) {
17959
- fs2.rmSync(commandsDir, { recursive: true, force: true });
18022
+ const r = safeRmSync(commandsDir, path2.resolve(cwd));
18023
+ if (!r.ok && r.reason === "outside-root") {
18024
+ console.log(` \u26A0 refused to remove ${commandsDir} \u2014 symlink resolves outside project root`);
18025
+ }
17960
18026
  }
17961
18027
  const commandsRoot = path2.join(cwd, ".claude/commands");
17962
18028
  let commandsRemoved = 0;
@@ -18034,8 +18100,12 @@ var require_uninstall = __commonJS({
18034
18100
  ]);
18035
18101
  const brainDir = path2.join(cwd, ".rihal", "brain");
18036
18102
  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)`);
18103
+ const r = safeRmSync(brainDir, path2.resolve(cwd));
18104
+ if (r.ok) {
18105
+ console.log(` \u2713 removed .rihal/brain/ (pulled content, will refresh on reinstall)`);
18106
+ } else if (r.reason === "outside-root") {
18107
+ console.log(` \u26A0 refused to remove .rihal/brain/ \u2014 symlink resolves outside project root`);
18108
+ }
18039
18109
  }
18040
18110
  if (plan.stateDir) {
18041
18111
  const rihalDir = path2.join(cwd, ".rihal");
@@ -18057,8 +18127,14 @@ var require_uninstall = __commonJS({
18057
18127
  );
18058
18128
  }
18059
18129
  if (shouldDeleteState) {
18060
- fs2.rmSync(rihalDir, { recursive: true, force: true });
18061
- console.log(` \u2713 removed .rihal/ state directory`);
18130
+ const r = safeRmSync(rihalDir, path2.resolve(cwd));
18131
+ if (r.ok) {
18132
+ console.log(` \u2713 removed .rihal/ state directory`);
18133
+ } else if (r.reason === "outside-root") {
18134
+ console.log(` \u26A0 refused to remove .rihal/ \u2014 symlink resolves outside project root`);
18135
+ } else {
18136
+ console.log(` \u26A0 could not remove .rihal/: ${r.reason}`);
18137
+ }
18062
18138
  } else {
18063
18139
  console.log(` \u2139 kept .rihal/ state directory (your project data is preserved)`);
18064
18140
  }
@@ -18066,8 +18142,14 @@ var require_uninstall = __commonJS({
18066
18142
  if (opts.purge) {
18067
18143
  const planningDir = path2.join(cwd, ".planning");
18068
18144
  if (fs2.existsSync(planningDir)) {
18069
- fs2.rmSync(planningDir, { recursive: true, force: true });
18070
- console.log(` \u2713 removed .planning/ (--purge)`);
18145
+ const r = safeRmSync(planningDir, path2.resolve(cwd));
18146
+ if (r.ok) {
18147
+ console.log(` \u2713 removed .planning/ (--purge)`);
18148
+ } else if (r.reason === "outside-root") {
18149
+ console.log(` \u26A0 refused to remove .planning/ \u2014 symlink resolves outside project root`);
18150
+ } else {
18151
+ console.log(` \u26A0 could not remove .planning/: ${r.reason}`);
18152
+ }
18071
18153
  }
18072
18154
  const gitignorePath = path2.join(cwd, ".gitignore");
18073
18155
  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.24",
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": {