@hanzlaa/rcode 3.4.21 → 3.4.22
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/generate-command-skills.cjs +33 -2
- package/cli/index.js +8 -8
- package/cli/install.js +115 -11
- package/cli/set-profile.js +5 -5
- package/cli/show-model.js +3 -3
- package/cli/uninstall.js +61 -9
- package/package.json +1 -1
- package/rihal/bin/rihal-tools.cjs +37 -0
|
@@ -149,7 +149,7 @@ Identical to \`/rihal-${cmdName}\`. See the workflow file for the canonical outp
|
|
|
149
149
|
`;
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
-
function main(packageRoot, targetSkillsDir, version) {
|
|
152
|
+
function main(packageRoot, targetSkillsDir, version, options = {}) {
|
|
153
153
|
if (!fs.existsSync(targetSkillsDir)) {
|
|
154
154
|
fs.mkdirSync(targetSkillsDir, { recursive: true });
|
|
155
155
|
}
|
|
@@ -158,11 +158,20 @@ function main(packageRoot, targetSkillsDir, version) {
|
|
|
158
158
|
const commandsDir = path.join(packageRoot, 'rihal', 'commands');
|
|
159
159
|
if (!fs.existsSync(commandsDir)) {
|
|
160
160
|
console.warn(`[generate-command-skills] commands dir not found: ${commandsDir}`);
|
|
161
|
-
return { generated: 0, skipped: 0 };
|
|
161
|
+
return { generated: 0, skipped: 0, skippedGlobal: 0 };
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
// Issue #679: skip stubs whose name already exists in ~/.claude/skills/.
|
|
165
|
+
// Otherwise the slash picker shows the same /rihal-* twice.
|
|
166
|
+
const os = require('os');
|
|
167
|
+
const globalSkillsDir = path.join(os.homedir(), '.claude', 'skills');
|
|
168
|
+
const globalSkills = (options.skipGlobalDuplicates && fs.existsSync(globalSkillsDir))
|
|
169
|
+
? new Set(fs.readdirSync(globalSkillsDir).filter(n => n.startsWith('rihal-')))
|
|
170
|
+
: new Set();
|
|
171
|
+
|
|
164
172
|
let generated = 0;
|
|
165
173
|
let skipped = 0;
|
|
174
|
+
let skippedGlobal = 0;
|
|
166
175
|
|
|
167
176
|
for (const file of fs.readdirSync(commandsDir)) {
|
|
168
177
|
if (!file.endsWith('.md')) continue;
|
|
@@ -177,6 +186,28 @@ function main(packageRoot, targetSkillsDir, version) {
|
|
|
177
186
|
continue;
|
|
178
187
|
}
|
|
179
188
|
|
|
189
|
+
if (globalSkills.has(skillName)) {
|
|
190
|
+
// Global skill of same name exists — installing a sidebar stub here would
|
|
191
|
+
// duplicate the entry in Claude Code's slash picker. Also clean up any
|
|
192
|
+
// previously-generated stub with the same name.
|
|
193
|
+
const existingStub = path.join(targetSkillsDir, skillName);
|
|
194
|
+
if (fs.existsSync(existingStub)) {
|
|
195
|
+
try {
|
|
196
|
+
const existingFile = path.join(existingStub, 'SKILL.md');
|
|
197
|
+
if (fs.existsSync(existingFile)) {
|
|
198
|
+
const text = fs.readFileSync(existingFile, 'utf8');
|
|
199
|
+
// Only delete previously-generated stubs (marker present) — never
|
|
200
|
+
// user customizations.
|
|
201
|
+
if (/^generated:\s*true/m.test(text)) {
|
|
202
|
+
fs.rmSync(existingStub, { recursive: true, force: true });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
} catch { /* non-fatal */ }
|
|
206
|
+
}
|
|
207
|
+
skippedGlobal++;
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
|
|
180
211
|
const cmdPath = path.join(commandsDir, file);
|
|
181
212
|
const cmdText = fs.readFileSync(cmdPath, 'utf8');
|
|
182
213
|
const cmdFm = parseFrontmatter(cmdText);
|
package/cli/index.js
CHANGED
|
@@ -3,14 +3,14 @@
|
|
|
3
3
|
* Rihal Code CLI
|
|
4
4
|
*
|
|
5
5
|
* Usage:
|
|
6
|
-
* npx @
|
|
7
|
-
* npx @
|
|
8
|
-
* npx @
|
|
9
|
-
* npx @
|
|
10
|
-
* npx @
|
|
11
|
-
* npx @
|
|
12
|
-
* npx @
|
|
13
|
-
* npx @
|
|
6
|
+
* npx @hanzlaa/rcode init → scaffold .rihal/ in current project
|
|
7
|
+
* npx @hanzlaa/rcode dashboard → start the Diwan view-only dashboard
|
|
8
|
+
* npx @hanzlaa/rcode serve → alias for dashboard
|
|
9
|
+
* npx @hanzlaa/rcode digest → print compact agent digests
|
|
10
|
+
* npx @hanzlaa/rcode team → list the team roster
|
|
11
|
+
* npx @hanzlaa/rcode doctor → compliance check
|
|
12
|
+
* npx @hanzlaa/rcode version → print version
|
|
13
|
+
* npx @hanzlaa/rcode help → this message
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
const path = require('path');
|
package/cli/install.js
CHANGED
|
@@ -370,11 +370,32 @@ async function resolveIde(opts) {
|
|
|
370
370
|
async function resolveCommitPlanning(opts) {
|
|
371
371
|
if (opts.commitPlanning !== null) return opts.commitPlanning;
|
|
372
372
|
if (opts.noPrompt || opts.global) return false; // global install: no planning artifacts
|
|
373
|
-
if (opts.yes || !process.stdin.isTTY) return true; // non-interactive default
|
|
374
373
|
|
|
374
|
+
// Issue #685: on re-install, read the existing .rihal/config.yaml and use
|
|
375
|
+
// its commit_planning value as the default. Otherwise the new prompt
|
|
376
|
+
// answer overwrites .gitignore but NOT config.yaml, leaving two sources of
|
|
377
|
+
// truth that silently diverge. Users on re-install almost always want to
|
|
378
|
+
// KEEP their existing setting unless they explicitly pass --commit-planning.
|
|
379
|
+
let existingValue = null;
|
|
380
|
+
try {
|
|
381
|
+
const cfgPath = path.join(opts.target, '.rihal', 'config.yaml');
|
|
382
|
+
if (fs.existsSync(cfgPath)) {
|
|
383
|
+
const cfg = fs.readFileSync(cfgPath, 'utf8');
|
|
384
|
+
const m = cfg.match(/^commit_planning:\s*(true|false)\s*$/m);
|
|
385
|
+
if (m) existingValue = m[1] === 'true';
|
|
386
|
+
}
|
|
387
|
+
} catch { /* fall through to prompt */ }
|
|
388
|
+
|
|
389
|
+
if (opts.yes || !process.stdin.isTTY) {
|
|
390
|
+
return existingValue !== null ? existingValue : true; // honor existing on re-install
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const initialValue = existingValue === false ? 'gitignore' : 'commit';
|
|
375
394
|
const choice = await clack.select({
|
|
376
|
-
message:
|
|
377
|
-
|
|
395
|
+
message: existingValue !== null
|
|
396
|
+
? '📋 .planning/ tracking — current setting preserved unless you change it.'
|
|
397
|
+
: '📋 .planning/ holds PRDs, roadmaps, sprints, SUMMARY files. How should they be tracked?',
|
|
398
|
+
initialValue,
|
|
378
399
|
options: [
|
|
379
400
|
{ value: 'commit', label: 'Commit', hint: 'collaborators see the same plans (recommended)' },
|
|
380
401
|
{ value: 'gitignore', label: 'Gitignore', hint: 'planning stays local (good for sensitive PRDs)' },
|
|
@@ -873,16 +894,26 @@ function installBrainScaffold(packageRoot, target) {
|
|
|
873
894
|
*
|
|
874
895
|
* A skill is marked internal by adding `internal: true` to its SKILL.md frontmatter.
|
|
875
896
|
*/
|
|
876
|
-
function installSkills(packageRoot, target) {
|
|
897
|
+
function installSkills(packageRoot, target, options = {}) {
|
|
877
898
|
const skillsSource = path.join(packageRoot, 'rihal/skills');
|
|
878
899
|
const skillsDest = path.join(target, '.claude/skills');
|
|
879
900
|
const internalDest = path.join(target, '.rihal/skills');
|
|
880
901
|
|
|
881
|
-
if (!fs.existsSync(skillsSource)) return 0;
|
|
902
|
+
if (!fs.existsSync(skillsSource)) return { count: 0, skippedGlobal: 0 };
|
|
882
903
|
fs.mkdirSync(skillsDest, { recursive: true });
|
|
883
904
|
fs.mkdirSync(internalDest, { recursive: true });
|
|
884
905
|
|
|
906
|
+
// Issue #679: when ~/.claude/skills/<name>/ already exists with the rihal-
|
|
907
|
+
// prefix, Claude Code reads from BOTH global and project, showing every
|
|
908
|
+
// /rihal-* twice in the slash picker. Skip the project copy for any rihal-*
|
|
909
|
+
// skill that already lives in the global skills dir.
|
|
910
|
+
const globalSkillsDir = path.join(os.homedir(), '.claude', 'skills');
|
|
911
|
+
const globalRihalSkills = (options.skipGlobalDuplicates && fs.existsSync(globalSkillsDir))
|
|
912
|
+
? new Set(fs.readdirSync(globalSkillsDir).filter(n => n.startsWith('rihal-')))
|
|
913
|
+
: new Set();
|
|
914
|
+
|
|
885
915
|
let count = 0;
|
|
916
|
+
let skippedGlobal = 0;
|
|
886
917
|
|
|
887
918
|
function isInternalSkill(skillDir) {
|
|
888
919
|
const skillMd = path.join(skillDir, 'SKILL.md');
|
|
@@ -891,6 +922,13 @@ function installSkills(packageRoot, target) {
|
|
|
891
922
|
return /^internal:\s*true\s*$/m.test(text);
|
|
892
923
|
}
|
|
893
924
|
|
|
925
|
+
function hasLocalOverride(destDir) {
|
|
926
|
+
if (!fs.existsSync(destDir)) return false;
|
|
927
|
+
try {
|
|
928
|
+
return fs.readdirSync(destDir).some(f => f.endsWith('.local.md'));
|
|
929
|
+
} catch { return false; }
|
|
930
|
+
}
|
|
931
|
+
|
|
894
932
|
function walkForSkills(dir) {
|
|
895
933
|
if (!fs.existsSync(dir)) return;
|
|
896
934
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
@@ -901,9 +939,23 @@ function installSkills(packageRoot, target) {
|
|
|
901
939
|
const destName = entry.name.startsWith('rihal-')
|
|
902
940
|
? entry.name
|
|
903
941
|
: `rihal-${entry.name}`;
|
|
904
|
-
const
|
|
942
|
+
const internal = isInternalSkill(src);
|
|
943
|
+
const dest = internal
|
|
905
944
|
? path.join(internalDest, destName) // internal → .rihal/skills/
|
|
906
945
|
: path.join(skillsDest, destName); // user-facing → .claude/skills/
|
|
946
|
+
|
|
947
|
+
// Skip user-facing (non-internal) rihal-* skills when the same name
|
|
948
|
+
// exists globally — UNLESS the user has a *.local.md override on the
|
|
949
|
+
// project copy, in which case we always preserve their customization.
|
|
950
|
+
if (!internal && globalRihalSkills.has(destName) && !hasLocalOverride(dest)) {
|
|
951
|
+
// Also remove the existing project copy (left over from previous
|
|
952
|
+
// installs that didn't dedup) so it stops showing in the picker.
|
|
953
|
+
if (fs.existsSync(dest)) {
|
|
954
|
+
try { fs.rmSync(dest, { recursive: true, force: true }); } catch { /* non-fatal */ }
|
|
955
|
+
}
|
|
956
|
+
skippedGlobal++;
|
|
957
|
+
continue;
|
|
958
|
+
}
|
|
907
959
|
copyDirRecursive(src, dest);
|
|
908
960
|
count++;
|
|
909
961
|
} else {
|
|
@@ -916,7 +968,7 @@ function installSkills(packageRoot, target) {
|
|
|
916
968
|
walkForSkills(path.join(skillsSource, bucket));
|
|
917
969
|
}
|
|
918
970
|
|
|
919
|
-
return count;
|
|
971
|
+
return { count, skippedGlobal };
|
|
920
972
|
}
|
|
921
973
|
|
|
922
974
|
/**
|
|
@@ -1425,6 +1477,18 @@ function convertToCursorMdc(sourceText) {
|
|
|
1425
1477
|
async function install(opts) {
|
|
1426
1478
|
if (opts.help) { printHelp(); return 0; }
|
|
1427
1479
|
|
|
1480
|
+
// Issue #680: --reset alone is a footgun — silently does nothing. Fail
|
|
1481
|
+
// fast with a clear message before any work happens.
|
|
1482
|
+
if (opts.reset && !opts.force) {
|
|
1483
|
+
console.log('');
|
|
1484
|
+
console.log(' ' + warn('--reset has no effect without --force.'));
|
|
1485
|
+
console.log(' ' + dim(' --reset wipes config.yaml and state.json. To prevent accidental data loss,'));
|
|
1486
|
+
console.log(' ' + dim(' it must be paired with --force. Re-run as:'));
|
|
1487
|
+
console.log(' ' + dim(' rcode install --reset --force'));
|
|
1488
|
+
console.log('');
|
|
1489
|
+
return 2;
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1428
1492
|
const pkgVersion = readPackageVersion();
|
|
1429
1493
|
|
|
1430
1494
|
// Header banner — only shown for interactive runs to keep CI/non-TTY logs terse.
|
|
@@ -1754,8 +1818,10 @@ async function install(opts) {
|
|
|
1754
1818
|
const configDir = path.join(opts.target, '.rihal', '_config');
|
|
1755
1819
|
ensureDir(configDir);
|
|
1756
1820
|
fs.writeFileSync(path.join(configDir, 'manifest.yaml'), generateInstallManifest(opts));
|
|
1757
|
-
// Install skills + sidebar stubs globally
|
|
1758
|
-
|
|
1821
|
+
// Install skills + sidebar stubs globally — never dedup against globals,
|
|
1822
|
+
// because in --global mode the target IS the global dir.
|
|
1823
|
+
const skillsResult = installSkills(PACKAGE_ROOT, opts.target);
|
|
1824
|
+
let skillsInstalled = skillsResult.count;
|
|
1759
1825
|
try {
|
|
1760
1826
|
const { main: generateCommandSkills } = require(path.join(PACKAGE_ROOT, 'cli', 'generate-command-skills.cjs'));
|
|
1761
1827
|
const stubsDir = path.join(opts.target, '.claude', 'skills');
|
|
@@ -1831,6 +1897,7 @@ async function install(opts) {
|
|
|
1831
1897
|
plan.length = 0;
|
|
1832
1898
|
filtered.forEach(e => plan.push(e));
|
|
1833
1899
|
}
|
|
1900
|
+
|
|
1834
1901
|
} catch { /* non-fatal — skip detection on permission errors */ }
|
|
1835
1902
|
}
|
|
1836
1903
|
|
|
@@ -1855,11 +1922,34 @@ async function install(opts) {
|
|
|
1855
1922
|
} else if (opts.force && (fs.existsSync(configPath) || fs.existsSync(stateDest))) {
|
|
1856
1923
|
existedBefore = true;
|
|
1857
1924
|
}
|
|
1925
|
+
// Note: --reset without --force is rejected at the top of install() (#680).
|
|
1858
1926
|
|
|
1859
1927
|
// Write .rihal/config.yaml (user_name, project_name, language, mode)
|
|
1860
1928
|
// Note: config.yaml is user data and should NOT be overwritten on --force (unless --reset)
|
|
1861
1929
|
if (!fs.existsSync(configPath)) {
|
|
1862
1930
|
fs.writeFileSync(configPath, generateConfigYaml(opts));
|
|
1931
|
+
} else {
|
|
1932
|
+
// Issue #685: re-install path. config.yaml is preserved BUT if the user
|
|
1933
|
+
// just changed commit_planning via the prompt/flag, .gitignore will be
|
|
1934
|
+
// rewritten with the new value while config.yaml keeps the old one,
|
|
1935
|
+
// creating a silent drift. Update only commit_planning in-place
|
|
1936
|
+
// (preserve everything else the user may have customized).
|
|
1937
|
+
try {
|
|
1938
|
+
const before = fs.readFileSync(configPath, 'utf8');
|
|
1939
|
+
const desired = opts.commitPlanning !== false;
|
|
1940
|
+
const re = /^commit_planning:\s*(true|false)\s*$/m;
|
|
1941
|
+
const match = before.match(re);
|
|
1942
|
+
const currentInFile = match ? match[1] === 'true' : null;
|
|
1943
|
+
if (match && currentInFile !== desired) {
|
|
1944
|
+
const updated = before.replace(re, `commit_planning: ${desired}`);
|
|
1945
|
+
fs.writeFileSync(configPath, updated);
|
|
1946
|
+
console.log(' ' + dim(`Updated commit_planning in config.yaml (${currentInFile} → ${desired}) — closes #685.`));
|
|
1947
|
+
} else if (!match) {
|
|
1948
|
+
// Older config without the key — append it so the next read finds it.
|
|
1949
|
+
const appended = before.replace(/\n*$/, '') + `\ncommit_planning: ${desired}\n`;
|
|
1950
|
+
fs.writeFileSync(configPath, appended);
|
|
1951
|
+
}
|
|
1952
|
+
} catch { /* best-effort — never fail install on this */ }
|
|
1863
1953
|
}
|
|
1864
1954
|
// Validate config.yaml with zod schema (#250) — warn but never block install.
|
|
1865
1955
|
try {
|
|
@@ -1917,7 +2007,16 @@ async function install(opts) {
|
|
|
1917
2007
|
|
|
1918
2008
|
// Install v1-style phrase-activated skills (scaffold-project, create-prd,
|
|
1919
2009
|
// retrospective, etc.) into .claude/skills/ alongside the v2 agents/commands.
|
|
1920
|
-
|
|
2010
|
+
// Issue #679: skip rihal-* skills that already exist in ~/.claude/skills/
|
|
2011
|
+
// (global precedence) so the slash picker doesn't show every command twice.
|
|
2012
|
+
// Reuse the isProjectInstall flag declared earlier in this scope.
|
|
2013
|
+
const skillsResult = installSkills(PACKAGE_ROOT, opts.target, {
|
|
2014
|
+
skipGlobalDuplicates: isProjectInstall,
|
|
2015
|
+
});
|
|
2016
|
+
let skillsInstalled = skillsResult.count;
|
|
2017
|
+
if (skillsResult.skippedGlobal > 0) {
|
|
2018
|
+
console.log(' ' + dim(`Skipped ${skillsResult.skippedGlobal} project-level rihal skills (global ones in ~/.claude/skills/ take precedence) — closes #679.`));
|
|
2019
|
+
}
|
|
1921
2020
|
|
|
1922
2021
|
// Generate install-time skill stubs that mirror sidebar-worthy slash commands.
|
|
1923
2022
|
// Source codebase stays clean — these stubs only exist at the install
|
|
@@ -1926,11 +2025,16 @@ async function install(opts) {
|
|
|
1926
2025
|
try {
|
|
1927
2026
|
const { main: generateCommandSkills } = require(path.join(PACKAGE_ROOT, 'cli', 'generate-command-skills.cjs'));
|
|
1928
2027
|
const stubsDir = path.join(opts.target, '.claude', 'skills');
|
|
1929
|
-
const result = generateCommandSkills(PACKAGE_ROOT, stubsDir, readPackageVersion()
|
|
2028
|
+
const result = generateCommandSkills(PACKAGE_ROOT, stubsDir, readPackageVersion(), {
|
|
2029
|
+
skipGlobalDuplicates: isProjectInstall,
|
|
2030
|
+
});
|
|
1930
2031
|
if (result.generated > 0) {
|
|
1931
2032
|
console.log(' ' + dim(`${result.generated} sidebar skill stub${result.generated === 1 ? '' : 's'} generated for command discoverability`));
|
|
1932
2033
|
skillsInstalled += result.generated;
|
|
1933
2034
|
}
|
|
2035
|
+
if (result.skippedGlobal > 0) {
|
|
2036
|
+
console.log(' ' + dim(`Skipped ${result.skippedGlobal} sidebar stub${result.skippedGlobal === 1 ? '' : 's'} that duplicate global ~/.claude/skills/ — closes #679.`));
|
|
2037
|
+
}
|
|
1934
2038
|
} catch (err) {
|
|
1935
2039
|
// Non-fatal: install succeeds without sidebar stubs
|
|
1936
2040
|
console.log(' ' + dim(`(sidebar stub generation skipped: ${err.message})`));
|
package/cli/set-profile.js
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
* rihal-code set-profile — change the model profile for the current project
|
|
3
3
|
*
|
|
4
4
|
* Usage:
|
|
5
|
-
* npx @
|
|
6
|
-
* npx @
|
|
7
|
-
* npx @
|
|
8
|
-
* npx @
|
|
9
|
-
* npx @
|
|
5
|
+
* npx @hanzlaa/rcode set-profile balanced
|
|
6
|
+
* npx @hanzlaa/rcode set-profile quality
|
|
7
|
+
* npx @hanzlaa/rcode set-profile budget
|
|
8
|
+
* npx @hanzlaa/rcode set-profile inherit
|
|
9
|
+
* npx @hanzlaa/rcode set-profile # show current
|
|
10
10
|
*
|
|
11
11
|
* This is a thin wrapper over `rihal-code config model_profile <name>`.
|
|
12
12
|
* All config read/write goes through cli/lib/config.cjs, which handles
|
package/cli/show-model.js
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
* rihal-code show-model — print the resolved model for an agent (or all agents)
|
|
3
3
|
*
|
|
4
4
|
* Usage:
|
|
5
|
-
* npx @
|
|
6
|
-
* npx @
|
|
7
|
-
* npx @
|
|
5
|
+
* npx @hanzlaa/rcode show-model # all agents in current profile
|
|
6
|
+
* npx @hanzlaa/rcode show-model waleed # single agent
|
|
7
|
+
* npx @hanzlaa/rcode show-model --profile=quality # different profile
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
const {
|
package/cli/uninstall.js
CHANGED
|
@@ -250,8 +250,15 @@ function isKnownSkillName(name) {
|
|
|
250
250
|
/**
|
|
251
251
|
* Build the list of files/dirs (relative to cwd) that the uninstall plan
|
|
252
252
|
* will delete or mutate. Used to feed `tar --files-from=-`.
|
|
253
|
+
*
|
|
254
|
+
* @param {object} plan — uninstall plan
|
|
255
|
+
* @param {string} cwd — project root
|
|
256
|
+
* @param {object} [options]
|
|
257
|
+
* @param {boolean} [options.purge=false] — when true, also include .rihal/
|
|
258
|
+
* and .planning/ in the backup so --purge users can recover state.json,
|
|
259
|
+
* decisions, and planning artifacts. Issue #683.
|
|
253
260
|
*/
|
|
254
|
-
function planToPathList(plan, cwd) {
|
|
261
|
+
function planToPathList(plan, cwd, options = {}) {
|
|
255
262
|
const paths = [];
|
|
256
263
|
|
|
257
264
|
for (const name of plan.claude.skills) {
|
|
@@ -278,6 +285,26 @@ function planToPathList(plan, cwd) {
|
|
|
278
285
|
paths.push('AGENTS.md');
|
|
279
286
|
}
|
|
280
287
|
|
|
288
|
+
// Issue #683: --purge wipes .rihal/ AND .planning/ but the backup never
|
|
289
|
+
// included them. User loses state.json, decisions, planning artifacts with
|
|
290
|
+
// no recovery. Add them when purging — but EXCLUDE .rihal/backups/ itself
|
|
291
|
+
// (we'd be writing into the dir we're tar-ing).
|
|
292
|
+
if (options.purge) {
|
|
293
|
+
const rihalDir = path.join(cwd, '.rihal');
|
|
294
|
+
if (fs.existsSync(rihalDir)) {
|
|
295
|
+
// Walk one level deep and add everything except backups/
|
|
296
|
+
try {
|
|
297
|
+
for (const entry of fs.readdirSync(rihalDir)) {
|
|
298
|
+
if (entry === 'backups') continue;
|
|
299
|
+
paths.push(path.join('.rihal', entry));
|
|
300
|
+
}
|
|
301
|
+
} catch { /* fall through; ok=false from tar will warn */ }
|
|
302
|
+
}
|
|
303
|
+
if (fs.existsSync(path.join(cwd, '.planning'))) {
|
|
304
|
+
paths.push('.planning');
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
281
308
|
return paths;
|
|
282
309
|
}
|
|
283
310
|
|
|
@@ -288,8 +315,8 @@ function planToPathList(plan, cwd) {
|
|
|
288
315
|
* (tar missing, no paths, etc.); the caller should warn the user but may
|
|
289
316
|
* still proceed since the user already confirmed the destructive action.
|
|
290
317
|
*/
|
|
291
|
-
function createBackup(cwd, plan) {
|
|
292
|
-
const paths = planToPathList(plan, cwd);
|
|
318
|
+
function createBackup(cwd, plan, options = {}) {
|
|
319
|
+
const paths = planToPathList(plan, cwd, { purge: options.purge === true });
|
|
293
320
|
if (paths.length === 0) {
|
|
294
321
|
return { ok: false, warning: 'nothing to back up' };
|
|
295
322
|
}
|
|
@@ -301,11 +328,17 @@ function createBackup(cwd, plan) {
|
|
|
301
328
|
return { ok: false, warning: 'tar not available on this system' };
|
|
302
329
|
}
|
|
303
330
|
|
|
304
|
-
|
|
331
|
+
// Issue #683: when --purge wipes .rihal/, a backup written into
|
|
332
|
+
// .rihal/backups/ would be deleted moments later. Write to a sibling
|
|
333
|
+
// .rihal-backups/ at the project root instead so the backup survives.
|
|
334
|
+
// For non-purge runs, keep the historical .rihal/backups/ location.
|
|
335
|
+
const backupsDir = options.purge
|
|
336
|
+
? path.join(cwd, '.rihal-backups')
|
|
337
|
+
: path.join(cwd, '.rihal/backups');
|
|
305
338
|
try {
|
|
306
339
|
fs.mkdirSync(backupsDir, { recursive: true });
|
|
307
340
|
} catch (err) {
|
|
308
|
-
return { ok: false, warning: `could not create .
|
|
341
|
+
return { ok: false, warning: `could not create ${path.relative(cwd, backupsDir)}/: ${err.message}` };
|
|
309
342
|
}
|
|
310
343
|
|
|
311
344
|
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
@@ -489,10 +522,15 @@ async function runUninstall(args) {
|
|
|
489
522
|
|
|
490
523
|
// Create a timestamped backup before doing anything destructive.
|
|
491
524
|
// Non-fatal on failure — the user already confirmed, we just warn.
|
|
525
|
+
// Issue #683: --purge backs up .rihal/ and .planning/ too so users can
|
|
526
|
+
// recover state.json, decisions log, and planning artifacts.
|
|
492
527
|
console.log();
|
|
493
|
-
const backup = createBackup(cwd, plan);
|
|
528
|
+
const backup = createBackup(cwd, plan, { purge: opts.purge === true });
|
|
494
529
|
if (backup.ok) {
|
|
495
530
|
console.log(` 💾 backup created: ${backup.path}`);
|
|
531
|
+
if (opts.purge) {
|
|
532
|
+
console.log(' includes .rihal/ and .planning/ (state, decisions, planning artifacts)');
|
|
533
|
+
}
|
|
496
534
|
} else {
|
|
497
535
|
console.log(` ⚠ no backup created (${backup.warning}) — continuing anyway`);
|
|
498
536
|
}
|
|
@@ -649,15 +687,29 @@ async function runUninstall(args) {
|
|
|
649
687
|
|
|
650
688
|
// Strip the rcode-managed block from .gitignore. The installer writes
|
|
651
689
|
// a fenced block; we remove it cleanly without touching user lines.
|
|
690
|
+
//
|
|
691
|
+
// Issue #684: previous regex `/\n?# rcode[\s\S]*?(?=\n\n|\n$|$)/g` was a
|
|
692
|
+
// footgun — it matched ANY user line starting with "# rcode" (e.g.
|
|
693
|
+
// "# rcode notes", "# rcode is great") and greedily consumed everything
|
|
694
|
+
// up to the next blank line, silently nuking user content.
|
|
695
|
+
//
|
|
696
|
+
// Three shapes have ever shipped:
|
|
697
|
+
// 1. Current (install.js:653-654): "# ===== rcode-managed gitignore block ... =====" ... "# ===== end rcode-managed gitignore block ====="
|
|
698
|
+
// 2. Old fenced markers: "# >>> rihal-code >>>" ... "# <<< rihal-code <<<"
|
|
699
|
+
// 3. Hypothetical legacy single-line "# rcode" — never actually
|
|
700
|
+
// committed by any installer version we can find. Removed.
|
|
701
|
+
//
|
|
702
|
+
// Both kept patterns require BOTH sentinel markers to be present —
|
|
703
|
+
// user content with "# rcode" prefix is now safe.
|
|
652
704
|
const gitignorePath = path.join(cwd, '.gitignore');
|
|
653
705
|
if (fs.existsSync(gitignorePath)) {
|
|
654
706
|
try {
|
|
655
707
|
const before = fs.readFileSync(gitignorePath, 'utf8');
|
|
656
|
-
// Match either fenced markers or the legacy "# rcode" header through to
|
|
657
|
-
// the next blank line — both shapes the installer has used historically.
|
|
658
708
|
const stripped = before
|
|
709
|
+
// Current shape (install.js BEGIN/END markers — exact match).
|
|
710
|
+
.replace(/\n?# ===== rcode-managed gitignore block[\s\S]*?# ===== end rcode-managed gitignore block =====\n?/g, '\n')
|
|
711
|
+
// Legacy >>> / <<< fenced shape.
|
|
659
712
|
.replace(/\n?# >>> rihal-code >>>[\s\S]*?# <<< rihal-code <<<\n?/g, '\n')
|
|
660
|
-
.replace(/\n?# rcode[\s\S]*?(?=\n\n|\n$|$)/g, '\n')
|
|
661
713
|
.replace(/\n{3,}/g, '\n\n');
|
|
662
714
|
if (stripped !== before) {
|
|
663
715
|
fs.writeFileSync(gitignorePath, stripped);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hanzlaa/rcode",
|
|
3
|
-
"version": "3.4.
|
|
3
|
+
"version": "3.4.22",
|
|
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": {
|
|
@@ -985,6 +985,26 @@ function cmdState(subArgs) {
|
|
|
985
985
|
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
986
986
|
}
|
|
987
987
|
|
|
988
|
+
// Issue #681: auto-clear the install-time _seeded_stub marker once the
|
|
989
|
+
// state has graduated to a real project (project field set + at least one
|
|
990
|
+
// real phase OR REQUIREMENTS.md present). project-status (#675) reads
|
|
991
|
+
// _seeded_stub; if no writer ever clears it, every project stays "stub"
|
|
992
|
+
// forever and downstream workflows misroute.
|
|
993
|
+
if (state._seeded_stub === true) {
|
|
994
|
+
const phases = Array.isArray(state.phases) ? state.phases : [];
|
|
995
|
+
const firstPhaseName = phases[0]?.name || '';
|
|
996
|
+
const hasRealPhase = phases.length > 1 ||
|
|
997
|
+
(firstPhaseName && firstPhaseName !== 'Setup & Scaffolding');
|
|
998
|
+
const hasRequirements = (() => {
|
|
999
|
+
try {
|
|
1000
|
+
return fs.existsSync(path.join(PROJECT_ROOT, '.planning', 'REQUIREMENTS.md'));
|
|
1001
|
+
} catch { return false; }
|
|
1002
|
+
})();
|
|
1003
|
+
if ((state.project && hasRealPhase) || hasRequirements) {
|
|
1004
|
+
delete state._seeded_stub;
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
988
1008
|
state.updated = new Date().toISOString();
|
|
989
1009
|
fs.mkdirSync(RIHAL_DIR, { recursive: true });
|
|
990
1010
|
const lockPath = statePath + '.lock';
|
|
@@ -1069,6 +1089,23 @@ function cmdState(subArgs) {
|
|
|
1069
1089
|
return state;
|
|
1070
1090
|
}
|
|
1071
1091
|
|
|
1092
|
+
// --- clear-stub --- (issue #681)
|
|
1093
|
+
// Explicit way to flip _seeded_stub off. Useful for /rihal-new-project once
|
|
1094
|
+
// PROJECT.md / REQUIREMENTS.md / ROADMAP.md are committed. The auto-clear in
|
|
1095
|
+
// writeState() also handles this, but having an explicit subcommand lets
|
|
1096
|
+
// workflows be self-documenting and idempotent.
|
|
1097
|
+
if (sub === 'clear-stub') {
|
|
1098
|
+
if (!fs.existsSync(statePath)) {
|
|
1099
|
+
return { ok: false, error: 'No state.json — nothing to clear.' };
|
|
1100
|
+
}
|
|
1101
|
+
const state = readState();
|
|
1102
|
+
if (!state) return { ok: false, error: 'state.json unreadable' };
|
|
1103
|
+
const wasStub = state._seeded_stub === true;
|
|
1104
|
+
if (wasStub) delete state._seeded_stub;
|
|
1105
|
+
writeState(state);
|
|
1106
|
+
return { ok: true, was_stub: wasStub, project: state.project || null };
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1072
1109
|
// --- init ---
|
|
1073
1110
|
if (sub === 'init') {
|
|
1074
1111
|
let existing;
|