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