@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 +67 -22
- package/cli/lib/fsutil.cjs +66 -0
- package/cli/uninstall.js +35 -9
- package/dist/rcode.js +116 -34
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
757
|
+
writeFileAtomic(gitignorePath, rewritten);
|
|
754
758
|
return { action: 'updated' };
|
|
755
759
|
}
|
|
756
760
|
return { action: 'already-present' };
|
|
757
761
|
}
|
|
758
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2162
|
-
|
|
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) <
|
|
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) <
|
|
2293
|
-
if ((counts.commandCount || 0) <
|
|
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
|
});
|
package/cli/lib/fsutil.cjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
645
|
-
|
|
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
|
-
|
|
672
|
-
|
|
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
|
-
|
|
685
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
15499
|
+
writeFileAtomic(gitignorePath, rewritten);
|
|
15499
15500
|
return { action: "updated" };
|
|
15500
15501
|
}
|
|
15501
15502
|
return { action: "already-present" };
|
|
15502
15503
|
}
|
|
15503
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
16577
|
-
const
|
|
16578
|
-
const
|
|
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) <
|
|
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) <
|
|
16696
|
-
if ((counts.commandCount || 0) <
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
18038
|
-
|
|
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
|
-
|
|
18061
|
-
|
|
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
|
-
|
|
18070
|
-
|
|
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.
|
|
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": {
|