@hanzlaa/rcode 4.1.2 → 4.3.0

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.
Files changed (67) hide show
  1. package/cli/install.js +176 -13
  2. package/cli/lib/config.cjs +4 -2
  3. package/cli/lib/fsutil.cjs +13 -2
  4. package/cli/lib/homedir.cjs +21 -0
  5. package/cli/lib/schemas.cjs +6 -1
  6. package/cli/nuke.js +13 -8
  7. package/cli/postinstall.js +14 -4
  8. package/cli/rcode-slash-router.cjs +118 -0
  9. package/cli/uninstall.js +59 -1
  10. package/cli/update.js +10 -5
  11. package/dist/rcode.js +234 -230
  12. package/package.json +1 -1
  13. package/server/dashboard.js +26 -7
  14. package/server/lib/api.js +62 -4
  15. package/server/lib/html/client/agents-data.js +22 -18
  16. package/server/lib/html/client/app.js +3 -0
  17. package/server/lib/html/client/components/AgentCard.js +127 -0
  18. package/server/lib/html/client/components/App.js +104 -39
  19. package/server/lib/html/client/components/CommandPalette.js +133 -0
  20. package/server/lib/html/client/components/FileReader.js +116 -0
  21. package/server/lib/html/client/components/FilterChips.js +94 -0
  22. package/server/lib/html/client/components/NotifyCenter.js +117 -0
  23. package/server/lib/html/client/components/OrchPanel.js +80 -52
  24. package/server/lib/html/client/components/PhaseGraph.js +300 -0
  25. package/server/lib/html/client/components/RejectDialog.js +78 -0
  26. package/server/lib/html/client/components/RunnerPicker.js +190 -0
  27. package/server/lib/html/client/components/Sidebar.js +106 -61
  28. package/server/lib/html/client/components/StatusSummaryBar.js +76 -0
  29. package/server/lib/html/client/components/TaskPipeline.js +83 -0
  30. package/server/lib/html/client/components/Topbar.js +86 -39
  31. package/server/lib/html/client/components/dashboard/Blockers.js +57 -0
  32. package/server/lib/html/client/components/dashboard/CompletedTasks.js +47 -0
  33. package/server/lib/html/client/components/dashboard/CurrentPhase.js +107 -0
  34. package/server/lib/html/client/components/dashboard/InProgress.js +72 -0
  35. package/server/lib/html/client/components/dashboard/ProgressDonut.js +101 -0
  36. package/server/lib/html/client/components/dashboard/ProgressTimeline.js +101 -0
  37. package/server/lib/html/client/components/dashboard/ProjectHealth.js +80 -0
  38. package/server/lib/html/client/components/dashboard/RecentDecisions.js +57 -0
  39. package/server/lib/html/client/components/dashboard/Timeline.js +143 -0
  40. package/server/lib/html/client/components/shared.js +47 -11
  41. package/server/lib/html/client/filter-state.js +72 -0
  42. package/server/lib/html/client/icons-client.js +7 -0
  43. package/server/lib/html/client/notify.js +75 -0
  44. package/server/lib/html/client/orchestrator.js +168 -41
  45. package/server/lib/html/client/preact.js +13 -8
  46. package/server/lib/html/client/store.js +70 -6
  47. package/server/lib/html/client/util.js +78 -0
  48. package/server/lib/html/client/vendor/htm.js +1 -0
  49. package/server/lib/html/client/vendor/preact-hooks.js +2 -0
  50. package/server/lib/html/client/vendor/preact.js +2 -0
  51. package/server/lib/html/client/views/AgentsView.js +144 -51
  52. package/server/lib/html/client/views/FilesView.js +20 -103
  53. package/server/lib/html/client/views/KanbanView.js +40 -21
  54. package/server/lib/html/client/views/MemoryView.js +26 -9
  55. package/server/lib/html/client/views/MilestonesView.js +4 -4
  56. package/server/lib/html/client/views/OrchestrationView.js +154 -19
  57. package/server/lib/html/client/views/OverviewView.js +47 -239
  58. package/server/lib/html/client/views/PhasesView.js +50 -6
  59. package/server/lib/html/client/views/RoadmapView.js +6 -3
  60. package/server/lib/html/client/views/SprintsView.js +50 -6
  61. package/server/lib/html/client/views/TasksView.js +4 -3
  62. package/server/lib/html/client.js +21 -4
  63. package/server/lib/html/css.js +2761 -8
  64. package/server/lib/html/icons.js +7 -0
  65. package/server/lib/html/shell.js +10 -3
  66. package/server/lib/scanner.js +376 -39
  67. package/server/orchestrator.js +329 -5
package/cli/install.js CHANGED
@@ -59,6 +59,11 @@ const os = require('os');
59
59
  // Ctrl+C mid-write and malicious symlink-traversal during dedup/cleanup.
60
60
  const { writeFileAtomic, safeRmSync } = require('./lib/fsutil.cjs');
61
61
 
62
+ // HOME-aware home resolution (#889) — os.homedir() ignores a stubbed HOME on
63
+ // Windows (it reads USERPROFILE), so HOME-isolated tests and CI leaked global
64
+ // installs (~/.rcode, ~/.codex, ~/.gemini) into the real profile dir there.
65
+ const { homedir } = require('./lib/homedir.cjs');
66
+
62
67
  // Bundled packages — devDeps inlined by esbuild, loaded from node_modules in dev.
63
68
  const pc = require('picocolors');
64
69
  const { createSpinner } = require('nanospinner');
@@ -89,7 +94,7 @@ const SOURCE_ROOT = path.join(PACKAGE_ROOT, 'rcode');
89
94
  * detectIdeSignals, plus a row to runInstallWizard's multiselect — three
90
95
  * sites instead of ten.
91
96
  */
92
- const SUPPORTED_IDES = Object.freeze(['claude', 'cursor', 'gemini', 'vscode', 'antigravity', 'windsurf', 'codex']);
97
+ const SUPPORTED_IDES = Object.freeze(['claude', 'cursor', 'gemini', 'vscode', 'antigravity', 'windsurf', 'codex', 'grok']);
93
98
 
94
99
  /**
95
100
  * Resolve the stable on-disk location of this package so config.yaml
@@ -247,7 +252,7 @@ function parseArgs(argv) {
247
252
  // project directory wrote rcode artifacts to that project, not to the user's
248
253
  // home where Claude Code reads global commands from.
249
254
  if (opts.global && !opts.targetProvided) {
250
- opts.target = os.homedir();
255
+ opts.target = homedir();
251
256
  }
252
257
  // Issue #821/#832: pnpm workspace anchor.
253
258
  // When `pnpm add -D @hanzlaa/rcode` runs inside a workspace member,
@@ -386,7 +391,7 @@ function detectIdeSignals(target) {
386
391
  if (fs.existsSync(path.join(target, '.antigravity'))) signals.antigravity = true;
387
392
  if (fs.existsSync(path.join(target, '.windsurf'))) signals.windsurf = true;
388
393
  // 2. User-level config dirs
389
- const home = os.homedir();
394
+ const home = homedir();
390
395
  if (fs.existsSync(path.join(home, '.claude'))) signals.claude = true;
391
396
  if (fs.existsSync(path.join(home, '.cursor'))) signals.cursor = true;
392
397
  if (fs.existsSync(path.join(home, '.config', 'Cursor'))) signals.cursor = true;
@@ -615,6 +620,21 @@ function getPathsForIde(ide, target) {
615
620
  // claude/vscode install paths). We install agent + command files to .claude/
616
621
  // so multi-IDE installs share files, and the rcode workflow bridge gives
617
622
  // Codex access to lifecycle workflows via `rcode workflow show <name>` (#883).
623
+ // NOTE: Codex surfaces native /slash commands ONLY from ~/.codex/prompts/*.md
624
+ // (home, startup-loaded) — installed separately by installNativeHomeSlashCommands()
625
+ // under the opt-in --global flag, since .claude/commands is invisible to Codex.
626
+ return {
627
+ agentsDir: path.join(target, '.claude', 'agents'),
628
+ commandsDir: path.join(target, '.claude', 'commands'),
629
+ workflowsDir: path.join(target, '.rcode', 'workflows'),
630
+ referencesDir: path.join(target, '.rcode', 'references'),
631
+ binDir: path.join(target, '.rcode', 'bin'),
632
+ };
633
+ case 'grok':
634
+ // Grok Build (xAI CLI) is Claude-Code-compatible: it reads slash commands
635
+ // from .claude/commands/*.md (project) and ~/.claude/commands (global), same
636
+ // as Claude Code. So grok maps to the identical .claude/ layout — verified
637
+ // live: `/rcode-add-phase` surfaces in grok from these dirs.
618
638
  return {
619
639
  agentsDir: path.join(target, '.claude', 'agents'),
620
640
  commandsDir: path.join(target, '.claude', 'commands'),
@@ -1079,7 +1099,7 @@ function installSkills(packageRoot, target, options = {}) {
1079
1099
  // prefix, Claude Code reads from BOTH global and project, showing every
1080
1100
  // /rcode-* twice in the slash picker. Skip the project copy for any rcode-*
1081
1101
  // skill that already lives in the global skills dir.
1082
- const globalSkillsDir = path.join(os.homedir(), '.claude', 'skills');
1102
+ const globalSkillsDir = path.join(homedir(), '.claude', 'skills');
1083
1103
  const globalRcodeSkills = (options.skipGlobalDuplicates && fs.existsSync(globalSkillsDir))
1084
1104
  ? new Set(fs.readdirSync(globalSkillsDir).filter(n => n.startsWith('rcode-')))
1085
1105
  : new Set();
@@ -1303,7 +1323,7 @@ function buildInstallPlan(ide = 'claude', target = process.cwd()) {
1303
1323
  const rel = path.relative(path.join(SOURCE_ROOT, 'commands'), f);
1304
1324
  const ext = ide === 'cursor' ? '.mdc' : '.md';
1305
1325
  const baseName = path.basename(f, '.md');
1306
- const outName = (ide === 'claude' || ide === 'vscode')
1326
+ const outName = (ide === 'claude' || ide === 'vscode' || ide === 'grok')
1307
1327
  ? `rcode-${baseName}${ext}`
1308
1328
  : baseName + ext;
1309
1329
  plan.push({ src: f, rel: path.join(relCommands, path.dirname(rel), outName), ide, cursor: ide === 'cursor' });
@@ -1452,8 +1472,8 @@ function generateAgentManifest(plan, target) {
1452
1472
  // Also scan rcode/agents/ in SOURCE_ROOT as a last-resort fallback so the
1453
1473
  // manifest is never empty when the package itself ships agent definitions.
1454
1474
  const extraScans = [
1455
- path.join(os.homedir(), '.claude', 'agents'),
1456
- path.join(os.homedir(), '.rcode', 'agents'),
1475
+ path.join(homedir(), '.claude', 'agents'),
1476
+ path.join(homedir(), '.rcode', 'agents'),
1457
1477
  ];
1458
1478
  // Final fallback: scan the package source itself.
1459
1479
  try {
@@ -1901,6 +1921,125 @@ function acquireInstallLock(target) {
1901
1921
  return { ok: false, pid: 0, lockPath };
1902
1922
  }
1903
1923
 
1924
+ // ─────────────────────────────────────────────────────────────────────────────
1925
+ // Native home-dir slash-command install.
1926
+ //
1927
+ // Some agentic CLIs surface their `/slash` command menu ONLY from a fixed
1928
+ // home directory (not from project dirs the way Claude Code / Grok do):
1929
+ // • Codex → ~/.codex/prompts/<name>.md (flat prompt files)
1930
+ // • Antigravity→ ~/.gemini/antigravity/skills/<name>/SKILL.md (skill dirs)
1931
+ // For those tools the normal project install writes files the CLI never reads,
1932
+ // so `/rcode-*` never appears. This installs the commands in each CLI's NATIVE
1933
+ // format into its NATIVE home dir, gated behind the opt-in `--global` flag.
1934
+ //
1935
+ // IDEs that read project dirs (claude, grok, cursor, vscode, windsurf) are a
1936
+ // no-op here — they already work via getPathsForIde().
1937
+ //
1938
+ // Each tool's writer lives in its own helper; the dispatcher routes by ide.
1939
+ // SOURCE_ROOT/commands/*.md is the canonical command source for all writers.
1940
+ // ─────────────────────────────────────────────────────────────────────────────
1941
+
1942
+ // Codex + Antigravity surface NO file-based slash commands (verified live),
1943
+ // but BOTH support a prompt-submit hook (UserPromptSubmit / UserPrompt) that
1944
+ // can inject context. We install a hook ROUTER (cli/rcode-slash-router.cjs)
1945
+ // into each, plus a home-dir copy of every command body the router reads.
1946
+ // See cli/rcode-slash-router.cjs for the runtime contract.
1947
+
1948
+ // Shared: copy every command body to ~/.rcode/slash-commands/<name>.md and the
1949
+ // router script to ~/.rcode/bin/. A fixed home-dir location lets the hook read
1950
+ // commands regardless of the user's cwd. Idempotent (plain overwrite).
1951
+ function installSlashRouterCommands(opts) {
1952
+ const home = homedir();
1953
+ const cmdDestDir = path.join(home, '.rcode', 'slash-commands');
1954
+ const binDestDir = path.join(home, '.rcode', 'bin');
1955
+ ensureDir(cmdDestDir);
1956
+ ensureDir(binDestDir);
1957
+
1958
+ const srcCmdDir = path.join(SOURCE_ROOT, 'commands');
1959
+ let copied = 0;
1960
+ for (const file of fs.readdirSync(srcCmdDir)) {
1961
+ if (!file.endsWith('.md')) continue;
1962
+ fs.copyFileSync(path.join(srcCmdDir, file), path.join(cmdDestDir, file));
1963
+ copied++;
1964
+ }
1965
+
1966
+ const routerSrc = path.join(PACKAGE_ROOT, 'cli', 'rcode-slash-router.cjs');
1967
+ const routerDest = path.join(binDestDir, 'rcode-slash-router.cjs');
1968
+ fs.copyFileSync(routerSrc, routerDest);
1969
+
1970
+ if (opts && opts.global !== 'silent') {
1971
+ console.log(' ' + ok(`Slash-router: ${copied} command bodies → ~/.rcode/slash-commands/ + router → ~/.rcode/bin/`));
1972
+ }
1973
+ return routerDest;
1974
+ }
1975
+
1976
+ // The absolute command a hook entry runs. Matched by substring for idempotency
1977
+ // and for removal on uninstall — keep the basename stable.
1978
+ function slashRouterHookCommand() {
1979
+ return `node "${path.join(homedir(), '.rcode', 'bin', 'rcode-slash-router.cjs')}"`;
1980
+ }
1981
+
1982
+ // Merge a prompt-submit hook entry into an existing CLI hooks JSON file without
1983
+ // disturbing any pre-existing entries (e.g. herdr's). `eventKey` is the hook
1984
+ // event name that CLI uses (codex: UserPromptSubmit, antigravity: UserPrompt).
1985
+ // Idempotent: re-running detects the router by command substring and no-ops.
1986
+ function mergeSlashRouterHook(jsonPath, eventKey, command, label) {
1987
+ let root = {};
1988
+ if (fs.existsSync(jsonPath)) {
1989
+ try {
1990
+ root = JSON.parse(fs.readFileSync(jsonPath, 'utf8')) || {};
1991
+ } catch {
1992
+ // Unparseable file — don't clobber the user's config; bail loudly.
1993
+ console.log(' ' + warn(`${label}: ${jsonPath} is not valid JSON — skipped slash-router wiring.`));
1994
+ return false;
1995
+ }
1996
+ }
1997
+ if (!root.hooks || typeof root.hooks !== 'object') root.hooks = {};
1998
+ if (!Array.isArray(root.hooks[eventKey])) root.hooks[eventKey] = [];
1999
+
2000
+ const already = root.hooks[eventKey].some(group =>
2001
+ Array.isArray(group?.hooks) &&
2002
+ group.hooks.some(h => typeof h?.command === 'string' && h.command.includes('rcode-slash-router.cjs')),
2003
+ );
2004
+ if (already) {
2005
+ console.log(' ' + ok(`${label}: slash-router hook already present (idempotent).`));
2006
+ return false;
2007
+ }
2008
+
2009
+ root.hooks[eventKey].push({ hooks: [{ type: 'command', command, timeout: 10 }] });
2010
+ ensureDir(path.dirname(jsonPath));
2011
+ fs.writeFileSync(jsonPath, JSON.stringify(root, null, 2) + '\n');
2012
+ console.log(' ' + ok(`${label}: wired slash-router into ${eventKey} hook (existing hooks preserved).`));
2013
+ return true;
2014
+ }
2015
+
2016
+ // Codex: ~/.codex/hooks.json, event UserPromptSubmit.
2017
+ function installCodexSlashRouterHook(opts) {
2018
+ installSlashRouterCommands(opts);
2019
+ const jsonPath = path.join(homedir(), '.codex', 'hooks.json');
2020
+ mergeSlashRouterHook(jsonPath, 'UserPromptSubmit', slashRouterHookCommand(), 'Codex');
2021
+ }
2022
+
2023
+ // Antigravity: ~/.gemini/antigravity/settings.json, event UserPrompt.
2024
+ function installAntigravitySlashRouterHook(opts) {
2025
+ installSlashRouterCommands(opts);
2026
+ const jsonPath = path.join(homedir(), '.gemini', 'antigravity', 'settings.json');
2027
+ mergeSlashRouterHook(jsonPath, 'UserPrompt', slashRouterHookCommand(), 'Antigravity');
2028
+ }
2029
+
2030
+ function installNativeHomeSlashCommands(opts) {
2031
+ if (!opts || !opts.global) return;
2032
+ const ides = Array.isArray(opts.ides) ? opts.ides : [opts.ide].filter(Boolean);
2033
+ for (const ide of ides) {
2034
+ switch (ide) {
2035
+ case 'codex': installCodexSlashRouterHook(opts); break;
2036
+ case 'antigravity': installAntigravitySlashRouterHook(opts); break;
2037
+ default:
2038
+ break;
2039
+ }
2040
+ }
2041
+ }
2042
+
1904
2043
  async function installInner(opts) {
1905
2044
  const pkgVersion = readPackageVersion();
1906
2045
 
@@ -1955,6 +2094,7 @@ async function installInner(opts) {
1955
2094
  console.error(' vscode — VS Code (with Claude Code / Continue / Copilot extension)');
1956
2095
  console.error(' windsurf — Windsurf (Codeium)');
1957
2096
  console.error(' antigravity — Antigravity (experimental)');
2097
+ console.error(' grok — Grok Build (xAI CLI, Claude-Code-compatible)');
1958
2098
  console.error('');
1959
2099
  console.error(' Tracked for future:');
1960
2100
  console.error(' jetbrains — IntelliJ / PyCharm');
@@ -2298,6 +2438,16 @@ async function installInner(opts) {
2298
2438
  } catch { /* non-fatal */ }
2299
2439
  console.log('');
2300
2440
  console.log(` ${dim(`${skillsInstalled} skills installed globally`)}`);
2441
+
2442
+ // Native home-dir slash commands for CLIs that can't surface file-based
2443
+ // /commands (codex, antigravity) but DO support a prompt-submit hook.
2444
+ // This MUST run inside the --global block: the global path returns here,
2445
+ // before the non-global call site below. Gated on opts.global inside.
2446
+ try {
2447
+ installNativeHomeSlashCommands(opts);
2448
+ } catch (err) {
2449
+ process.stderr.write(pc.yellow(`WARNING: native slash-command install skipped: ${err?.message || err}`) + '\n');
2450
+ }
2301
2451
  return 0;
2302
2452
  }
2303
2453
 
@@ -2305,9 +2455,9 @@ async function installInner(opts) {
2305
2455
  // skip writing agents/commands to the project's .claude/ directory. Without this,
2306
2456
  // running `npx rcode install` in the home dir AND then in a project creates two sets
2307
2457
  // of identical files — Claude Code shows both as duplicate slash commands.
2308
- const globalClaudeCommands = path.join(os.homedir(), '.claude', 'commands');
2458
+ const globalClaudeCommands = path.join(homedir(), '.claude', 'commands');
2309
2459
  const projectClaudeCommands = path.join(opts.target, '.claude', 'commands');
2310
- const isProjectInstall = opts.target !== os.homedir();
2460
+ const isProjectInstall = opts.target !== homedir();
2311
2461
  // Run dedup even when force:true — only forceOverwrite skips it.
2312
2462
  if (isProjectInstall && !opts.forceOverwrite) {
2313
2463
  try {
@@ -2489,7 +2639,7 @@ async function installInner(opts) {
2489
2639
  }
2490
2640
 
2491
2641
  // ~/.rcode/agents/ global agents directory
2492
- const globalAgentsDir = path.join(os.homedir(), '.rcode', 'agents');
2642
+ const globalAgentsDir = path.join(homedir(), '.rcode', 'agents');
2493
2643
  ensureDir(globalAgentsDir);
2494
2644
 
2495
2645
  // Issue #702: files-manifest.csv used to be written here, BEFORE
@@ -2674,9 +2824,9 @@ async function installInner(opts) {
2674
2824
  // the project skills folder may have only sidebar stubs while ~/.claude/
2675
2825
  // has the real skills — health check should see those.
2676
2826
  if (agentCount === 0 || commandCount === 0 || skillsInstalled < 20) {
2677
- const homeAgents = path.join(os.homedir(), '.claude/agents');
2678
- const homeCommands = path.join(os.homedir(), '.claude/commands');
2679
- const homeSkills = path.join(os.homedir(), '.claude/skills');
2827
+ const homeAgents = path.join(homedir(), '.claude/agents');
2828
+ const homeCommands = path.join(homedir(), '.claude/commands');
2829
+ const homeSkills = path.join(homedir(), '.claude/skills');
2680
2830
  if (agentCount === 0 && fs.existsSync(homeAgents)) {
2681
2831
  // #669 — count both rcode-* and rcode-* prefixes; missing rcode-
2682
2832
  // branch produced "Agents: 0" alongside "Skills: 120".
@@ -2721,6 +2871,14 @@ async function installInner(opts) {
2721
2871
  }
2722
2872
  } catch { /* non-fatal */ }
2723
2873
 
2874
+ // Native home-dir slash commands for CLIs that ONLY surface /commands from
2875
+ // their own home dir (not project dirs). Opt-in via --global. See the fn def.
2876
+ try {
2877
+ installNativeHomeSlashCommands(opts);
2878
+ } catch (err) {
2879
+ process.stderr.write(pc.yellow(`WARNING: native slash-command install skipped: ${err?.message || err}`) + '\n');
2880
+ }
2881
+
2724
2882
  const version = readPackageVersion();
2725
2883
  console.log('');
2726
2884
  console.log(` ${bold('Version:')} ${pc.cyan('@hanzlaa/rcode@' + version)}`);
@@ -3071,3 +3229,8 @@ module.exports.install = install;
3071
3229
  module.exports.SUPPORTED_IDES = SUPPORTED_IDES;
3072
3230
  module.exports.migrateVscodeCommandsLayout = migrateVscodeCommandsLayout;
3073
3231
  module.exports.getPathsForIde = getPathsForIde;
3232
+ // Slash-router (hook-based /rcode-* support for codex + antigravity).
3233
+ module.exports.installSlashRouterCommands = installSlashRouterCommands;
3234
+ module.exports.installCodexSlashRouterHook = installCodexSlashRouterHook;
3235
+ module.exports.installAntigravitySlashRouterHook = installAntigravitySlashRouterHook;
3236
+ module.exports.installNativeHomeSlashCommands = installNativeHomeSlashCommands;
@@ -18,9 +18,11 @@
18
18
  */
19
19
 
20
20
  const fs = require('fs');
21
- const os = require('os');
22
21
  const path = require('path');
23
22
  const { writeJsonAtomic } = require('./fsutil.cjs');
23
+ // HOME-aware home resolution (#889) — os.homedir() ignores a stubbed HOME
24
+ // on Windows, which broke user-level defaults isolation in tests.
25
+ const { homedir } = require('./homedir.cjs');
24
26
 
25
27
  // ---------- Schema ----------
26
28
 
@@ -102,7 +104,7 @@ const VALID_COMMUNICATION_MODES = new Set(['guided', 'yolo']);
102
104
  // ---------- Paths ----------
103
105
 
104
106
  function userLevelPath() {
105
- return path.join(os.homedir(), '.rcode', 'defaults.json');
107
+ return path.join(homedir(), '.rcode', 'defaults.json');
106
108
  }
107
109
 
108
110
  function projectLevelPath(cwd) {
@@ -115,8 +115,19 @@ function safeRmSync(targetPath, projectRoot) {
115
115
  }
116
116
  }
117
117
 
118
- // Real path must stay inside the project root.
119
- const root = path.resolve(projectRoot);
118
+ // Real path must stay inside the project root. The root must be
119
+ // realpathed too: on macOS os.tmpdir() lives behind a symlink
120
+ // (/tmp → /private/tmp, /var → /private/var), so comparing a realpathed
121
+ // target against a merely-resolved root misreports anything under /tmp
122
+ // as outside-root.
123
+ let root;
124
+ try {
125
+ root = fs.realpathSync(projectRoot);
126
+ } catch {
127
+ // Root missing/unreadable — fall back to a lexical resolve; the
128
+ // containment check below then fails closed for an existing target.
129
+ root = path.resolve(projectRoot);
130
+ }
120
131
  let resolved;
121
132
  try {
122
133
  resolved = fs.realpathSync(targetPath);
@@ -0,0 +1,21 @@
1
+ /**
2
+ * cli/lib/homedir.cjs — user home directory resolution (#889).
3
+ *
4
+ * process.env.HOME wins over os.homedir() so a single env var redirects
5
+ * every home-relative read/write on EVERY platform. os.homedir() ignores
6
+ * HOME on Windows (it reads USERPROFILE), which made HOME-stubbed tests
7
+ * silently escape to the real profile dir on Windows CI — installs leaked
8
+ * ~/.codex / ~/.gemini / ~/.rcode into the runner's real home and broke
9
+ * unrelated tests. Honoring HOME also matches git/npm behavior on Windows
10
+ * (git-bash sets HOME), so real users get consistent paths across shells.
11
+ */
12
+
13
+ 'use strict';
14
+
15
+ const os = require('os');
16
+
17
+ function homedir() {
18
+ return process.env.HOME || os.homedir();
19
+ }
20
+
21
+ module.exports = { homedir };
@@ -35,9 +35,14 @@ const { z } = require('zod');
35
35
  * @returns {{ frontmatter: object, body: string }}
36
36
  */
37
37
  function parseFrontmatter(text) {
38
- if (typeof text !== 'string' || !text.startsWith('---\n')) {
38
+ if (typeof text !== 'string') {
39
39
  return { frontmatter: {}, body: text || '' };
40
40
  }
41
+ // CRLF tolerance (#889): Windows checkouts/user files may use \r\n.
42
+ text = text.replace(/\r\n/g, '\n');
43
+ if (!text.startsWith('---\n')) {
44
+ return { frontmatter: {}, body: text };
45
+ }
41
46
  const end = text.indexOf('\n---\n', 4);
42
47
  if (end === -1) return { frontmatter: {}, body: text };
43
48
  const block = text.slice(4, end);
package/cli/nuke.js CHANGED
@@ -20,9 +20,12 @@
20
20
  'use strict';
21
21
 
22
22
  const fs = require('fs');
23
- const os = require('os');
24
23
  const path = require('path');
25
24
  const { spawnSync } = require('child_process');
25
+ // HOME-aware home resolution (#889) — os.homedir() ignores a stubbed HOME
26
+ // on Windows, so tests pointing HOME at a temp dir still scanned the real
27
+ // profile dir there (and tripped over real ~/.rcode state).
28
+ const { homedir } = require('./lib/homedir.cjs');
26
29
 
27
30
  function exists(p) {
28
31
  try { fs.accessSync(p); return true; } catch { return false; }
@@ -37,7 +40,7 @@ function readDirSafe(p) {
37
40
  * Returns a list of { manager, dir } — dir may not exist.
38
41
  */
39
42
  function getGlobalNodeModulesDirs() {
40
- const home = os.homedir();
43
+ const home = homedir();
41
44
  const candidates = [];
42
45
 
43
46
  // npm — npm root -g resolves to the active node version's lib/node_modules.
@@ -106,7 +109,7 @@ function findRcodePackages(globalNodeModules) {
106
109
  * Resolve global bin directories where rcode/rcode/rcode may live.
107
110
  */
108
111
  function getGlobalBinDirs() {
109
- const home = os.homedir();
112
+ const home = homedir();
110
113
  const dirs = new Set();
111
114
 
112
115
  // npm prefix bin
@@ -214,7 +217,7 @@ function findClaudeArtifacts(claudeDir) {
214
217
  }
215
218
 
216
219
  function buildPlan({ includePlanning }) {
217
- const home = os.homedir();
220
+ const home = homedir();
218
221
  const cwd = process.cwd();
219
222
  const plan = {
220
223
  packages: [],
@@ -244,13 +247,15 @@ function buildPlan({ includePlanning }) {
244
247
  plan.globalClaude = findClaudeArtifacts(path.join(home, '.claude'));
245
248
 
246
249
  // Global state (~/.rcode/)
250
+ // #889: was `= globalRcode` (undefined) — a ReferenceError that only fired
251
+ // when ~/.rcode existed, crashing every dry-run on machines with global state.
247
252
  const globalrcode = path.join(home, '.rcode');
248
- if (exists(globalrcode)) plan.globalrcode = globalRcode;
253
+ if (exists(globalrcode)) plan.globalrcode = globalrcode;
249
254
 
250
255
  // Project-level (CWD only — never recurse, user may have many projects)
251
256
  plan.projectClaude = findClaudeArtifacts(path.join(cwd, '.claude'));
252
257
  const projectrcode = path.join(cwd, '.rcode');
253
- if (exists(projectrcode) && cwd !== home) plan.projectrcode = projectRcode;
258
+ if (exists(projectrcode) && cwd !== home) plan.projectrcode = projectrcode;
254
259
 
255
260
  if (includePlanning) {
256
261
  const projectPlanning = path.join(cwd, '.planning');
@@ -351,7 +356,7 @@ function executePlan(plan) {
351
356
  if (rmrf(a.path)) { console.log(` ✓ removed ${a.path}`); removed++; }
352
357
  }
353
358
  if (plan.globalrcode && rmrf(plan.globalrcode)) {
354
- console.log(` ✓ removed ${plan.globalRcode}`); removed++;
359
+ console.log(` ✓ removed ${plan.globalrcode}`); removed++;
355
360
  }
356
361
 
357
362
  // Claude artifacts (project)
@@ -359,7 +364,7 @@ function executePlan(plan) {
359
364
  if (rmrf(a.path)) { console.log(` ✓ removed ${a.path}`); removed++; }
360
365
  }
361
366
  if (plan.projectrcode && rmrf(plan.projectrcode)) {
362
- console.log(` ✓ removed ${plan.projectRcode}`); removed++;
367
+ console.log(` ✓ removed ${plan.projectrcode}`); removed++;
363
368
  }
364
369
  if (plan.projectPlanning && rmrf(plan.projectPlanning)) {
365
370
  console.log(` ✓ removed ${plan.projectPlanning}`); removed++;
@@ -13,6 +13,17 @@
13
13
  const os = require('os');
14
14
  const path = require('path');
15
15
 
16
+ /**
17
+ * Path containment check that survives Windows: path.relative normalizes
18
+ * separators (/ vs \) and compares drive letters case-insensitively, which
19
+ * a raw startsWith prefix compare does not.
20
+ */
21
+ function isPathInside(child, parent) {
22
+ const rel = path.relative(parent, child);
23
+ if (rel === '') return true;
24
+ return rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel);
25
+ }
26
+
16
27
  /**
17
28
  * Decide whether the current postinstall invocation represents a GLOBAL
18
29
  * `npm install -g @hanzlaa/rcode` (true) or a transitive devDep install
@@ -28,17 +39,16 @@ const path = require('path');
28
39
  function isGlobalInstall(env, dirname, cwd) {
29
40
  try {
30
41
  if (env.npm_config_global === 'true') return true;
31
- if (env.PNPM_HOME && dirname.startsWith(env.PNPM_HOME)) return true;
42
+ if (env.PNPM_HOME && isPathInside(dirname, env.PNPM_HOME)) return true;
32
43
  const globalPatterns = [
33
- /\/node_modules\/@hanzlaa\/rcode/,
44
+ /[/\\]node_modules[/\\]@hanzlaa[/\\]rcode/,
34
45
  /[/\\]lib[/\\]node_modules[/\\]/,
35
46
  /\.nvm[/\\]versions[/\\]/,
36
47
  /\.pnpm[/\\]/,
37
48
  /\.yarn[/\\]global/,
38
49
  ];
39
50
  if (globalPatterns.some((re) => re.test(dirname))) return true;
40
- const localNodeModules = path.join(cwd, 'node_modules');
41
- if (!dirname.startsWith(localNodeModules)) return true;
51
+ if (!isPathInside(dirname, path.join(cwd, 'node_modules'))) return true;
42
52
  return false;
43
53
  } catch {
44
54
  return false;
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // rcode slash-command hook router.
5
+ //
6
+ // WHY this exists: Codex CLI and Antigravity CLI do NOT surface file-based
7
+ // `/slash` commands the way Claude Code / Grok do (verified live). They DO,
8
+ // however, support a prompt-submit hook (`UserPromptSubmit` / `UserPrompt`)
9
+ // that can inject extra context into the model's turn. This router is wired
10
+ // into that hook by the installer. When the user types `/rcode-<name> [args]`,
11
+ // the router loads the matching command body and injects it as additional
12
+ // context so the model executes that command — the closest thing to a native
13
+ // slash command those CLIs allow.
14
+ //
15
+ // Dependency-free (Node stdlib only) so it can run from a stable home dir
16
+ // (~/.rcode/bin/) without an install step. NEVER throws to the host CLI: any
17
+ // error exits 0 with no output so a malfunctioning router can never break or
18
+ // swallow the user's real prompt.
19
+
20
+ const fs = require('fs');
21
+ const os = require('os');
22
+ const path = require('path');
23
+
24
+ // Command bodies are copied here by the installer (installSlashRouterCommands).
25
+ // A fixed home-dir location means the hook can always read them regardless of
26
+ // the user's current working directory.
27
+ // HOME wins over os.homedir() (#889): os.homedir() ignores HOME on Windows
28
+ // (it reads USERPROFILE), so HOME-redirected runs (tests, git-bash) would read
29
+ // the wrong profile dir. Inlined — this script is copied standalone to
30
+ // ~/.rcode/bin/ and must stay dependency-free (no ./lib requires).
31
+ const COMMANDS_DIR = path.join(process.env.HOME || os.homedir(), '.rcode', 'slash-commands');
32
+
33
+ // Matches `/rcode-<name>` at the very start, optional whitespace, then the
34
+ // rest of the line(s) as arguments. `\b` ends the command name so trailing
35
+ // punctuation/args don't leak into <name>.
36
+ const SLASH_RE = /^\/rcode-([a-z0-9-]+)\b[ \t]*([\s\S]*)$/;
37
+
38
+ function readStdin() {
39
+ try {
40
+ return fs.readFileSync(0, 'utf8');
41
+ } catch {
42
+ return '';
43
+ }
44
+ }
45
+
46
+ // Strip a leading YAML frontmatter block (`---\n...\n---`). The frontmatter is
47
+ // CLI-tooling metadata (name/description/allowed-tools) that only confuses the
48
+ // model — we want the executable command body injected, not its header.
49
+ // \r?\n because Windows checkouts may deliver CRLF command bodies (#889).
50
+ function stripFrontmatter(text) {
51
+ return text.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '');
52
+ }
53
+
54
+ function emit(hookEventName, additionalContext) {
55
+ const payload = {
56
+ hookSpecificOutput: {
57
+ hookEventName: hookEventName || 'UserPromptSubmit',
58
+ additionalContext,
59
+ },
60
+ };
61
+ process.stdout.write(JSON.stringify(payload));
62
+ }
63
+
64
+ function main() {
65
+ const raw = readStdin();
66
+ if (!raw.trim()) return;
67
+
68
+ let data;
69
+ try {
70
+ data = JSON.parse(raw);
71
+ } catch {
72
+ return; // not JSON we understand → pass-through (no output)
73
+ }
74
+
75
+ // Field names vary across CLIs; accept the common spellings.
76
+ const prompt =
77
+ data.prompt ??
78
+ data.user_prompt ??
79
+ data.userPrompt ??
80
+ data.message ??
81
+ data.input ??
82
+ '';
83
+ const hookEventName = data.hook_event_name || data.hookEventName || 'UserPromptSubmit';
84
+
85
+ if (typeof prompt !== 'string') return;
86
+
87
+ const match = prompt.replace(/^\s+/, '').match(SLASH_RE);
88
+ if (!match) return; // not an rcode command → pass-through (no output)
89
+
90
+ const name = match[1];
91
+ const args = (match[2] || '').trim();
92
+
93
+ const cmdFile = path.join(COMMANDS_DIR, `${name}.md`);
94
+ if (!fs.existsSync(cmdFile)) {
95
+ // Unknown command: inject a short note rather than silently doing nothing,
96
+ // so the user learns the command name didn't resolve.
97
+ emit(
98
+ hookEventName,
99
+ `Unknown rcode command: /rcode-${name}. No matching command body was found in ${COMMANDS_DIR}.`,
100
+ );
101
+ return;
102
+ }
103
+
104
+ let body = stripFrontmatter(fs.readFileSync(cmdFile, 'utf8')).trim();
105
+ if (args) {
106
+ // Surface user-supplied args the way the command bodies expect ($ARGUMENTS).
107
+ body += `\n\nArguments: ${args}`;
108
+ }
109
+
110
+ emit(hookEventName, body);
111
+ }
112
+
113
+ try {
114
+ main();
115
+ } catch {
116
+ // Never break the host CLI's prompt — fail open, silently.
117
+ }
118
+ process.exit(0);