@nemus-cli/nemus 0.13.0 → 0.15.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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.15.0] - 2026-09-02
11
+
12
+ ### Added
13
+
14
+ - **XDG base-directory support for config/state (Linux).** A fresh install now
15
+ stores its config/state under `$XDG_CONFIG_HOME/nemus` (default
16
+ `~/.config/nemus`) on Linux, making Nemus a better Linux citizen. Precedence
17
+ (first match wins): `NEMUS_CACHE_DIR`/`WORKSPACE_MANAGER_CACHE_DIR` override →
18
+ an existing `~/.nemus` with state (kept on every platform, never moved) →
19
+ an absolute `XDG_CONFIG_HOME` → Linux default `~/.config/nemus` → `~/.nemus`
20
+ (macOS/Windows). Existing `~/.nemus` installs are untouched. (#77)
21
+
22
+ ## [0.14.0] - 2026-09-02
23
+
24
+ ### Changed
25
+
26
+ - **Shell integration now keeps its generated functions out of your shell RC
27
+ files.** Nemus writes them to `~/.nemus/shell-integration.sh` and adds only a
28
+ single guarded `source` line to `.zshrc`/`.bashrc`, so upgrades no longer
29
+ churn hundreds of lines in your personal shell config. Existing inline
30
+ installations migrate automatically without disturbing surrounding user
31
+ content, and reinstalls stay idempotent. Uninstall removes the generated file
32
+ too. Thanks to @uzikilon for the idea and original implementation (#50).
33
+
10
34
  ## [0.13.0] - 2026-09-02
11
35
 
12
36
  ### Added
package/README.md CHANGED
@@ -138,7 +138,9 @@ npm install -g @nemus-cli/nemus
138
138
  ```
139
139
 
140
140
  The postinstall step sets up optional shell integration (auto-cd into new
141
- workspaces + a quick-navigate helper).
141
+ workspaces + a quick-navigate helper). It keeps the generated functions in
142
+ `~/.nemus/shell-integration.sh` and adds only a guarded source line to your
143
+ shell RC file, so upgrades don't churn your `.zshrc`/`.bashrc`.
142
144
 
143
145
  ### From source
144
146
 
@@ -282,7 +284,8 @@ Everything Nemus reads from the environment (all optional):
282
284
  | Variable | Effect |
283
285
  | --- | --- |
284
286
  | `NEMUS_DIR` | Override where workspaces are created (also `WORKSPACE_MANAGER_DIR`). |
285
- | `NEMUS_CACHE_DIR` | Override the cache/config/state dir, default `~/.nemus` (also `WORKSPACE_MANAGER_CACHE_DIR`). |
287
+ | `NEMUS_CACHE_DIR` | Override the config/state dir. Default: `~/.nemus`, or `$XDG_CONFIG_HOME/nemus` ( `~/.config/nemus`) on Linux. Also `WORKSPACE_MANAGER_CACHE_DIR`. |
288
+ | `XDG_CONFIG_HOME` | On Linux (or when set explicitly), a fresh install stores config/state under `$XDG_CONFIG_HOME/nemus`. An existing `~/.nemus` is always kept as-is. |
286
289
  | `NEMUS_JUDGE_MODEL` | Model for the `reflect` judge (overrides `--model`'s default). |
287
290
  | `NEMUS_JUDGE_THINKING` | Thinking level for the `reflect` judge on pi (`off`…`max`). |
288
291
  | `NEMUS_JUDGE_TIMEOUT_MS` | Timeout for the `reflect` judge call. |
@@ -424,7 +427,19 @@ export NEMUS_CACHE_DIR="$HOME/.cache/nemus" # default: ~/.nemus
424
427
 
425
428
  (The legacy `WORKSPACE_MANAGER_DIR` / `WORKSPACE_MANAGER_CACHE_DIR` names still
426
429
  work as fallbacks. On first run, state from the old `~/.workspace-manager-cache`
427
- location is migrated to `~/.nemus` automatically.)
430
+ location is migrated to the resolved config dir automatically.)
431
+
432
+ **Config/state location (XDG-aware).** Resolution order, first match wins —
433
+ designed so existing installs are never silently moved:
434
+
435
+ 1. `NEMUS_CACHE_DIR` / `WORKSPACE_MANAGER_CACHE_DIR` (explicit override).
436
+ 2. An existing `~/.nemus` that holds durable state (anything beyond regenerable
437
+ caches like `repos-cache.json` / `last-version-check.json` and the
438
+ `shell-integration.sh` file — e.g. `config.json`, `suites.json`, the
439
+ `reflect/` reports) — kept on every platform, never moved.
440
+ 3. `XDG_CONFIG_HOME/nemus`, when `XDG_CONFIG_HOME` is set to an absolute path.
441
+ 4. Linux with no prior install → `~/.config/nemus` (the XDG default).
442
+ 5. Otherwise (macOS/Windows, fresh install) → `~/.nemus`.
428
443
 
429
444
  Key settings: `githubOrg` (which org's repos to list — leave empty to list your
430
445
  own), `cloneProtocol` (`ssh`|`https`), `aiAgent`, `primaryAgent`, `installMcp`.
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.config = exports.CLONE_MAX_BUFFER = exports.CLONE_TIMEOUT_MS = exports.CONFIG_PATH = exports.META_FILENAME = exports.SUITES_FILE = exports.HISTORY_FILE = exports.CACHE_DIR = exports.WORKSPACES_DIR = exports.CONFIG_DEFAULTS = void 0;
37
+ exports.resolveNemusHome = resolveNemusHome;
37
38
  exports.getUserConfig = getUserConfig;
38
39
  exports.getPackageVersion = getPackageVersion;
39
40
  exports.getCloneUrl = getCloneUrl;
@@ -42,11 +43,62 @@ const os = __importStar(require("os"));
42
43
  const path = __importStar(require("path"));
43
44
  const fs = __importStar(require("fs"));
44
45
  const HOME_DIR = os.homedir();
45
- // Cache/config/state directory. Prefer the branded NEMUS_* env vars; fall back
46
- // to the legacy WORKSPACE_MANAGER_* names for backward compatibility; else the
47
- // default ~/.nemus home.
48
- const DEFAULT_CACHE_DIR = path.join(HOME_DIR, '.nemus');
49
46
  const LEGACY_CACHE_DIR = path.join(HOME_DIR, '.workspace-manager-cache');
47
+ // Entries in ~/.nemus that do NOT make it a "real install": regenerable caches
48
+ // and the shell-integration artifact. Anything else (config.json, suites.json,
49
+ // history.jsonl, the reflect/ report dir, or any future state) marks the dir as
50
+ // an existing install to keep in place. Denylisting the throwaways — a small,
51
+ // stable set — is exhaustive for durable state by construction, which is safer
52
+ // than allowlisting state files (that could miss e.g. reflect/ and wrongly
53
+ // relocate a real install to XDG on upgrade).
54
+ const REGENERABLE_ENTRIES = new Set([
55
+ 'last-version-check.json', // update-check cache
56
+ 'repos-cache.json', // GitHub repo-list cache
57
+ 'last-error.json', // last error, for report-bug
58
+ 'shell-integration.sh', // shell installer artifact — not Nemus state
59
+ '.DS_Store',
60
+ ]);
61
+ /** True if ~/.nemus holds anything beyond regenerable caches / the shell artifact. */
62
+ function hasDurableState(dir, readDir) {
63
+ let entries;
64
+ try {
65
+ entries = readDir(dir);
66
+ }
67
+ catch {
68
+ return false; // dir doesn't exist
69
+ }
70
+ return entries.some((e) => !REGENERABLE_ENTRIES.has(e));
71
+ }
72
+ /**
73
+ * Resolve the directory that holds Nemus config + state. Precedence (first
74
+ * match wins), designed so existing users are never silently moved:
75
+ *
76
+ * 1. Explicit override — NEMUS_CACHE_DIR (or legacy WORKSPACE_MANAGER_CACHE_DIR).
77
+ * 2. An existing ~/.nemus that holds durable state (anything beyond
78
+ * regenerable caches / the shell-integration file) — kept on every platform.
79
+ * 3. XDG_CONFIG_HOME, when set to an absolute path — $XDG_CONFIG_HOME/nemus.
80
+ * 4. Linux with no prior install — the XDG default ~/.config/nemus.
81
+ * 5. Otherwise (macOS/Windows, fresh install) — ~/.nemus.
82
+ *
83
+ * Config + state are kept together in one dir (a later change may split cache
84
+ * out under XDG_CACHE_HOME). The dir is chosen under XDG_CONFIG_HOME, not
85
+ * XDG_CACHE_HOME, because it holds real config (config.json) — not disposable
86
+ * cache a cleaner may wipe. A relative XDG_CONFIG_HOME is ignored per the spec.
87
+ */
88
+ function resolveNemusHome({ env, home, platform, readDir }) {
89
+ const explicit = env.NEMUS_CACHE_DIR || env.WORKSPACE_MANAGER_CACHE_DIR;
90
+ if (explicit)
91
+ return explicit;
92
+ const branded = path.join(home, '.nemus');
93
+ if (hasDurableState(branded, readDir))
94
+ return branded;
95
+ const xdg = env.XDG_CONFIG_HOME;
96
+ if (xdg && path.isAbsolute(xdg))
97
+ return path.join(xdg, 'nemus');
98
+ if (platform === 'linux')
99
+ return path.join(home, '.config', 'nemus');
100
+ return branded;
101
+ }
50
102
  /**
51
103
  * One-time, best-effort migration of state from the pre-0.2.2 cache location
52
104
  * (~/.workspace-manager-cache) to ~/.nemus. Runs only when using the default
@@ -58,11 +110,15 @@ const LEGACY_CACHE_DIR = path.join(HOME_DIR, '.workspace-manager-cache');
58
110
  * shared by another tool whose "latest version" is unrelated to nemus, and
59
111
  * copying it would make the update check report a wrong version until the entry
60
112
  * expires. Skipping it just forces one fresh lookup.
113
+ *
114
+ * Skipped when an explicit env override is in effect (don't copy into a path the
115
+ * user deliberately chose). Runs into whatever default location was resolved,
116
+ * including a new XDG dir on Linux.
61
117
  */
62
118
  const MIGRATION_SKIP = new Set(['last-version-check.json']);
63
- function migrateLegacyCacheDir(targetDir) {
119
+ function migrateLegacyCacheDir(targetDir, isExplicitOverride) {
64
120
  try {
65
- if (targetDir === DEFAULT_CACHE_DIR && !fs.existsSync(targetDir) && fs.existsSync(LEGACY_CACHE_DIR)) {
121
+ if (!isExplicitOverride && !fs.existsSync(targetDir) && fs.existsSync(LEGACY_CACHE_DIR)) {
66
122
  fs.cpSync(LEGACY_CACHE_DIR, targetDir, {
67
123
  recursive: true,
68
124
  filter: (src) => !MIGRATION_SKIP.has(path.basename(src)),
@@ -73,8 +129,14 @@ function migrateLegacyCacheDir(targetDir) {
73
129
  // best-effort — ignore and let the dir be created lazily
74
130
  }
75
131
  }
76
- const CACHE_DIR_RESOLVED = process.env.NEMUS_CACHE_DIR || process.env.WORKSPACE_MANAGER_CACHE_DIR || DEFAULT_CACHE_DIR;
77
- migrateLegacyCacheDir(CACHE_DIR_RESOLVED);
132
+ const EXPLICIT_OVERRIDE = !!(process.env.NEMUS_CACHE_DIR || process.env.WORKSPACE_MANAGER_CACHE_DIR);
133
+ const CACHE_DIR_RESOLVED = resolveNemusHome({
134
+ env: process.env,
135
+ home: HOME_DIR,
136
+ platform: process.platform,
137
+ readDir: (p) => fs.readdirSync(p),
138
+ });
139
+ migrateLegacyCacheDir(CACHE_DIR_RESOLVED, EXPLICIT_OVERRIDE);
78
140
  // Config file lives inside the cache dir.
79
141
  const CONFIG_FILE = path.join(CACHE_DIR_RESOLVED, 'config.json');
80
142
  exports.CONFIG_DEFAULTS = {
@@ -491,17 +491,30 @@ else
491
491
  exit 1
492
492
  fi
493
493
 
494
- # Check if already installed (version marker is in the grep below)
495
- if grep -q "Shell Integration (v50)" "$RC_FILE" 2>/dev/null; then
494
+ # Keep the generated functions out of the user's shell config. The same file
495
+ # works in both bash and zsh; the RC file only needs this guarded source line.
496
+ SHELL_INTEGRATION_DIR="$HOME/.nemus"
497
+ SHELL_INTEGRATION_FILE="$SHELL_INTEGRATION_DIR/shell-integration.sh"
498
+ SOURCE_LINE='[ -f "$HOME/.nemus/shell-integration.sh" ] && source "$HOME/.nemus/shell-integration.sh"'
499
+
500
+ mkdir -p "$SHELL_INTEGRATION_DIR"
501
+ SHELL_TMPFILE=$(mktemp "$SHELL_INTEGRATION_DIR/.shell-integration.XXXXXX")
502
+ printf '%s\n' "$SHELL_FUNCTION" > "$SHELL_TMPFILE"
503
+ mv "$SHELL_TMPFILE" "$SHELL_INTEGRATION_FILE"
504
+
505
+ append_source_line() {
506
+ printf '\n# Nemus - Shell Integration\n%s\n' "$SOURCE_LINE" >> "$1"
507
+ }
508
+
509
+ # Check if already installed
510
+ if grep -Fq "$SOURCE_LINE" "$RC_FILE" 2>/dev/null; then
496
511
  echo "✅ Shell integration already up to date in $RC_FILE"
497
512
  elif grep -q "# Nemus - Shell Integration" "$RC_FILE" 2>/dev/null; then
498
- # Old version installed — remove it and install new version
513
+ # Old inline version installed — remove it and leave only the source line
499
514
  echo "🔄 Upgrading shell integration in $RC_FILE..."
500
515
  TMPFILE=$(mktemp)
501
516
  awk -f "$SCRIPT_DIR/remove-shell-block.awk" "$RC_FILE" > "$TMPFILE"
502
- # Append new shell integration to temp file so mv is atomic
503
- echo "" >> "$TMPFILE"
504
- echo "$SHELL_FUNCTION" >> "$TMPFILE"
517
+ append_source_line "$TMPFILE"
505
518
  cp "$RC_FILE" "${RC_FILE}.backup.$(date +%Y%m%d_%H%M%S)"
506
519
  chmod --reference="$RC_FILE" "$TMPFILE" 2>/dev/null || chmod "$(stat -f '%Lp' "$RC_FILE")" "$TMPFILE" 2>/dev/null || true
507
520
  mv "$TMPFILE" "$RC_FILE"
@@ -514,9 +527,8 @@ else
514
527
  echo "📦 Backup created: ${RC_FILE}.backup.*"
515
528
  fi
516
529
 
517
- # Append shell function (creates the file if it doesn't exist)
518
- echo "" >> "$RC_FILE"
519
- echo "$SHELL_FUNCTION" >> "$RC_FILE"
530
+ # Append the source line (creates the file if it doesn't exist)
531
+ append_source_line "$RC_FILE"
520
532
 
521
533
  echo "✅ Shell integration installed in $RC_FILE"
522
534
  echo ""
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nemus-cli/nemus",
3
- "version": "0.13.0",
3
+ "version": "0.15.0",
4
4
  "workspaces": [
5
5
  "packages/*"
6
6
  ],
@@ -33,6 +33,14 @@ skip {
33
33
  saw_func = 1
34
34
  next
35
35
  }
36
+ if (/\.nemus\/shell-integration\.sh/ && /source/) {
37
+ # Current installs keep the functions in a generated file and leave only
38
+ # this source line in the RC file.
39
+ skip = 0
40
+ saw_func = 0
41
+ buf = ""
42
+ next
43
+ }
36
44
  # Non-function line: block is over — emit any buffered lines
37
45
  skip = 0
38
46
  if (buf != "") printf "%s", buf
@@ -202,12 +202,21 @@ describe('config', () => {
202
202
  });
203
203
 
204
204
  describe('cache dir location + legacy migration', () => {
205
- it('defaults CACHE_DIR to ~/.nemus when no env override is set', async () => {
205
+ // With XDG support a fresh install resolves to ~/.config/nemus on Linux and
206
+ // ~/.nemus elsewhere. Delete XDG_CONFIG_HOME so resolution is homedir-based
207
+ // (and stays inside the tempDir sandbox on any CI host).
208
+ const defaultHome = () =>
209
+ process.platform === 'linux'
210
+ ? path.join(tempDir, '.config', 'nemus')
211
+ : path.join(tempDir, '.nemus');
212
+
213
+ it('defaults CACHE_DIR to the platform default when no env override is set', async () => {
206
214
  delete process.env.NEMUS_CACHE_DIR;
207
215
  delete process.env.WORKSPACE_MANAGER_CACHE_DIR;
216
+ delete process.env.XDG_CONFIG_HOME;
208
217
  vi.resetModules();
209
218
  const { CACHE_DIR } = await loadModule();
210
- expect(CACHE_DIR).toBe(path.join(tempDir, '.nemus'));
219
+ expect(CACHE_DIR).toBe(defaultHome());
211
220
  });
212
221
 
213
222
  it('prefers NEMUS_CACHE_DIR, then legacy WORKSPACE_MANAGER_CACHE_DIR', async () => {
@@ -222,41 +231,122 @@ describe('config', () => {
222
231
  expect((await loadModule()).CACHE_DIR).toBe(path.join(tempDir, '.workspace-manager-cache'));
223
232
  });
224
233
 
225
- it('migrates state from ~/.workspace-manager-cache to ~/.nemus on first run', async () => {
234
+ it('migrates state from ~/.workspace-manager-cache to the default dir on first run', async () => {
226
235
  delete process.env.NEMUS_CACHE_DIR;
227
236
  delete process.env.WORKSPACE_MANAGER_CACHE_DIR;
237
+ delete process.env.XDG_CONFIG_HOME;
228
238
  // legacy dir (created by beforeEach) holds prior state; new dir absent
229
239
  fs.writeFileSync(path.join(tempDir, '.workspace-manager-cache', 'suites.json'), '{"x":1}');
230
- expect(fs.existsSync(path.join(tempDir, '.nemus'))).toBe(false);
240
+ expect(fs.existsSync(defaultHome())).toBe(false);
231
241
  vi.resetModules();
232
242
  const { CACHE_DIR } = await loadModule();
233
- expect(CACHE_DIR).toBe(path.join(tempDir, '.nemus'));
243
+ expect(CACHE_DIR).toBe(defaultHome());
234
244
  // copied, not moved: file exists in the new dir AND the legacy dir survives
235
- expect(fs.readFileSync(path.join(tempDir, '.nemus', 'suites.json'), 'utf-8')).toBe('{"x":1}');
245
+ expect(fs.readFileSync(path.join(defaultHome(), 'suites.json'), 'utf-8')).toBe('{"x":1}');
236
246
  expect(fs.existsSync(path.join(tempDir, '.workspace-manager-cache', 'suites.json'))).toBe(true);
237
247
  });
238
248
 
239
249
  it('does not migrate last-version-check.json (avoids inheriting a foreign latest)', async () => {
240
250
  delete process.env.NEMUS_CACHE_DIR;
241
251
  delete process.env.WORKSPACE_MANAGER_CACHE_DIR;
252
+ delete process.env.XDG_CONFIG_HOME;
242
253
  const legacy = path.join(tempDir, '.workspace-manager-cache');
243
254
  fs.writeFileSync(path.join(legacy, 'suites.json'), '{"x":1}');
244
255
  fs.writeFileSync(path.join(legacy, 'last-version-check.json'), '{"latestVersion":"99.0.0"}');
245
256
  vi.resetModules();
246
257
  await loadModule();
247
258
  // other state migrates, but the version-check cache is left behind
248
- expect(fs.existsSync(path.join(tempDir, '.nemus', 'suites.json'))).toBe(true);
249
- expect(fs.existsSync(path.join(tempDir, '.nemus', 'last-version-check.json'))).toBe(false);
259
+ expect(fs.existsSync(path.join(defaultHome(), 'suites.json'))).toBe(true);
260
+ expect(fs.existsSync(path.join(defaultHome(), 'last-version-check.json'))).toBe(false);
250
261
  });
251
262
 
252
263
  it('does not migrate when the new dir already exists', async () => {
253
264
  delete process.env.NEMUS_CACHE_DIR;
254
265
  delete process.env.WORKSPACE_MANAGER_CACHE_DIR;
255
- fs.mkdirSync(path.join(tempDir, '.nemus'), { recursive: true });
266
+ delete process.env.XDG_CONFIG_HOME;
267
+ fs.mkdirSync(defaultHome(), { recursive: true });
256
268
  fs.writeFileSync(path.join(tempDir, '.workspace-manager-cache', 'suites.json'), '{"x":1}');
257
269
  vi.resetModules();
258
270
  await loadModule();
259
- expect(fs.existsSync(path.join(tempDir, '.nemus', 'suites.json'))).toBe(false);
271
+ expect(fs.existsSync(path.join(defaultHome(), 'suites.json'))).toBe(false);
272
+ });
273
+
274
+ it('keeps an existing ~/.nemus install even on Linux (no surprise XDG move)', async () => {
275
+ delete process.env.NEMUS_CACHE_DIR;
276
+ delete process.env.WORKSPACE_MANAGER_CACHE_DIR;
277
+ delete process.env.XDG_CONFIG_HOME;
278
+ const branded = path.join(tempDir, '.nemus');
279
+ fs.mkdirSync(branded, { recursive: true });
280
+ fs.writeFileSync(path.join(branded, 'config.json'), '{}'); // real prior state
281
+ vi.resetModules();
282
+ const { CACHE_DIR } = await loadModule();
283
+ expect(CACHE_DIR).toBe(branded);
284
+ });
285
+ });
286
+
287
+ describe('resolveNemusHome precedence', () => {
288
+ const home = '/home/u';
289
+ const branded = path.join(home, '.nemus');
290
+ // A readDir that reports the given entries for ~/.nemus and "absent" elsewhere.
291
+ const brandedHas = (...entries: string[]) => (p: string): string[] => {
292
+ if (p === branded) return entries;
293
+ throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
294
+ };
295
+ const empty = (): string[] => {
296
+ throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
297
+ };
298
+
299
+ it('explicit NEMUS_CACHE_DIR / legacy override wins over everything', async () => {
300
+ const { resolveNemusHome } = await loadModule();
301
+ expect(
302
+ resolveNemusHome({ env: { NEMUS_CACHE_DIR: '/x', XDG_CONFIG_HOME: '/c' }, home, platform: 'linux', readDir: brandedHas('config.json') }),
303
+ ).toBe('/x');
304
+ expect(
305
+ resolveNemusHome({ env: { WORKSPACE_MANAGER_CACHE_DIR: '/y' }, home, platform: 'linux', readDir: brandedHas('config.json') }),
306
+ ).toBe('/y');
307
+ });
308
+
309
+ it('an existing ~/.nemus with durable state wins over XDG, on any platform', async () => {
310
+ const { resolveNemusHome } = await loadModule();
311
+ expect(resolveNemusHome({ env: { XDG_CONFIG_HOME: '/c' }, home, platform: 'linux', readDir: brandedHas('config.json') })).toBe(branded);
312
+ });
313
+
314
+ it('counts reflect/ (and any non-cache entry) as durable state — not just the classic 3', async () => {
315
+ const { resolveNemusHome } = await loadModule();
316
+ // a reflect-only user has no config.json/suites.json/history.jsonl
317
+ expect(resolveNemusHome({ env: {}, home, platform: 'linux', readDir: brandedHas('reflect') })).toBe(branded);
318
+ });
319
+
320
+ it('a ~/.nemus holding only regenerable caches / the shell artifact does NOT count', async () => {
321
+ const { resolveNemusHome } = await loadModule();
322
+ expect(
323
+ resolveNemusHome({
324
+ env: {},
325
+ home,
326
+ platform: 'linux',
327
+ readDir: brandedHas('shell-integration.sh', 'last-version-check.json', 'repos-cache.json', 'last-error.json', '.DS_Store'),
328
+ }),
329
+ ).toBe(path.join(home, '.config', 'nemus'));
330
+ });
331
+
332
+ it('honors an absolute XDG_CONFIG_HOME on a fresh install (any platform)', async () => {
333
+ const { resolveNemusHome } = await loadModule();
334
+ expect(resolveNemusHome({ env: { XDG_CONFIG_HOME: '/cfg' }, home, platform: 'linux', readDir: empty })).toBe('/cfg/nemus');
335
+ expect(resolveNemusHome({ env: { XDG_CONFIG_HOME: '/cfg' }, home, platform: 'darwin', readDir: empty })).toBe('/cfg/nemus');
336
+ });
337
+
338
+ it('ignores a relative XDG_CONFIG_HOME (per spec)', async () => {
339
+ const { resolveNemusHome } = await loadModule();
340
+ expect(resolveNemusHome({ env: { XDG_CONFIG_HOME: 'rel/path' }, home, platform: 'linux', readDir: empty })).toBe(
341
+ path.join(home, '.config', 'nemus'),
342
+ );
343
+ });
344
+
345
+ it('fresh-install default: Linux -> ~/.config/nemus, macOS/Windows -> ~/.nemus', async () => {
346
+ const { resolveNemusHome } = await loadModule();
347
+ expect(resolveNemusHome({ env: {}, home, platform: 'linux', readDir: empty })).toBe(path.join(home, '.config', 'nemus'));
348
+ expect(resolveNemusHome({ env: {}, home, platform: 'darwin', readDir: empty })).toBe(branded);
349
+ expect(resolveNemusHome({ env: {}, home, platform: 'win32', readDir: empty })).toBe(branded);
260
350
  });
261
351
  });
262
352
  });
@@ -4,12 +4,73 @@ import * as fs from 'fs';
4
4
 
5
5
  const HOME_DIR = os.homedir();
6
6
 
7
- // Cache/config/state directory. Prefer the branded NEMUS_* env vars; fall back
8
- // to the legacy WORKSPACE_MANAGER_* names for backward compatibility; else the
9
- // default ~/.nemus home.
10
- const DEFAULT_CACHE_DIR = path.join(HOME_DIR, '.nemus');
11
7
  const LEGACY_CACHE_DIR = path.join(HOME_DIR, '.workspace-manager-cache');
12
8
 
9
+ // Entries in ~/.nemus that do NOT make it a "real install": regenerable caches
10
+ // and the shell-integration artifact. Anything else (config.json, suites.json,
11
+ // history.jsonl, the reflect/ report dir, or any future state) marks the dir as
12
+ // an existing install to keep in place. Denylisting the throwaways — a small,
13
+ // stable set — is exhaustive for durable state by construction, which is safer
14
+ // than allowlisting state files (that could miss e.g. reflect/ and wrongly
15
+ // relocate a real install to XDG on upgrade).
16
+ const REGENERABLE_ENTRIES = new Set([
17
+ 'last-version-check.json', // update-check cache
18
+ 'repos-cache.json', // GitHub repo-list cache
19
+ 'last-error.json', // last error, for report-bug
20
+ 'shell-integration.sh', // shell installer artifact — not Nemus state
21
+ '.DS_Store',
22
+ ]);
23
+
24
+ export interface NemusHomeEnv {
25
+ env: NodeJS.ProcessEnv;
26
+ home: string;
27
+ platform: NodeJS.Platform;
28
+ /** List a directory's entries; throw (or return []) when it doesn't exist. */
29
+ readDir: (p: string) => string[];
30
+ }
31
+
32
+ /** True if ~/.nemus holds anything beyond regenerable caches / the shell artifact. */
33
+ function hasDurableState(dir: string, readDir: (p: string) => string[]): boolean {
34
+ let entries: string[];
35
+ try {
36
+ entries = readDir(dir);
37
+ } catch {
38
+ return false; // dir doesn't exist
39
+ }
40
+ return entries.some((e) => !REGENERABLE_ENTRIES.has(e));
41
+ }
42
+
43
+ /**
44
+ * Resolve the directory that holds Nemus config + state. Precedence (first
45
+ * match wins), designed so existing users are never silently moved:
46
+ *
47
+ * 1. Explicit override — NEMUS_CACHE_DIR (or legacy WORKSPACE_MANAGER_CACHE_DIR).
48
+ * 2. An existing ~/.nemus that holds durable state (anything beyond
49
+ * regenerable caches / the shell-integration file) — kept on every platform.
50
+ * 3. XDG_CONFIG_HOME, when set to an absolute path — $XDG_CONFIG_HOME/nemus.
51
+ * 4. Linux with no prior install — the XDG default ~/.config/nemus.
52
+ * 5. Otherwise (macOS/Windows, fresh install) — ~/.nemus.
53
+ *
54
+ * Config + state are kept together in one dir (a later change may split cache
55
+ * out under XDG_CACHE_HOME). The dir is chosen under XDG_CONFIG_HOME, not
56
+ * XDG_CACHE_HOME, because it holds real config (config.json) — not disposable
57
+ * cache a cleaner may wipe. A relative XDG_CONFIG_HOME is ignored per the spec.
58
+ */
59
+ export function resolveNemusHome({ env, home, platform, readDir }: NemusHomeEnv): string {
60
+ const explicit = env.NEMUS_CACHE_DIR || env.WORKSPACE_MANAGER_CACHE_DIR;
61
+ if (explicit) return explicit;
62
+
63
+ const branded = path.join(home, '.nemus');
64
+ if (hasDurableState(branded, readDir)) return branded;
65
+
66
+ const xdg = env.XDG_CONFIG_HOME;
67
+ if (xdg && path.isAbsolute(xdg)) return path.join(xdg, 'nemus');
68
+
69
+ if (platform === 'linux') return path.join(home, '.config', 'nemus');
70
+
71
+ return branded;
72
+ }
73
+
13
74
  /**
14
75
  * One-time, best-effort migration of state from the pre-0.2.2 cache location
15
76
  * (~/.workspace-manager-cache) to ~/.nemus. Runs only when using the default
@@ -21,11 +82,15 @@ const LEGACY_CACHE_DIR = path.join(HOME_DIR, '.workspace-manager-cache');
21
82
  * shared by another tool whose "latest version" is unrelated to nemus, and
22
83
  * copying it would make the update check report a wrong version until the entry
23
84
  * expires. Skipping it just forces one fresh lookup.
85
+ *
86
+ * Skipped when an explicit env override is in effect (don't copy into a path the
87
+ * user deliberately chose). Runs into whatever default location was resolved,
88
+ * including a new XDG dir on Linux.
24
89
  */
25
90
  const MIGRATION_SKIP = new Set(['last-version-check.json']);
26
- function migrateLegacyCacheDir(targetDir: string): void {
91
+ function migrateLegacyCacheDir(targetDir: string, isExplicitOverride: boolean): void {
27
92
  try {
28
- if (targetDir === DEFAULT_CACHE_DIR && !fs.existsSync(targetDir) && fs.existsSync(LEGACY_CACHE_DIR)) {
93
+ if (!isExplicitOverride && !fs.existsSync(targetDir) && fs.existsSync(LEGACY_CACHE_DIR)) {
29
94
  fs.cpSync(LEGACY_CACHE_DIR, targetDir, {
30
95
  recursive: true,
31
96
  filter: (src) => !MIGRATION_SKIP.has(path.basename(src)),
@@ -36,9 +101,14 @@ function migrateLegacyCacheDir(targetDir: string): void {
36
101
  }
37
102
  }
38
103
 
39
- const CACHE_DIR_RESOLVED =
40
- process.env.NEMUS_CACHE_DIR || process.env.WORKSPACE_MANAGER_CACHE_DIR || DEFAULT_CACHE_DIR;
41
- migrateLegacyCacheDir(CACHE_DIR_RESOLVED);
104
+ const EXPLICIT_OVERRIDE = !!(process.env.NEMUS_CACHE_DIR || process.env.WORKSPACE_MANAGER_CACHE_DIR);
105
+ const CACHE_DIR_RESOLVED = resolveNemusHome({
106
+ env: process.env,
107
+ home: HOME_DIR,
108
+ platform: process.platform,
109
+ readDir: (p) => fs.readdirSync(p),
110
+ });
111
+ migrateLegacyCacheDir(CACHE_DIR_RESOLVED, EXPLICIT_OVERRIDE);
42
112
 
43
113
  // Config file lives inside the cache dir.
44
114
  const CONFIG_FILE = path.join(CACHE_DIR_RESOLVED, 'config.json');
package/uninstall.sh CHANGED
@@ -38,7 +38,7 @@ echo -e "${YELLOW}This will NOT remove:${NC}"
38
38
  echo " • Your workspaces ($HOME/workspaces/)"
39
39
  echo " • Cache files (~/.workspace-manager-cache/)"
40
40
  echo " • Configuration files (~/.workspace-manager-claude-config.json)"
41
- echo " • Shell integration (from .zshrc or .bashrc)"
41
+ echo " • Shell integration (from .zshrc or .bashrc and ~/.nemus/)"
42
42
  echo " • ghq (if installed)"
43
43
  echo ""
44
44
 
@@ -85,8 +85,11 @@ if [[ $REPLY =~ ^[Yy]$ ]]; then
85
85
  if grep -q "# Nemus - Shell Integration" "$HOME/.zshrc"; then
86
86
  # Create backup
87
87
  cp "$HOME/.zshrc" "$HOME/.zshrc.backup.$(date +%Y%m%d_%H%M%S)"
88
- # Remove the function
89
- sed -i.bak '/# Nemus - Shell Integration/,/^}$/d' "$HOME/.zshrc"
88
+ # Remove the block/source line (awk handles both formats safely)
89
+ TMPFILE=$(mktemp)
90
+ awk -f "$SCRIPT_DIR/remove-shell-block.awk" "$HOME/.zshrc" > "$TMPFILE"
91
+ chmod --reference="$HOME/.zshrc" "$TMPFILE" 2>/dev/null || chmod "$(stat -f '%Lp' "$HOME/.zshrc")" "$TMPFILE" 2>/dev/null || true
92
+ mv "$TMPFILE" "$HOME/.zshrc"
90
93
  print_success "Removed from .zshrc (backup created)"
91
94
  fi
92
95
  fi
@@ -96,11 +99,19 @@ if [[ $REPLY =~ ^[Yy]$ ]]; then
96
99
  if grep -q "# Nemus - Shell Integration" "$HOME/.bashrc"; then
97
100
  # Create backup
98
101
  cp "$HOME/.bashrc" "$HOME/.bashrc.backup.$(date +%Y%m%d_%H%M%S)"
99
- # Remove the function
100
- sed -i.bak '/# Nemus - Shell Integration/,/^}$/d' "$HOME/.bashrc"
102
+ # Remove the block/source line (awk handles both formats safely)
103
+ TMPFILE=$(mktemp)
104
+ awk -f "$SCRIPT_DIR/remove-shell-block.awk" "$HOME/.bashrc" > "$TMPFILE"
105
+ chmod --reference="$HOME/.bashrc" "$TMPFILE" 2>/dev/null || chmod "$(stat -f '%Lp' "$HOME/.bashrc")" "$TMPFILE" 2>/dev/null || true
106
+ mv "$TMPFILE" "$HOME/.bashrc"
101
107
  print_success "Removed from .bashrc (backup created)"
102
108
  fi
103
109
  fi
110
+
111
+ if [ -f "$HOME/.nemus/shell-integration.sh" ]; then
112
+ rm -f "$HOME/.nemus/shell-integration.sh"
113
+ print_success "Removed ~/.nemus/shell-integration.sh"
114
+ fi
104
115
  fi
105
116
 
106
117
  # Optionally remove cache