@hanzlaa/rcode 3.4.22 → 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();
@@ -15188,10 +15189,23 @@ var require_install = __commonJS({
15188
15189
  async function resolveCommitPlanning(opts) {
15189
15190
  if (opts.commitPlanning !== null) return opts.commitPlanning;
15190
15191
  if (opts.noPrompt || opts.global) return false;
15191
- if (opts.yes || !process.stdin.isTTY) return true;
15192
+ let existingValue = null;
15193
+ try {
15194
+ const cfgPath = path2.join(opts.target, ".rihal", "config.yaml");
15195
+ if (fs2.existsSync(cfgPath)) {
15196
+ const cfg = fs2.readFileSync(cfgPath, "utf8");
15197
+ const m = cfg.match(/^commit_planning:\s*(true|false)\s*$/m);
15198
+ if (m) existingValue = m[1] === "true";
15199
+ }
15200
+ } catch {
15201
+ }
15202
+ if (opts.yes || !process.stdin.isTTY) {
15203
+ return existingValue !== null ? existingValue : true;
15204
+ }
15205
+ const initialValue = existingValue === false ? "gitignore" : "commit";
15192
15206
  const choice = await clack.select({
15193
- message: "\u{1F4CB} .planning/ holds PRDs, roadmaps, sprints, SUMMARY files. How should they be tracked?",
15194
- initialValue: "commit",
15207
+ message: existingValue !== null ? "\u{1F4CB} .planning/ tracking \u2014 current setting preserved unless you change it." : "\u{1F4CB} .planning/ holds PRDs, roadmaps, sprints, SUMMARY files. How should they be tracked?",
15208
+ initialValue,
15195
15209
  options: [
15196
15210
  { value: "commit", label: "Commit", hint: "collaborators see the same plans (recommended)" },
15197
15211
  { value: "gitignore", label: "Gitignore", hint: "planning stays local (good for sensitive PRDs)" }
@@ -15312,9 +15326,13 @@ Installs (IDE-specific):
15312
15326
  fs2.mkdirSync(planningDir, { recursive: true });
15313
15327
  const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
15314
15328
  const name = projectName || path2.basename(target);
15329
+ const STUB_BANNER = `<!-- INSTALL STUB \u2014 overwritten by /rihal-new-project. Delete this file or run
15330
+ /rihal-new-project before committing. See https://github.com/hanzlahabib/rihal-code/issues/670 -->
15331
+
15332
+ `;
15315
15333
  fs2.writeFileSync(
15316
15334
  projectPath,
15317
- `# ${name}
15335
+ STUB_BANNER + `# ${name}
15318
15336
 
15319
15337
  **One-line:** Describe what this project is in one sentence.
15320
15338
 
@@ -15331,7 +15349,7 @@ What this project delivers and who it serves.
15331
15349
  );
15332
15350
  fs2.writeFileSync(
15333
15351
  roadmapPath,
15334
- `# ${name} \u2014 Roadmap
15352
+ STUB_BANNER + `# ${name} \u2014 Roadmap
15335
15353
 
15336
15354
  **Milestone: M1 \u2014 Initial Delivery** (v1.0)
15337
15355
  Started: ${today} \xB7 Current
@@ -15355,7 +15373,7 @@ Ideas and future phases go here.
15355
15373
  );
15356
15374
  fs2.writeFileSync(
15357
15375
  statePath,
15358
- `# ${name} \u2014 State
15376
+ STUB_BANNER + `# ${name} \u2014 State
15359
15377
 
15360
15378
  **Last updated:** ${today}
15361
15379
  **Milestone:** M1 \u2014 Initial Delivery
@@ -15374,7 +15392,7 @@ _None._
15374
15392
 
15375
15393
  ## Next Action
15376
15394
 
15377
- Say "plan a sprint" or run \`/rihal-sprint-planning\` to break Phase 01 into stories.
15395
+ Run \`/rihal-new-project <description>\` to bootstrap, or \`/rihal-sprint-planning\` once a real phase exists.
15378
15396
  `
15379
15397
  );
15380
15398
  const rihalStateJson = path2.join(target, ".rihal", "state.json");
@@ -15382,16 +15400,15 @@ Say "plan a sprint" or run \`/rihal-sprint-planning\` to break Phase 01 into sto
15382
15400
  const now = (/* @__PURE__ */ new Date()).toISOString();
15383
15401
  const state = {
15384
15402
  version: "1",
15385
- project: name,
15403
+ project: null,
15404
+ _seeded_stub: true,
15386
15405
  created: now,
15387
15406
  updated: now,
15388
- current_phase: "01",
15407
+ current_phase: null,
15389
15408
  current_plan: 0,
15390
15409
  current_sprint: null,
15391
- milestone: "M1 \u2014 Initial Delivery",
15392
- phases: [
15393
- { id: "01", name: "Setup & Scaffolding", status: "planned" }
15394
- ],
15410
+ milestone: null,
15411
+ phases: [],
15395
15412
  executions: [],
15396
15413
  decisions: [],
15397
15414
  blockers: [],
@@ -15403,7 +15420,7 @@ Say "plan a sprint" or run \`/rihal-sprint-planning\` to break Phase 01 into sto
15403
15420
  velocity_history: []
15404
15421
  };
15405
15422
  fs2.mkdirSync(path2.dirname(rihalStateJson), { recursive: true });
15406
- fs2.writeFileSync(rihalStateJson, JSON.stringify(state, null, 2) + "\n");
15423
+ writeFileAtomic(rihalStateJson, JSON.stringify(state, null, 2) + "\n");
15407
15424
  }
15408
15425
  return true;
15409
15426
  }
@@ -15472,19 +15489,19 @@ Say "plan a sprint" or run \`/rihal-sprint-planning\` to break Phase 01 into sto
15472
15489
  };
15473
15490
  var spliceBlock = spliceBlock2;
15474
15491
  if (!fs2.existsSync(gitignorePath)) {
15475
- fs2.writeFileSync(gitignorePath, BLOCK);
15492
+ writeFileAtomic(gitignorePath, BLOCK);
15476
15493
  return { action: "created" };
15477
15494
  }
15478
15495
  const existing = fs2.readFileSync(gitignorePath, "utf8");
15479
15496
  if (existing.includes(BEGIN)) {
15480
15497
  const rewritten = spliceBlock2(existing, BLOCK);
15481
15498
  if (rewritten !== null && rewritten !== existing) {
15482
- fs2.writeFileSync(gitignorePath, rewritten);
15499
+ writeFileAtomic(gitignorePath, rewritten);
15483
15500
  return { action: "updated" };
15484
15501
  }
15485
15502
  return { action: "already-present" };
15486
15503
  }
15487
- fs2.writeFileSync(gitignorePath, existing + BLOCK);
15504
+ writeFileAtomic(gitignorePath, existing + BLOCK);
15488
15505
  return { action: "appended" };
15489
15506
  } catch (err) {
15490
15507
  return { action: "skipped-error", error: err.message };
@@ -15533,23 +15550,20 @@ Say "plan a sprint" or run \`/rihal-sprint-planning\` to break Phase 01 into sto
15533
15550
  var spliceBlock = spliceBlock2;
15534
15551
  fs2.mkdirSync(hooksDir, { recursive: true });
15535
15552
  if (!fs2.existsSync(hookPath)) {
15536
- fs2.writeFileSync(hookPath, `#!/bin/sh
15537
- ${BLOCK}`);
15538
- fs2.chmodSync(hookPath, 493);
15553
+ writeFileAtomic(hookPath, `#!/bin/sh
15554
+ ${BLOCK}`, { mode: 493 });
15539
15555
  return { action: "created" };
15540
15556
  }
15541
15557
  const existing = fs2.readFileSync(hookPath, "utf8");
15542
15558
  if (existing.includes(BEGIN)) {
15543
15559
  const rewritten = spliceBlock2(existing, BLOCK);
15544
15560
  if (rewritten !== null && rewritten !== existing) {
15545
- fs2.writeFileSync(hookPath, rewritten);
15546
- fs2.chmodSync(hookPath, 493);
15561
+ writeFileAtomic(hookPath, rewritten, { mode: 493 });
15547
15562
  return { action: "updated" };
15548
15563
  }
15549
15564
  return { action: "already-present" };
15550
15565
  }
15551
- fs2.writeFileSync(hookPath, existing + BLOCK);
15552
- fs2.chmodSync(hookPath, 493);
15566
+ writeFileAtomic(hookPath, existing + BLOCK, { mode: 493 });
15553
15567
  return { action: "appended" };
15554
15568
  } catch (err) {
15555
15569
  return { action: "skipped-error", error: err.message };
@@ -15584,20 +15598,31 @@ ${BLOCK}`);
15584
15598
  }
15585
15599
  return copied;
15586
15600
  }
15587
- function installSkills(packageRoot, target) {
15601
+ function installSkills(packageRoot, target, options = {}) {
15588
15602
  const skillsSource = path2.join(packageRoot, "rihal/skills");
15589
15603
  const skillsDest = path2.join(target, ".claude/skills");
15590
15604
  const internalDest = path2.join(target, ".rihal/skills");
15591
- if (!fs2.existsSync(skillsSource)) return 0;
15605
+ if (!fs2.existsSync(skillsSource)) return { count: 0, skippedGlobal: 0 };
15592
15606
  fs2.mkdirSync(skillsDest, { recursive: true });
15593
15607
  fs2.mkdirSync(internalDest, { recursive: true });
15608
+ const globalSkillsDir = path2.join(os.homedir(), ".claude", "skills");
15609
+ const globalRihalSkills = options.skipGlobalDuplicates && fs2.existsSync(globalSkillsDir) ? new Set(fs2.readdirSync(globalSkillsDir).filter((n) => n.startsWith("rihal-"))) : /* @__PURE__ */ new Set();
15594
15610
  let count = 0;
15611
+ let skippedGlobal = 0;
15595
15612
  function isInternalSkill(skillDir) {
15596
15613
  const skillMd = path2.join(skillDir, "SKILL.md");
15597
15614
  if (!fs2.existsSync(skillMd)) return false;
15598
15615
  const text = fs2.readFileSync(skillMd, "utf8");
15599
15616
  return /^internal:\s*true\s*$/m.test(text);
15600
15617
  }
15618
+ function hasLocalOverride(destDir) {
15619
+ if (!fs2.existsSync(destDir)) return false;
15620
+ try {
15621
+ return fs2.readdirSync(destDir).some((f) => f.endsWith(".local.md"));
15622
+ } catch {
15623
+ return false;
15624
+ }
15625
+ }
15601
15626
  function walkForSkills(dir) {
15602
15627
  if (!fs2.existsSync(dir)) return;
15603
15628
  for (const entry of fs2.readdirSync(dir, { withFileTypes: true })) {
@@ -15606,7 +15631,18 @@ ${BLOCK}`);
15606
15631
  const hasSkillMd = fs2.existsSync(path2.join(src, "SKILL.md"));
15607
15632
  if (hasSkillMd) {
15608
15633
  const destName = entry.name.startsWith("rihal-") ? entry.name : `rihal-${entry.name}`;
15609
- const dest = isInternalSkill(src) ? path2.join(internalDest, destName) : path2.join(skillsDest, destName);
15634
+ const internal = isInternalSkill(src);
15635
+ const dest = internal ? path2.join(internalDest, destName) : path2.join(skillsDest, destName);
15636
+ if (!internal && globalRihalSkills.has(destName) && !hasLocalOverride(dest)) {
15637
+ if (fs2.existsSync(dest)) {
15638
+ try {
15639
+ safeRmSync(dest, target);
15640
+ } catch {
15641
+ }
15642
+ }
15643
+ skippedGlobal++;
15644
+ continue;
15645
+ }
15610
15646
  copyDirRecursive(src, dest);
15611
15647
  count++;
15612
15648
  } else {
@@ -15617,7 +15653,7 @@ ${BLOCK}`);
15617
15653
  for (const bucket of ["agents", "actions", "core"]) {
15618
15654
  walkForSkills(path2.join(skillsSource, bucket));
15619
15655
  }
15620
- return count;
15656
+ return { count, skippedGlobal };
15621
15657
  }
15622
15658
  function parseFrontmatter(text) {
15623
15659
  if (!text.startsWith("---\n")) return { frontmatter: {}, body: text };
@@ -16001,6 +16037,15 @@ ${BLOCK}`);
16001
16037
  printHelp2();
16002
16038
  return 0;
16003
16039
  }
16040
+ if (opts.reset && !opts.force) {
16041
+ console.log("");
16042
+ console.log(" " + warn("--reset has no effect without --force."));
16043
+ console.log(" " + dim(" --reset wipes config.yaml and state.json. To prevent accidental data loss,"));
16044
+ console.log(" " + dim(" it must be paired with --force. Re-run as:"));
16045
+ console.log(" " + dim(" rcode install --reset --force"));
16046
+ console.log("");
16047
+ return 2;
16048
+ }
16004
16049
  const pkgVersion = readPackageVersion();
16005
16050
  const isInteractive = process.stdin.isTTY && !opts.yes;
16006
16051
  if (isInteractive) printInstallHeader(pkgVersion);
@@ -16273,7 +16318,8 @@ ${BLOCK}`);
16273
16318
  const configDir2 = path2.join(opts.target, ".rihal", "_config");
16274
16319
  ensureDir(configDir2);
16275
16320
  fs2.writeFileSync(path2.join(configDir2, "manifest.yaml"), generateInstallManifest(opts));
16276
- let skillsInstalled2 = installSkills(PACKAGE_ROOT2, opts.target);
16321
+ const skillsResult2 = installSkills(PACKAGE_ROOT2, opts.target);
16322
+ let skillsInstalled2 = skillsResult2.count;
16277
16323
  try {
16278
16324
  const { main: generateCommandSkills } = require(path2.join(PACKAGE_ROOT2, "cli", "generate-command-skills.cjs"));
16279
16325
  const stubsDir = path2.join(opts.target, ".claude", "skills");
@@ -16312,7 +16358,7 @@ ${BLOCK}`);
16312
16358
  }
16313
16359
  const rihalSubdir = path2.join(projectClaudeCommands, "rihal");
16314
16360
  if (fs2.existsSync(rihalSubdir)) {
16315
- fs2.rmSync(rihalSubdir, { recursive: true, force: true });
16361
+ safeRmSync(rihalSubdir, opts.target);
16316
16362
  }
16317
16363
  const projectAgentsDir = path2.join(opts.target, ".claude", "agents");
16318
16364
  if (fs2.existsSync(projectAgentsDir)) {
@@ -16352,7 +16398,26 @@ ${BLOCK}`);
16352
16398
  existedBefore = true;
16353
16399
  }
16354
16400
  if (!fs2.existsSync(configPath)) {
16355
- fs2.writeFileSync(configPath, generateConfigYaml(opts));
16401
+ writeFileAtomic(configPath, generateConfigYaml(opts));
16402
+ } else {
16403
+ try {
16404
+ const before = fs2.readFileSync(configPath, "utf8");
16405
+ const desired = opts.commitPlanning !== false;
16406
+ const re = /^commit_planning:\s*(true|false)\s*$/m;
16407
+ const match = before.match(re);
16408
+ const currentInFile = match ? match[1] === "true" : null;
16409
+ if (match && currentInFile !== desired) {
16410
+ const updated = before.replace(re, `commit_planning: ${desired}`);
16411
+ writeFileAtomic(configPath, updated);
16412
+ console.log(" " + dim(`Updated commit_planning in config.yaml (${currentInFile} \u2192 ${desired}) \u2014 closes #685.`));
16413
+ } else if (!match) {
16414
+ const appended = before.replace(/\n*$/, "") + `
16415
+ commit_planning: ${desired}
16416
+ `;
16417
+ writeFileAtomic(configPath, appended);
16418
+ }
16419
+ } catch {
16420
+ }
16356
16421
  }
16357
16422
  try {
16358
16423
  const configText = fs2.readFileSync(configPath, "utf8");
@@ -16372,7 +16437,7 @@ ${BLOCK}`);
16372
16437
  const now = (/* @__PURE__ */ new Date()).toISOString();
16373
16438
  const stateContent = fs2.readFileSync(stateSrc, "utf8").replace(/__PROJECT_NAME__/g, opts.projectName).replace(/__INSTALL_DATE__/g, now);
16374
16439
  ensureDir(path2.dirname(stateDest));
16375
- fs2.writeFileSync(stateDest, stateContent);
16440
+ writeFileAtomic(stateDest, stateContent);
16376
16441
  }
16377
16442
  }
16378
16443
  ensureDir(path2.join(opts.target, ".planning", "council-sessions"));
@@ -16392,15 +16457,26 @@ ${BLOCK}`);
16392
16457
  path2.join(configDir, "files-manifest.csv"),
16393
16458
  generateFilesManifest(plan, opts.target, { mergeExistingManifest: !opts.force })
16394
16459
  );
16395
- let skillsInstalled = installSkills(PACKAGE_ROOT2, opts.target);
16460
+ const skillsResult = installSkills(PACKAGE_ROOT2, opts.target, {
16461
+ skipGlobalDuplicates: isProjectInstall
16462
+ });
16463
+ let skillsInstalled = skillsResult.count;
16464
+ if (skillsResult.skippedGlobal > 0) {
16465
+ console.log(" " + dim(`Skipped ${skillsResult.skippedGlobal} project-level rihal skills (global ones in ~/.claude/skills/ take precedence) \u2014 closes #679.`));
16466
+ }
16396
16467
  try {
16397
16468
  const { main: generateCommandSkills } = require(path2.join(PACKAGE_ROOT2, "cli", "generate-command-skills.cjs"));
16398
16469
  const stubsDir = path2.join(opts.target, ".claude", "skills");
16399
- const result = generateCommandSkills(PACKAGE_ROOT2, stubsDir, readPackageVersion());
16470
+ const result = generateCommandSkills(PACKAGE_ROOT2, stubsDir, readPackageVersion(), {
16471
+ skipGlobalDuplicates: isProjectInstall
16472
+ });
16400
16473
  if (result.generated > 0) {
16401
16474
  console.log(" " + dim(`${result.generated} sidebar skill stub${result.generated === 1 ? "" : "s"} generated for command discoverability`));
16402
16475
  skillsInstalled += result.generated;
16403
16476
  }
16477
+ if (result.skippedGlobal > 0) {
16478
+ console.log(" " + dim(`Skipped ${result.skippedGlobal} sidebar stub${result.skippedGlobal === 1 ? "" : "s"} that duplicate global ~/.claude/skills/ \u2014 closes #679.`));
16479
+ }
16404
16480
  } catch (err) {
16405
16481
  console.log(" " + dim(`(sidebar stub generation skipped: ${err.message})`));
16406
16482
  }
@@ -16494,10 +16570,10 @@ ${BLOCK}`);
16494
16570
  const commandFilter = primaryIde === "claude" ? (f) => f.startsWith("rihal-") && (f.endsWith(".md") || f.endsWith(".mdc")) : (f) => f.endsWith(".md") || f.endsWith(".mdc");
16495
16571
  commandCount = fs2.readdirSync(commandsDir).filter(commandFilter).length;
16496
16572
  }
16497
- if (agentCount === 0 || commandCount === 0) {
16498
- const os2 = require("os");
16499
- const homeAgents = path2.join(os2.homedir(), ".claude/agents");
16500
- 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");
16501
16577
  if (agentCount === 0 && fs2.existsSync(homeAgents)) {
16502
16578
  const n = fs2.readdirSync(homeAgents).filter((f) => f.startsWith("rihal-") && f.endsWith(".md")).length;
16503
16579
  if (n > 0) {
@@ -16512,6 +16588,13 @@ ${BLOCK}`);
16512
16588
  commandsFromGlobal = true;
16513
16589
  }
16514
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
+ }
16515
16598
  }
16516
16599
  } catch {
16517
16600
  }
@@ -16580,6 +16663,22 @@ ${BLOCK}`);
16580
16663
  console.log(` ${bold("Health check:")}`);
16581
16664
  const { execFileSync } = require("child_process");
16582
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
+ }
16583
16682
  function check(label, fn) {
16584
16683
  try {
16585
16684
  const out = fn();
@@ -16609,13 +16708,15 @@ ${BLOCK}`);
16609
16708
  return "valid JSON";
16610
16709
  });
16611
16710
  check("agents installed", () => {
16612
- 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
+ }
16613
16714
  return `${counts.agentCount}`;
16614
16715
  });
16615
16716
  check("skills + commands installed", () => {
16616
16717
  const issues = [];
16617
- if ((counts.skillsInstalled || 0) < 20) issues.push(`${counts.skillsInstalled} skills`);
16618
- 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})`);
16619
16720
  if (issues.length) throw new Error(`low count: ${issues.join(", ")}`);
16620
16721
  return `${counts.skillsInstalled} skills + ${counts.commandCount} commands`;
16621
16722
  });
@@ -17019,9 +17120,44 @@ var require_fsutil = __commonJS({
17019
17120
  const content = JSON.stringify(obj, null, 2) + "\n";
17020
17121
  writeFileAtomic(filePath, content, opts);
17021
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
+ }
17022
17157
  module2.exports = {
17023
17158
  writeFileAtomic,
17024
- writeJsonAtomic
17159
+ writeJsonAtomic,
17160
+ safeRmSync
17025
17161
  };
17026
17162
  }
17027
17163
  });
@@ -17502,7 +17638,7 @@ var require_uninstall = __commonJS({
17502
17638
  var path2 = require("path");
17503
17639
  var { spawnSync } = require("child_process");
17504
17640
  var { askConfirm, PromptAbortError } = require_prompts();
17505
- var { writeFileAtomic } = require_fsutil();
17641
+ var { writeFileAtomic, safeRmSync } = require_fsutil();
17506
17642
  function parseArgs(args) {
17507
17643
  const opts = {
17508
17644
  editor: null,
@@ -17538,11 +17674,16 @@ var require_uninstall = __commonJS({
17538
17674
  function removeMatching(dir, predicate) {
17539
17675
  if (!fs2.existsSync(dir)) return 0;
17540
17676
  let count = 0;
17677
+ const projectRoot = path2.resolve(process.cwd());
17541
17678
  for (const entry of fs2.readdirSync(dir, { withFileTypes: true })) {
17542
17679
  if (isLocalOverride(entry.name)) continue;
17543
17680
  if (!predicate(entry.name)) continue;
17544
17681
  const full = path2.join(dir, entry.name);
17545
- 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
+ }
17546
17687
  count++;
17547
17688
  }
17548
17689
  return count;
@@ -17666,7 +17807,7 @@ var require_uninstall = __commonJS({
17666
17807
  function isKnownSkillName(name) {
17667
17808
  return KNOWN_ACTION_SKILLS.includes(name);
17668
17809
  }
17669
- function planToPathList(plan, cwd) {
17810
+ function planToPathList(plan, cwd, options = {}) {
17670
17811
  const paths = [];
17671
17812
  for (const name of plan.claude.skills) {
17672
17813
  paths.push(path2.join(".claude/skills", name));
@@ -17689,10 +17830,25 @@ var require_uninstall = __commonJS({
17689
17830
  if (plan.agentsMd && fs2.existsSync(path2.join(cwd, "AGENTS.md"))) {
17690
17831
  paths.push("AGENTS.md");
17691
17832
  }
17833
+ if (options.purge) {
17834
+ const rihalDir = path2.join(cwd, ".rihal");
17835
+ if (fs2.existsSync(rihalDir)) {
17836
+ try {
17837
+ for (const entry of fs2.readdirSync(rihalDir)) {
17838
+ if (entry === "backups") continue;
17839
+ paths.push(path2.join(".rihal", entry));
17840
+ }
17841
+ } catch {
17842
+ }
17843
+ }
17844
+ if (fs2.existsSync(path2.join(cwd, ".planning"))) {
17845
+ paths.push(".planning");
17846
+ }
17847
+ }
17692
17848
  return paths;
17693
17849
  }
17694
- function createBackup(cwd, plan) {
17695
- const paths = planToPathList(plan, cwd);
17850
+ function createBackup(cwd, plan, options = {}) {
17851
+ const paths = planToPathList(plan, cwd, { purge: options.purge === true });
17696
17852
  if (paths.length === 0) {
17697
17853
  return { ok: false, warning: "nothing to back up" };
17698
17854
  }
@@ -17700,11 +17856,11 @@ var require_uninstall = __commonJS({
17700
17856
  if (tarCheck.status !== 0) {
17701
17857
  return { ok: false, warning: "tar not available on this system" };
17702
17858
  }
17703
- const backupsDir = path2.join(cwd, ".rihal/backups");
17859
+ const backupsDir = options.purge ? path2.join(cwd, ".rihal-backups") : path2.join(cwd, ".rihal/backups");
17704
17860
  try {
17705
17861
  fs2.mkdirSync(backupsDir, { recursive: true });
17706
17862
  } catch (err) {
17707
- return { ok: false, warning: `could not create .rihal/backups/: ${err.message}` };
17863
+ return { ok: false, warning: `could not create ${path2.relative(cwd, backupsDir)}/: ${err.message}` };
17708
17864
  }
17709
17865
  const ts = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19);
17710
17866
  const backupFile = path2.join(backupsDir, `uninstall-${ts}.tgz`);
@@ -17845,9 +18001,12 @@ var require_uninstall = __commonJS({
17845
18001
  }
17846
18002
  }
17847
18003
  console.log();
17848
- const backup = createBackup(cwd, plan);
18004
+ const backup = createBackup(cwd, plan, { purge: opts.purge === true });
17849
18005
  if (backup.ok) {
17850
18006
  console.log(` \u{1F4BE} backup created: ${backup.path}`);
18007
+ if (opts.purge) {
18008
+ console.log(" includes .rihal/ and .planning/ (state, decisions, planning artifacts)");
18009
+ }
17851
18010
  } else {
17852
18011
  console.log(` \u26A0 no backup created (${backup.warning}) \u2014 continuing anyway`);
17853
18012
  }
@@ -17860,7 +18019,10 @@ var require_uninstall = __commonJS({
17860
18019
  if (n > 0) console.log(` \u2713 removed ${n} Claude skills`);
17861
18020
  const commandsDir = path2.join(cwd, ".claude/commands/rihal");
17862
18021
  if (fs2.existsSync(commandsDir)) {
17863
- 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
+ }
17864
18026
  }
17865
18027
  const commandsRoot = path2.join(cwd, ".claude/commands");
17866
18028
  let commandsRemoved = 0;
@@ -17938,8 +18100,12 @@ var require_uninstall = __commonJS({
17938
18100
  ]);
17939
18101
  const brainDir = path2.join(cwd, ".rihal", "brain");
17940
18102
  if (fs2.existsSync(brainDir)) {
17941
- fs2.rmSync(brainDir, { recursive: true, force: true });
17942
- 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
+ }
17943
18109
  }
17944
18110
  if (plan.stateDir) {
17945
18111
  const rihalDir = path2.join(cwd, ".rihal");
@@ -17961,8 +18127,14 @@ var require_uninstall = __commonJS({
17961
18127
  );
17962
18128
  }
17963
18129
  if (shouldDeleteState) {
17964
- fs2.rmSync(rihalDir, { recursive: true, force: true });
17965
- 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
+ }
17966
18138
  } else {
17967
18139
  console.log(` \u2139 kept .rihal/ state directory (your project data is preserved)`);
17968
18140
  }
@@ -17970,14 +18142,20 @@ var require_uninstall = __commonJS({
17970
18142
  if (opts.purge) {
17971
18143
  const planningDir = path2.join(cwd, ".planning");
17972
18144
  if (fs2.existsSync(planningDir)) {
17973
- fs2.rmSync(planningDir, { recursive: true, force: true });
17974
- 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
+ }
17975
18153
  }
17976
18154
  const gitignorePath = path2.join(cwd, ".gitignore");
17977
18155
  if (fs2.existsSync(gitignorePath)) {
17978
18156
  try {
17979
18157
  const before = fs2.readFileSync(gitignorePath, "utf8");
17980
- const stripped = before.replace(/\n?# >>> rihal-code >>>[\s\S]*?# <<< rihal-code <<<\n?/g, "\n").replace(/\n?# rcode[\s\S]*?(?=\n\n|\n$|$)/g, "\n").replace(/\n{3,}/g, "\n\n");
18158
+ const stripped = before.replace(/\n?# ===== rcode-managed gitignore block[\s\S]*?# ===== end rcode-managed gitignore block =====\n?/g, "\n").replace(/\n?# >>> rihal-code >>>[\s\S]*?# <<< rihal-code <<<\n?/g, "\n").replace(/\n{3,}/g, "\n\n");
17981
18159
  if (stripped !== before) {
17982
18160
  fs2.writeFileSync(gitignorePath, stripped);
17983
18161
  console.log(` \u2713 stripped rcode block from .gitignore (--purge)`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanzlaa/rcode",
3
- "version": "3.4.22",
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": {
@@ -15,6 +15,7 @@
15
15
  "postinstall": "node cli/postinstall.js",
16
16
  "build:cli": "node scripts/build.cjs",
17
17
  "build": "node scripts/build.cjs",
18
+ "prepack": "node scripts/build.cjs",
18
19
  "dogfood": "bash scripts/dogfood-check.sh"
19
20
  },
20
21
  "files": [