claude-nomad 0.25.5 → 0.26.1

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
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.26.1](https://github.com/funkadelic/claude-nomad/compare/v0.26.0...v0.26.1) (2026-05-27)
4
+
5
+
6
+ ### Fixed
7
+
8
+ * **preview:** replace diffJsonStrings parallel walk with jsdiff LCS line diff ([#159](https://github.com/funkadelic/claude-nomad/issues/159)) ([d9c0dca](https://github.com/funkadelic/claude-nomad/commit/d9c0dcae7b417d069e826ce3b825e9f23115ba2d))
9
+ * **remap:** fail closed on path-map collisions in remapPush before any write ([#156](https://github.com/funkadelic/claude-nomad/issues/156)) ([db33157](https://github.com/funkadelic/claude-nomad/commit/db33157faa02b5439278ae4e92a02bd671faee72))
10
+
11
+
12
+ ### Changed
13
+
14
+ * **codeql:** skip analysis on release-please PR branches ([#160](https://github.com/funkadelic/claude-nomad/issues/160)) ([2bfed46](https://github.com/funkadelic/claude-nomad/commit/2bfed463ccd76f6331e5ab775bbd5d19df2898e9))
15
+ * **tests:** skip PR-time test matrix on release-please PR branches ([#158](https://github.com/funkadelic/claude-nomad/issues/158)) ([222e27b](https://github.com/funkadelic/claude-nomad/commit/222e27b2643382136505f72e148c17c7ecb7a08f))
16
+
17
+ ## [0.26.0](https://github.com/funkadelic/claude-nomad/compare/v0.25.5...v0.26.0) (2026-05-27)
18
+
19
+
20
+ ### Added
21
+
22
+ * **doctor:** readability pass on doctor output ([#154](https://github.com/funkadelic/claude-nomad/issues/154)) ([d4bb912](https://github.com/funkadelic/claude-nomad/commit/d4bb912180dbae8e12dfafb4ddd71abb54d0d441))
23
+
3
24
  ## [0.25.5](https://github.com/funkadelic/claude-nomad/compare/v0.25.4...v0.25.5) (2026-05-27)
4
25
 
5
26
 
package/README.md CHANGED
@@ -12,15 +12,16 @@
12
12
  **Your entire Claude Code setup, on every machine. History included, every push secret-scanned.**
13
13
 
14
14
  Open Claude Code on a second machine and it is a blank slate: none of your custom agents, slash
15
- commands, tuned settings, or past conversations. claude-nomad keeps all of it in sync through a
15
+ commands, tuned settings, or past conversations. **claude-nomad** keeps all of it in sync through a
16
16
  private Git repo you control. `nomad push` on one machine, `nomad pull` on the next, and everything
17
17
  is there, conversations included.
18
18
 
19
- - **Resume your sessions on any machine.** Start a conversation on your desktop and pick it up on
20
- your laptop. claude-nomad remaps the file paths Claude Code embeds in every transcript, so your
21
- history follows you instead of getting stranded on the box where it started.
19
+ - **Resume your Claude Code [sessions](https://code.claude.com/docs/en/agent-sdk/sessions) on any
20
+ machine.** Start a conversation on your desktop and pick it up on your laptop. **claude-nomad**
21
+ remaps the file paths Claude Code embeds in every transcript, so your history follows you instead
22
+ of getting stranded on the box where it started.
22
23
  - **Secret-scanned, private by default.** Your `~/.claude/` also holds OAuth tokens, MCP
23
- credentials, and the full text of every conversation, so claude-nomad is deliberate about what
24
+ credentials, and the full text of every conversation, so **claude-nomad** is deliberate about what
24
25
  leaves your machine: credentials and ephemeral state never sync, only an explicit allow-list of
25
26
  paths is pushed, and everything that does go up is scanned by
26
27
  [gitleaks](https://github.com/gitleaks/gitleaks) before it leaves your machine; the push aborts on
@@ -30,7 +31,7 @@ is there, conversations included.
30
31
  and follow you everywhere. Per-machine tweaks like model choice, MCP URLs, and env vars merge on
31
32
  top instead of clobbering your shared defaults.
32
33
 
33
- Not dotfiles, not rsync. claude-nomad understands Claude Code's state, so your session history
34
+ Not dotfiles, not rsync. **claude-nomad** understands Claude Code's state, so your session history
34
35
  survives different file paths and your secrets never ride along.
35
36
 
36
37
  For anyone running Claude Code on more than one machine: a laptop and a desktop, a Mac and a WSL
@@ -65,8 +66,8 @@ box, a personal rig and a work machine. [Get started in three steps.](#quickstar
65
66
 
66
67
  ## Quickstart
67
68
 
68
- If you already have a private claude-nomad mirror (see [Setup](#setup) for the one-time bootstrap),
69
- adding a new host is three steps:
69
+ If you already have a private **claude-nomad** mirror (see [Setup](#setup) for the one-time
70
+ bootstrap), adding a new host is three steps:
70
71
 
71
72
  ```bash
72
73
  $ npm i -g claude-nomad
@@ -97,8 +98,8 @@ First-host bootstrap and the safe-migration sequence for a populated `~/.claude/
97
98
 
98
99
  ## How it works (two-repo model)
99
100
 
100
- claude-nomad is a **tool**, not a config store. You maintain a separate **private** repo that holds
101
- your actual config (`CLAUDE.md`, agents, skills, settings overrides, session transcripts). The
101
+ **claude-nomad** is a **tool**, not a config store. You maintain a separate **private** repo that
102
+ holds your actual config (`CLAUDE.md`, agents, skills, settings overrides, session transcripts). The
102
103
  tool's source and your config end up coexisting in one working tree on each host.
103
104
 
104
105
  ```text
@@ -205,8 +206,8 @@ cleanly instead of creating an orphan `~/.claude/projects/TBD/`. Replace each `"
205
206
  path when you bring up that host.
206
207
 
207
208
  On `push`, sessions in `~/.claude/projects/-Users-you-code-my-example-repo/` get copied to
208
- `shared/projects/my-example-repo/`. On `pull` on another machine, they get copied to that host's
209
- encoded path. `claude --resume` then finds them (see
209
+ `shared/projects/my-example-repo/`. On `nomad pull` on another machine, they get copied to that
210
+ host's encoded path. `claude --resume` then finds them (see
210
211
  [What does NOT sync (deliberate trade-offs)](#what-does-not-sync-deliberate-trade-offs) for the
211
212
  cross-OS cwd-binding gotcha).
212
213
 
@@ -215,10 +216,10 @@ working unchanged. Each value is an array of directory or root-file names (e.g.
215
216
  `CLAUDE.md`) checked against `SUPPORTED_EXTRAS` in `src/config.ts`; anything outside that whitelist
216
217
  is skipped with a log line, so an unrecognized name cannot widen the sync surface.
217
218
 
218
- On `push`, opted-in content at `<localRoot>/<name>` (a directory subtree or a single file) is copied
219
- to `shared/extras/<logical>/<name>` and goes through the same staged-tree gitleaks scan as
220
- everything else. On `pull`, the reverse copy runs after `git pull --rebase`, and just before it
221
- overwrites your working tree a divergence check compares the incoming content against your local
219
+ On `nomad push`, opted-in content at `<localRoot>/<name>` (a directory subtree or a single file) is
220
+ copied to `shared/extras/<logical>/<name>` and goes through the same staged-tree gitleaks scan as
221
+ everything else. On `nomad pull`, the reverse copy runs after `git pull --rebase`, and just before
222
+ it overwrites your working tree a divergence check compares the incoming content against your local
222
223
  copy and prints a per-file WARN naming anything that differs.
223
224
 
224
225
  Your existing local content is backed up under `~/.cache/claude-nomad/backup/<ts>/extras/` before
@@ -229,10 +230,11 @@ the pull copy lands, so an unexpected overwrite is always recoverable.
229
230
  `settings.base.json` holds portable defaults (model, permissions, plugins).
230
231
  `hosts/<NOMAD_HOST>.json` holds machine-specific patches. They're deep-merged on every pull (scalars
231
232
  override, objects merge recursively, arrays replace). Keys that used to be force-marked per-host
232
- because they embedded absolute paths (`statusLine.command`, `hooks`) can live in base if you write
233
- the commands with `$HOME` (e.g. `"command": "node \"$HOME/.claude/my-statusline.cjs\""`); Claude
234
- Code runs them through a shell so shell expansion applies. Reserve per-host files for truly
235
- machine-specific values (env, MCP URLs, host-only model overrides).
233
+ because they embedded absolute paths (`statusLine.command`, `hooks`) can live in
234
+ `settings.base.json` if you write the commands with `$HOME` (e.g.
235
+ `"command": "node \"$HOME/.claude/my-statusline.cjs\""`); Claude Code runs them through a shell so
236
+ shell expansion applies. Reserve per-host files for truly machine-specific values (env, MCP URLs,
237
+ host-only model overrides).
236
238
 
237
239
  `shared/settings.base.json`:
238
240
 
@@ -243,7 +245,7 @@ machine-specific values (env, MCP URLs, host-only model overrides).
243
245
  }
244
246
  ```
245
247
 
246
- `hosts/<your-wsl-host>.json`:
248
+ `hosts/<your-other-host>.json`:
247
249
 
248
250
  ```json
249
251
  {
@@ -252,7 +254,7 @@ machine-specific values (env, MCP URLs, host-only model overrides).
252
254
  }
253
255
  ```
254
256
 
255
- Result on that host: opus model, the local Ollama env var, plus the shared permissions array.
257
+ Results on `your-other-host`: opus 4.7, the local Ollama env var, plus the shared permissions array.
256
258
 
257
259
  > [!CAUTION] Never hand-edit `~/.claude/settings.json` on a synced host. It's regenerated on every
258
260
  > `nomad pull` from base + host, so your edits will be clobbered. Edit the base or host file in the
@@ -273,8 +275,8 @@ Read these before adopting so you opt in with eyes open.
273
275
  surface. Unsafe path-map values (path-traversal in `logical` keys, non-absolute or unnormalized
274
276
  `localRoot` values) abort the run before any file is touched, so a malformed entry fails loudly
275
277
  instead of corrupting state.
276
- - **Cross-OS `claude --resume` cwd binding.** Sessions embed the cwd where they were created, so the
277
- picker's `cd ... && claude --resume <id>` line fails on a different host. Use
278
+ - **Cross-OS `claude --resume` cwd binding.** Sessions embed the cwd where they were created, so
279
+ Claude Code's picker's `cd ... && claude --resume <id>` line fails on a different host. Use
278
280
  `nomad doctor --resume-cmd <id>` for a host-local equivalent (see
279
281
  [Cross-OS resume](#cross-os-resume)). The sidecar approach preserves transcript byte-equality.
280
282
  - **Empty directories don't survive sync.** Git doesn't track empty dirs; `nomad doctor` reports
@@ -293,13 +295,14 @@ Read these before adopting so you opt in with eyes open.
293
295
  it is absent or mismatched)
294
296
  - A **private** GitHub repo (or any Git remote you control)
295
297
 
296
- **Optional:**
298
+ **Optional, but recommended:**
297
299
 
298
- - `gh` (GitHub CLI), used only by `nomad init` to auto-disable Actions on the private repo; if it is
299
- missing or unauthenticated, init prints a manual fallback tip and continues
300
- - `curl`, used only by the version/update check (the `nomad doctor` latest-release line and the
301
- post-`nomad update` check); it degrades silently when curl is absent or offline, so the rest of
302
- the CLI works without it
300
+ - `gh` ([GitHub CLI](https://cli.github.com/)), used only by `nomad init` to auto-disable Actions on
301
+ the private repo; if it is missing or unauthenticated, init prints a manual fallback tip and
302
+ continues
303
+ - [curl](https://curl.se/), used only by the version/update check (the `nomad doctor` latest-release
304
+ line and the post-`nomad update` check); it degrades silently when curl is absent or offline, so
305
+ the rest of the CLI works without it
303
306
 
304
307
  ## Setup
305
308
 
@@ -329,7 +332,7 @@ enforces an Actions policy upstream).
329
332
 
330
333
  > [!WARNING] If you ever flip the mirror to public, both protections evaporate: CI starts firing on
331
334
  > every `nomad push` against `main`, and your session transcripts (which include conversation
332
- > content) become world-readable. Keep it private.
335
+ > content) become world-readable. **Keep it private.**
333
336
 
334
337
  ### Bootstrap
335
338
 
@@ -342,10 +345,10 @@ $ gh repo create <your-username>/claude-nomad --private
342
345
  # 2. Copy the public tool into your private repo. A bare clone followed by a
343
346
  # mirror push makes a complete, independent copy (every branch and tag) with
344
347
  # no fork link back to upstream, which is what lets you keep it private. Once, ever.
345
- $ git clone --bare git@github.com:funkadelic/claude-nomad.git /tmp/cn.git # download a full copy
346
- $ cd /tmp/cn.git
348
+ $ git clone --bare git@github.com:funkadelic/claude-nomad.git /tmp/claude-nomad.git # download a full copy
349
+ $ cd /tmp/claude-nomad.git
347
350
  $ git push --mirror git@github.com:<your-username>/claude-nomad.git # upload it to your private repo
348
- $ cd .. && rm -rf /tmp/cn.git
351
+ $ cd .. && rm -rf /tmp/claude-nomad.git
349
352
 
350
353
  # 3. Install the CLI globally and clone your private copy. Repeat on every host.
351
354
  $ npm i -g claude-nomad
@@ -380,9 +383,6 @@ $ nomad init
380
383
  # starting point. Stages shared/ and writes hosts/<NOMAD_HOST>.json from
381
384
  # your current ~/.claude/settings.json. Does NOT touch the originals.
382
385
  $ nomad init --snapshot
383
-
384
- # Either form accepts --keep-actions to skip the auto-disable.
385
- $ nomad init --keep-actions
386
386
  ```
387
387
 
388
388
  `nomad init` refuses to clobber existing scaffold artifacts, so re-running on a populated repo is a
@@ -533,9 +533,9 @@ point under your npm prefix's `bin/`), then delete the alias line from your shel
533
533
  | `nomad doctor --check-shared` | Read-only gitleaks preflight: stages the session transcripts a `push` would publish into a temp tree and scans them, failing (`✗`, exit 1) per affected session with rotate-and-scrub guidance. Skips with a `⚠︎` when gitleaks is not on PATH. See [Recovery flow: gitleaks FATAL on a session JSONL](#recovery-flow-gitleaks-fatal-on-a-session-jsonl). |
534
534
  | `nomad --version` | Print the installed CLI version as bare semver to stdout; exits 0. Used by the npm-publish smoke test and useful for ad-hoc upgrade checks. |
535
535
 
536
- The version-check emits ``⚠︎ version: <local> -> <latest> (run `nomad update`)`` when the local
537
- install is behind the latest upstream release, and `✓ version: <local> (latest)` when current. It
538
- silently skips on network failures.
536
+ The version-check emits ``⚠︎ claude-nomad: <local> -> <latest> (run `nomad update`)`` when the local
537
+ install is behind the latest upstream release, and `✓ claude-nomad: <local> (latest)` when current.
538
+ It silently skips on network failures.
539
539
 
540
540
  Two further `⚠︎`-only drift checks run in `nomad doctor`. The gitleaks version-drift line
541
541
  `⚠︎ gitleaks: <local> -> <pinned> (...)` fires when the local gitleaks major.minor differs from the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-nomad",
3
- "version": "0.25.5",
3
+ "version": "0.26.1",
4
4
  "type": "module",
5
5
  "description": "Sync Claude Code config (~/.claude/) across machines via a private Git repo, with path remapping and per-host settings overrides.",
6
6
  "keywords": [
@@ -80,6 +80,7 @@
80
80
  "vitest": "^4.1.6"
81
81
  },
82
82
  "dependencies": {
83
+ "diff": "^9.0.0",
83
84
  "picocolors": "^1.1.1",
84
85
  "tsx": "^4.22.2"
85
86
  }
@@ -14,7 +14,7 @@
14
14
 
15
15
  import { join } from 'node:path';
16
16
 
17
- import { green, red, okGlyph, failGlyph } from './color.ts';
17
+ import { green, red, dim, okGlyph, failGlyph } from './color.ts';
18
18
  import { addItem, type DoctorSection } from './commands.doctor.format.ts';
19
19
  import { CLAUDE_HOME } from './config.ts';
20
20
  import { type Finding, partitionFindings, scanStagedTree } from './push-gitleaks.ts';
@@ -47,16 +47,16 @@ function reportSessionFindings(
47
47
  ): void {
48
48
  for (const [sid, counts] of bySession) {
49
49
  const summary = [...counts.entries()].map(([rule, n]) => `${rule} (${n})`).join(', ');
50
- addItem(section, `${red(failGlyph)} session ${sid}: ${summary}`);
50
+ addItem(section, `${red(failGlyph)} ${red(summary)} in session ${sid}`);
51
51
  const logical = logicalBySession.get(sid);
52
52
  /* c8 ignore next -- false branch is defensive; every bySession sid is keyed in logicalBySession */
53
53
  if (logical !== undefined) {
54
54
  addItem(
55
55
  section,
56
- ` rotate the credential, then scrub ${scrubPath(logical, sid, logicalToEncoded)}`,
56
+ ` ${dim(`rotate the credential, then scrub ${scrubPath(logical, sid, logicalToEncoded)}`)}`,
57
57
  );
58
58
  }
59
- addItem(section, ` false positive? add a pattern to .gitleaks.toml`);
59
+ addItem(section, ` ${dim('false positive? add a pattern to .gitleaks.toml')}`);
60
60
  }
61
61
  process.exitCode = 1;
62
62
  }
@@ -70,7 +70,7 @@ function reportSessionFindings(
70
70
  */
71
71
  function reportOtherFindings(section: DoctorSection, other: Finding[]): void {
72
72
  for (const f of other) {
73
- addItem(section, `${red(failGlyph)} leak in ${f.File}: ${f.RuleID}`);
73
+ addItem(section, `${red(failGlyph)} ${red(f.RuleID)} leak in ${f.File}`);
74
74
  }
75
75
  process.exitCode = 1;
76
76
  }
@@ -111,6 +111,29 @@ function buildLogicalBySession(findings: Finding[]): Map<string, string> {
111
111
  return logicalBySession;
112
112
  }
113
113
 
114
+ /**
115
+ * Emit a deduplicated description legend in the footer: one `[rule-id]:
116
+ * description` row per distinct RuleID across all findings, set off by a blank
117
+ * line before and after. Sourced from the `Description` gitleaks bakes into
118
+ * each finding, so it needs no network; rules whose description is absent
119
+ * (older gitleaks, custom rules) are skipped, and the whole block (including
120
+ * the surrounding blanks) is omitted when no descriptions are available. The
121
+ * legend lives in the footer so a rule hit across many files or sessions
122
+ * (e.g. `sonar-api-token`) is explained once, not per occurrence.
123
+ */
124
+ function emitDescriptionLegend(section: DoctorSection, findings: Finding[]): void {
125
+ const descByRule = new Map<string, string>();
126
+ for (const f of findings) {
127
+ if (f.Description && !descByRule.has(f.RuleID)) descByRule.set(f.RuleID, f.Description);
128
+ }
129
+ if (descByRule.size === 0) return;
130
+ addItem(section, '');
131
+ for (const [rule, desc] of descByRule) {
132
+ addItem(section, ` ${red(`[${rule}]`)}: ${dim(desc)}`);
133
+ }
134
+ addItem(section, '');
135
+ }
136
+
114
137
  /**
115
138
  * Scan the staged temp tree through the shared `scanStagedTree` and emit the
116
139
  * result rows. Isolates the deepest nesting from `reportCheckShared`: the scan
@@ -155,4 +178,5 @@ export function scanAndReport(
155
178
  if (bySession.size > 0) {
156
179
  reportSessionFindings(section, bySession, buildLogicalBySession(findings), logicalToEncoded);
157
180
  }
181
+ emitDescriptionLegend(section, findings);
158
182
  }
@@ -13,7 +13,7 @@ import { encodePath } from './utils.json.ts';
13
13
  * `process.exitCode = 1`. Read-only: FAIL lines stay on stdout.
14
14
  */
15
15
 
16
- /** Emits the mapped-projects header for the current host and one line per mapped project. */
16
+ /** Emits the mapped-projects header for the current host and one indented child line per mapped project. */
17
17
  function reportMappedProjects(section: DoctorSection, map: PathMap): void {
18
18
  const mapped = Object.entries(map.projects).filter(([, hosts]) => hosts[HOST]);
19
19
  addItem(
@@ -21,7 +21,7 @@ function reportMappedProjects(section: DoctorSection, map: PathMap): void {
21
21
  `${dim(infoGlyph)} mapped projects for ${cyan(HOST)}: ${dim(String(mapped.length))}`,
22
22
  );
23
23
  for (const [name, hosts] of mapped) {
24
- addItem(section, `${dim(infoGlyph)} ${name} -> ${blue(hosts[HOST])}`);
24
+ addItem(section, ` ${name} -> ${blue(hosts[HOST])}`);
25
25
  }
26
26
  }
27
27
 
@@ -43,7 +43,7 @@ export function isOverrideActive(): boolean {
43
43
  * Pushes the host identity (info) and the two key path lines (repo and
44
44
  * claude-home) with gutter glyphs. Path presence is reported via warnGlyph
45
45
  * (not failGlyph) so an absent CLAUDE_HOME does not flip sectionFailed to
46
- * decorate the Host header with `✘`. The authoritative empty-repo FAIL is
46
+ * decorate the Host header with a fail glyph. The authoritative empty-repo FAIL is
47
47
  * owned by reportRepoState; these two lines remain informational and do
48
48
  * NOT mutate process.exitCode.
49
49
  */
@@ -83,6 +83,16 @@ export function reportRepoState(section: DoctorSection): void {
83
83
  }
84
84
  }
85
85
 
86
+ /**
87
+ * True when the repo has a `shared/<name>` source for this link. `applySharedLinks`
88
+ * only creates a symlink when this source exists, so when it does NOT, an absent
89
+ * or dangling link in `~/.claude/` is expected (nothing to sync), not a problem to
90
+ * fix. Doctor uses this to downgrade those rows from a warn to an info note.
91
+ */
92
+ function repoHasSharedSource(name: string): boolean {
93
+ return existsSync(join(REPO_HOME, 'shared', name));
94
+ }
95
+
86
96
  /**
87
97
  * Resolve the display item and optional exit-code side-effect for a single
88
98
  * shared-link path. Returns `{ line, fail }` where `fail` true means the
@@ -99,7 +109,12 @@ function classifySharedLink(name: string, p: string): { line: string; fail: bool
99
109
  } catch (err) {
100
110
  const code = (err as NodeJS.ErrnoException).code;
101
111
  if (code === 'ENOENT') {
102
- return { line: `${yellow(warnGlyph)} ${name}: missing`, fail: false };
112
+ return repoHasSharedSource(name)
113
+ ? {
114
+ line: `${yellow(warnGlyph)} ${name}: missing (run \`nomad pull\` to restore)`,
115
+ fail: false,
116
+ }
117
+ : { line: `${dim(infoGlyph)} ${name}: not synced (nothing in shared/)`, fail: false };
103
118
  }
104
119
  return { line: `${red(failGlyph)} ${name}: could not stat (${String(code)})`, fail: true };
105
120
  }
@@ -112,7 +127,10 @@ function classifySharedLink(name: string, p: string): { line: string; fail: bool
112
127
  /**
113
128
  * Resolve the display item for a path already confirmed to be a symlink.
114
129
  * Follows the link via statSync; a throw means the target is missing or
115
- * unreadable. Returns `{ line, fail: false }` (symlink issues are WARN, not FAIL).
130
+ * unreadable. Never FAILs (`fail: false`): a dangling link whose source still
131
+ * lives in the repo is a WARN with a `nomad pull` hint, a dangling link whose
132
+ * source is gone from the repo is an info note (stale, safe to remove), and a
133
+ * non-ENOENT stat error is a WARN naming the code.
116
134
  */
117
135
  function classifySymlinkTarget(name: string, p: string): { line: string; fail: boolean } {
118
136
  try {
@@ -121,10 +139,15 @@ function classifySymlinkTarget(name: string, p: string): { line: string; fail: b
121
139
  } catch (err) {
122
140
  const code = (err as NodeJS.ErrnoException).code;
123
141
  if (code === 'ENOENT') {
124
- return {
125
- line: `${yellow(warnGlyph)} ${name}: broken symlink (target missing)`,
126
- fail: false,
127
- };
142
+ return repoHasSharedSource(name)
143
+ ? {
144
+ line: `${yellow(warnGlyph)} ${name}: broken symlink (target missing, run \`nomad pull\`)`,
145
+ fail: false,
146
+ }
147
+ : {
148
+ line: `${dim(infoGlyph)} ${name}: stale symlink (no longer in shared/, safe to remove)`,
149
+ fail: false,
150
+ };
128
151
  }
129
152
  return {
130
153
  line: `${yellow(warnGlyph)} ${name}: symlink target unreadable (${String(code)})`,
@@ -135,13 +158,15 @@ function classifySymlinkTarget(name: string, p: string): { line: string; fail: b
135
158
 
136
159
  /**
137
160
  * Emits a per-entry status line for each name in SHARED_LINKS
138
- * (okGlyph/warnGlyph/failGlyph). A non-symlink blocks sync and FAILs via
139
- * process.exitCode. TOCTOU-safe: lstatSync is wrapped in try/catch so a path
161
+ * (okGlyph/warnGlyph/infoGlyph/failGlyph). A non-symlink blocks sync and FAILs
162
+ * via process.exitCode. TOCTOU-safe: lstatSync is wrapped in try/catch so a path
140
163
  * that vanishes or becomes unreadable between the probe and the stat yields a
141
- * row instead of an unhandled throw that aborts the whole doctor run. A symlink
142
- * whose target cannot be resolved is a WARN (broken-symlink for a missing
143
- * target, target-unreadable otherwise), never a healthy OK, so a dangling or
144
- * unreadable link is not masked.
164
+ * row instead of an unhandled throw that aborts the whole doctor run. Severity
165
+ * keys off whether the repo still has a `shared/<name>` source: an absent or
166
+ * dangling link is a WARN with a `nomad pull` hint when the source exists (a
167
+ * real out-of-sync state), and a calm info note when it does not (nothing to
168
+ * sync). A symlink whose target cannot be resolved is never a healthy OK, so a
169
+ * dangling or unreadable link is not masked.
145
170
  */
146
171
  export function reportSharedLinks(section: DoctorSection): void {
147
172
  for (const name of SHARED_LINKS) {
@@ -70,17 +70,32 @@ function sectionFailed(s: DoctorSection): boolean {
70
70
  * headers with a red `✗ ` glyph (U+2717, same as the per-item FAIL glyph so
71
71
  * `grep -F '✗'` catches both row and header failures), and writes one blank
72
72
  * line between rendered sections (no leading or trailing blank).
73
+ *
74
+ * An empty-string item renders as a true blank line (no tree connector), which
75
+ * lets a reporter set off a footer block (e.g. the `--check-shared` description
76
+ * legend) with vertical whitespace. The `└` connector attaches to the last
77
+ * non-empty item rather than the last array slot so a trailing blank does not
78
+ * strand the elbow on an empty line.
79
+ */
80
+ /**
81
+ * Render one section: a (possibly fail-glyph-prefixed) header followed by its
82
+ * items as a tree. Empty-string items print as true blank lines; the `└` elbow
83
+ * attaches to the last non-empty item so a trailing blank cannot strand it.
73
84
  */
85
+ function renderSection(s: DoctorSection): void {
86
+ const header = sectionFailed(s) ? `${red(FAIL_GLYPH_BARE)} ${s.header}` : s.header;
87
+ console.log(header);
88
+ const lastContent = s.items.reduce((acc, item, j) => (item !== '' ? j : acc), -1);
89
+ for (let j = 0; j < s.items.length; j++) {
90
+ if (s.items[j] === '') console.log('');
91
+ else console.log(`${j === lastContent ? ' └ ' : ' ├ '}${s.items[j]}`);
92
+ }
93
+ }
94
+
74
95
  export function renderDoctor(sections: DoctorSection[]): void {
75
96
  const visible = sections.filter((s) => s.items.length > 0);
76
97
  for (let i = 0; i < visible.length; i++) {
77
98
  if (i > 0) console.log('');
78
- const s = visible[i];
79
- const header = sectionFailed(s) ? `${red(FAIL_GLYPH_BARE)} ${s.header}` : s.header;
80
- console.log(header);
81
- for (let j = 0; j < s.items.length; j++) {
82
- const isLast = j === s.items.length - 1;
83
- console.log(`${isLast ? ' └ ' : ' ├ '}${s.items[j]}`);
84
- }
99
+ renderSection(visible[i]);
85
100
  }
86
101
  }
@@ -62,7 +62,7 @@ export function cmdDoctor(opts: { checkShared?: boolean } = {}): void {
62
62
  reportRebaseClean(repository);
63
63
  reportMirrorActions(repository);
64
64
 
65
- const version = section('Version');
65
+ const version = section('Version Checks');
66
66
  reportVersionCheck(version);
67
67
  reportNodeEngineCheck(version);
68
68
  reportGitleaksVersionCheck(version);
@@ -11,9 +11,9 @@ import { HOME, UPSTREAM_REPO_SLUG } from './config.ts';
11
11
  * Soft, offline-tolerant release-version check appended to `cmdDoctor`. Reads
12
12
  * the local `package.json.version`, compares it to the latest release tag on
13
13
  * the upstream GitHub repo (cached 1h, 3s curl timeout), and emits one of:
14
- * - `✓ version: <local> (latest)` when local == latest
15
- * - `⚠︎ version: <local> -> <latest>` when local < latest
16
- * - `ℹ︎ version: <local> (ahead of latest release <latest>)` when local > latest
14
+ * - `✓ claude-nomad: <local> (latest)` when local == latest
15
+ * - `⚠︎ claude-nomad: <local> -> <latest>` when local < latest
16
+ * - `ℹ︎ claude-nomad: <local> (ahead of latest release <latest>)` when local > latest
17
17
  * Every failure path (offline, curl missing, non-2xx, malformed JSON, missing
18
18
  * `tag_name`, missing/unreadable package.json) is a SILENT skip; this module
19
19
  * never sets `process.exitCode` and never writes to stderr.
@@ -153,9 +153,9 @@ function fetchLatestTag(): string | null {
153
153
  * Emit a single, non-fatal version diagnostic for `nomad doctor` by comparing the local package.json version to the latest upstream release.
154
154
  *
155
155
  * Logs one of:
156
- * - `✓ version: <local> (latest)` when the versions match
157
- * - `⚠︎ version: <local> -> <latest> (run \`nomad update\`)` when the local version is behind
158
- * - `ℹ︎ version: <local> (ahead of latest release <latest>)` when the local version is ahead
156
+ * - `✓ claude-nomad: <local> (latest)` when the versions match
157
+ * - `⚠︎ claude-nomad: <local> -> <latest> (run \`nomad update\`)` when the local version is behind
158
+ * - `ℹ︎ claude-nomad: <local> (ahead of latest release <latest>)` when the local version is ahead
159
159
  *
160
160
  * Any failure to read the local version, retrieve or parse the latest release, or use the cache results in no output and does not change `process.exitCode`.
161
161
  */
@@ -181,10 +181,16 @@ export function reportVersionCheck(section: DoctorSection): void {
181
181
 
182
182
  const cmp = compareSemver(localPure, latest);
183
183
  if (cmp === 0) {
184
- addItem(section, `${green(okGlyph)} version: ${local} (latest)`);
184
+ addItem(section, `${green(okGlyph)} claude-nomad: ${local} (latest)`);
185
185
  } else if (cmp === -1) {
186
- addItem(section, `${yellow(warnGlyph)} version: ${local} -> ${latest} (run \`nomad update\`)`);
186
+ addItem(
187
+ section,
188
+ `${yellow(warnGlyph)} claude-nomad: ${local} -> ${latest} (run \`nomad update\`)`,
189
+ );
187
190
  } else {
188
- addItem(section, `${dim(infoGlyph)} version: ${local} (ahead of latest release ${latest})`);
191
+ addItem(
192
+ section,
193
+ `${dim(infoGlyph)} claude-nomad: ${local} (ahead of latest release ${latest})`,
194
+ );
189
195
  }
190
196
  }
@@ -0,0 +1,43 @@
1
+ import { diffLines } from 'diff';
2
+
3
+ import { green, red } from './color.ts';
4
+
5
+ /**
6
+ * Map a jsdiff `diffLines` result for two pre-stringified JSON strings into
7
+ * an array of unified-diff body lines (the two-line header is the caller's
8
+ * responsibility).
9
+ *
10
+ * Each jsdiff part has a `value` that may span multiple lines and may carry a
11
+ * trailing `\n`. The value is split on `\n` and any trailing empty element
12
+ * (produced by the trailing newline) is dropped so that it does not become a
13
+ * spurious blank output line.
14
+ *
15
+ * Line prefix mapping per part type:
16
+ * - context (neither added nor removed): a single leading space then the line
17
+ * - removed (`part.removed === true`): `red('-' + line)`
18
+ * - added (`part.added === true`): `green('+' + line)`
19
+ *
20
+ * Coloring routes through `color.ts` `red`/`green` helpers, so `NO_COLOR` /
21
+ * non-TTY environments degrade to literal `-` / `+` prefixed lines with no
22
+ * ANSI escape sequences. Picocolors owns the detection logic.
23
+ */
24
+ export function diffLinesToUnified(oldStr: string, newStr: string): string[] {
25
+ const parts = diffLines(oldStr, newStr);
26
+ const lines: string[] = [];
27
+ for (const part of parts) {
28
+ const partLines = part.value.split('\n');
29
+ // A part value ending in '\n' yields a trailing '' after split; drop it.
30
+ if (partLines[partLines.length - 1] === '') {
31
+ partLines.pop();
32
+ }
33
+ const prefix = part.removed
34
+ ? (line: string) => red(`-${line}`)
35
+ : part.added
36
+ ? (line: string) => green(`+${line}`)
37
+ : (line: string) => ` ${line}`;
38
+ for (const line of partLines) {
39
+ lines.push(prefix(line));
40
+ }
41
+ }
42
+ return lines;
43
+ }
package/src/preview.ts CHANGED
@@ -1,53 +1,34 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
 
4
- import { green, red } from './color.ts';
5
4
  import { CLAUDE_HOME, HOST, REPO_HOME } from './config.ts';
5
+ import { diffLinesToUnified } from './diff-lines.ts';
6
6
  import { applySharedLinks } from './links.ts';
7
7
  import { remapPull } from './remap.ts';
8
8
  import { log } from './utils.ts';
9
9
  import { deepMerge, readJson } from './utils.json.ts';
10
10
 
11
11
  /**
12
- * Minimal in-tree unified-diff helper for two pre-stringified JSON
13
- * documents. Walks the line arrays in parallel and emits a unified-diff
14
- * style output: unchanged lines prefixed with a space, removed lines with
15
- * `-` (red), added lines with `+` (green), plus at most three lines of
16
- * surrounding context per changed block. The implementation is intentionally
17
- * naive (no LCS); for two ~50-line settings JSON inputs the result is
18
- * acceptable even if not optimal.
12
+ * LCS line diff for two pre-stringified JSON documents via jsdiff. Returns a
13
+ * unified-diff style string: the two literal header lines
14
+ * `--- ~/.claude/settings.json` and `+++ would write`, followed by body lines
15
+ * where unchanged lines are prefixed with a space, removed lines with `-`
16
+ * (red), and added lines with `+` (green). Coloring routes through `color.ts`
17
+ * so `NO_COLOR` / non-TTY environments degrade to literal prefixes with no
18
+ * ANSI escape sequences.
19
19
  *
20
- * Returns the empty string when the inputs are byte-identical so the caller
21
- * can suppress the section. Picocolors handles `NO_COLOR` / `FORCE_COLOR`
22
- * detection, so the `red`/`green` wrappers degrade to identity in non-TTY
23
- * environments and the output stays literal `-` / `+` prefixed.
24
- *
25
- * The header line `--- ~/.claude/settings.json` / `+++ would write` is
26
- * literal; callers that want a different header can prepend their own.
20
+ * Returns the empty string when inputs are byte-identical so the caller can
21
+ * suppress the section. jsdiff `diffLines` aligns on the longest common
22
+ * subsequence, so a mid-document insertion does not cascade false `-`/`+`
23
+ * pairs for the unchanged tail.
27
24
  */
28
25
  export function diffJsonStrings(currentJsonText: string, newJsonText: string): string {
29
26
  if (currentJsonText === newJsonText) return '';
30
- const a = currentJsonText.split('\n');
31
- const b = newJsonText.split('\n');
32
- const lines: string[] = [];
33
- lines.push('--- ~/.claude/settings.json');
34
- lines.push('+++ would write');
35
-
36
- // Walk both arrays in parallel. Lines that match index-wise are context;
37
- // others are emitted as -a / +b. A real unified-diff would compute the
38
- // longest common subsequence; this naive walk is good enough for two JSON
39
- // documents pretty-printed at the same indentation level.
40
- const maxLen = Math.max(a.length, b.length);
41
- for (let i = 0; i < maxLen; i++) {
42
- const av = a[i];
43
- const bv = b[i];
44
- if (av === bv) {
45
- if (av !== undefined) lines.push(` ${av}`);
46
- continue;
47
- }
48
- if (av !== undefined) lines.push(red(`-${av}`));
49
- if (bv !== undefined) lines.push(green(`+${bv}`));
50
- }
27
+ const lines: string[] = [
28
+ '--- ~/.claude/settings.json',
29
+ '+++ would write',
30
+ ...diffLinesToUnified(currentJsonText, newJsonText),
31
+ ];
51
32
  return lines.join('\n');
52
33
  }
53
34
 
@@ -31,6 +31,13 @@ export type Finding = {
31
31
  StartLine: number;
32
32
  Match: string;
33
33
  Fingerprint: string;
34
+ /**
35
+ * Human-readable rule description gitleaks bakes into every finding (the
36
+ * matched rule's `description` from its toml). Optional: absent on older
37
+ * gitleaks reports or custom rules with no description, in which case the
38
+ * doctor legend silently omits the entry (graceful degradation, no network).
39
+ */
40
+ Description?: string;
34
41
  };
35
42
 
36
43
  /**
package/src/remap.ts CHANGED
@@ -2,7 +2,7 @@ import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, statSync } from 'no
2
2
  import { join, relative, sep } from 'node:path';
3
3
 
4
4
  import { CLAUDE_HOME, HOST, REPO_HOME, type PathMap } from './config.ts';
5
- import { log } from './utils.ts';
5
+ import { die, log } from './utils.ts';
6
6
  import { backupBeforeWrite, backupRepoWrite } from './utils.fs.ts';
7
7
  import { encodePath, readJson } from './utils.json.ts';
8
8
 
@@ -98,17 +98,67 @@ export function remapPull(ts: string, opts: { dryRun?: boolean } = {}): { unmapp
98
98
  return { unmapped };
99
99
  }
100
100
 
101
+ /**
102
+ * Build the encoded-key to logical-name reverse map for the current host,
103
+ * failing closed on any `path-map.json` shape that would silently lose session
104
+ * data on push. Both failure modes `die()` (throw `NomadFatal`) before the
105
+ * caller writes anything:
106
+ *
107
+ * - Encoded-path collision: two distinct host paths that `encodePath` maps to
108
+ * the same key (every `/` becomes `-`), which would clobber each other under
109
+ * one repo directory.
110
+ * - Duplicate path: two logical names mapping to the same host path, where only
111
+ * one logical could be pushed and the other's `shared/projects/` copy would
112
+ * be orphaned.
113
+ *
114
+ * @param map - the parsed `path-map.json`
115
+ * @returns reverse lookup from encoded local dir name to logical project name
116
+ */
117
+ function buildReverseMap(map: PathMap): Map<string, string> {
118
+ const reverse = new Map<string, string>();
119
+ const encodedPaths = new Map<string, string>();
120
+ for (const [logical, hosts] of Object.entries(map.projects)) {
121
+ const p = hosts[HOST];
122
+ if (!p || p === 'TBD') continue;
123
+ const encoded = encodePath(p);
124
+ const prior = encodedPaths.get(encoded);
125
+ if (prior !== undefined) {
126
+ if (prior !== p) {
127
+ die(
128
+ `encoded-path collision in path-map.json: "${prior}" and "${p}" both encode to` +
129
+ ` "${encoded}" (encodePath replaces every / with -).` +
130
+ ` Edit path-map.json so the two paths do not encode identically.` +
131
+ ` Run nomad doctor for the full list of collisions.`,
132
+ );
133
+ }
134
+ die(
135
+ `duplicate path in path-map.json: logical names "${reverse.get(encoded)}" and "${logical}"` +
136
+ ` both map to "${p}" for ${HOST}, so only one could be pushed and the other's` +
137
+ ` shared/projects/ copy would be orphaned.` +
138
+ ` Edit path-map.json so each host path maps to a single logical name.`,
139
+ );
140
+ }
141
+ encodedPaths.set(encoded, p);
142
+ reverse.set(encoded, logical);
143
+ }
144
+ return reverse;
145
+ }
146
+
101
147
  /**
102
148
  * Push: copy local path-encoded dirs back to repo under logical names.
103
149
  *
104
150
  * Returns `{ unmapped: N, collisions: M }` where `unmapped` is the count of
105
151
  * `~/.claude/projects/<dir>/` entries that have no path-map reverse-lookup
106
- * for this host. `collisions` is reserved for a future slice's path-encoding
107
- * collision detection and is always `0` here.
152
+ * for this host. `collisions` is always `0` on the success path: any
153
+ * `path-map.json` shape that would silently lose data (an encoded-path
154
+ * collision between two distinct host paths, or two logical names mapping to
155
+ * the same host path) makes `buildReverseMap` `die()` (throw `NomadFatal`) to
156
+ * refuse the push before any `shared/projects/` content is written. Detection
157
+ * runs during the reverse-map build, so it fires under `dryRun` too.
108
158
  *
109
159
  * `opts.dryRun` (default `false`): when `true`, log `would push:` lines
110
- * instead of calling `backupRepoWrite` + `copyDir`. Counts are computed
111
- * identically in both modes.
160
+ * instead of calling `backupRepoWrite` + `copyDir`. Collision detection
161
+ * runs identically in both modes.
112
162
  */
113
163
  export function remapPush(
114
164
  ts: string,
@@ -116,7 +166,6 @@ export function remapPush(
116
166
  ): { unmapped: number; collisions: number } {
117
167
  const dryRun = opts.dryRun === true;
118
168
  let unmapped = 0;
119
- const collisions = 0;
120
169
  const mapPath = join(REPO_HOME, 'path-map.json');
121
170
  if (!existsSync(mapPath)) {
122
171
  log('no path-map.json; skipping session export');
@@ -126,16 +175,14 @@ export function remapPush(
126
175
  const map = readJson<PathMap>(mapPath);
127
176
  const localProjects = join(CLAUDE_HOME, 'projects');
128
177
  const repoProjects = join(REPO_HOME, 'shared', 'projects');
129
- if (!dryRun) mkdirSync(repoProjects, { recursive: true });
130
178
 
131
- const reverse = new Map<string, string>();
132
- for (const [logical, hosts] of Object.entries(map.projects)) {
133
- const p = hosts[HOST];
134
- if (!p || p === 'TBD') continue;
135
- reverse.set(encodePath(p), logical);
136
- }
179
+ const reverse = buildReverseMap(map);
180
+ if (!existsSync(localProjects)) return { unmapped, collisions: 0 };
181
+ // Create the repo destination only after collision detection passes and we
182
+ // know there is something to push, so a failing or no-op push is fully
183
+ // side-effect-free (no empty shared/projects/ left behind).
184
+ if (!dryRun) mkdirSync(repoProjects, { recursive: true });
137
185
 
138
- if (!existsSync(localProjects)) return { unmapped, collisions };
139
186
  for (const dir of readdirSync(localProjects)) {
140
187
  const logical = reverse.get(dir);
141
188
  if (!logical) {
@@ -156,5 +203,5 @@ export function remapPush(
156
203
  copyDirJsonlOnly(join(localProjects, dir), repoDst);
157
204
  log(`pushed ${dir} -> ${logical}`);
158
205
  }
159
- return { unmapped, collisions };
206
+ return { unmapped, collisions: 0 };
160
207
  }