@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 +67 -22
- package/cli/lib/fsutil.cjs +66 -0
- package/cli/uninstall.js +35 -9
- package/dist/rcode.js +236 -58
- package/package.json +2 -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();
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
15403
|
+
project: null,
|
|
15404
|
+
_seeded_stub: true,
|
|
15386
15405
|
created: now,
|
|
15387
15406
|
updated: now,
|
|
15388
|
-
current_phase:
|
|
15407
|
+
current_phase: null,
|
|
15389
15408
|
current_plan: 0,
|
|
15390
15409
|
current_sprint: null,
|
|
15391
|
-
milestone:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
15499
|
+
writeFileAtomic(gitignorePath, rewritten);
|
|
15483
15500
|
return { action: "updated" };
|
|
15484
15501
|
}
|
|
15485
15502
|
return { action: "already-present" };
|
|
15486
15503
|
}
|
|
15487
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
16499
|
-
const
|
|
16500
|
-
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");
|
|
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) <
|
|
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) <
|
|
16618
|
-
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})`);
|
|
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
|
-
|
|
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 .
|
|
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
|
-
|
|
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
|
-
|
|
17942
|
-
|
|
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
|
-
|
|
17965
|
-
|
|
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
|
-
|
|
17974
|
-
|
|
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?#
|
|
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.
|
|
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": [
|