claude-nomad 0.25.4 → 0.25.5

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,23 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.25.5](https://github.com/funkadelic/claude-nomad/compare/v0.25.4...v0.25.5) (2026-05-27)
4
+
5
+
6
+ ### Fixed
7
+
8
+ * **gh-actions:** distinguish probe errors from not-authed ([#153](https://github.com/funkadelic/claude-nomad/issues/153)) ([14f11df](https://github.com/funkadelic/claude-nomad/commit/14f11df208e7996ddd92ffb79c14cd34707b552c))
9
+
10
+
11
+ ### Changed
12
+
13
+ * **eslint:** gate on cognitive complexity, demote line cap to advisory ([#151](https://github.com/funkadelic/claude-nomad/issues/151)) ([43c8130](https://github.com/funkadelic/claude-nomad/commit/43c81309759247f2bca4b90358bf88667e778724))
14
+ * resolve SonarCloud code-smell findings ([#152](https://github.com/funkadelic/claude-nomad/issues/152)) ([497ab64](https://github.com/funkadelic/claude-nomad/commit/497ab646bd56c11c695957400322c4c802a73b1d))
15
+
16
+
17
+ ### Documentation
18
+
19
+ * lint and wrap Markdown, refresh badges, document --version ([#149](https://github.com/funkadelic/claude-nomad/issues/149)) ([a8b2636](https://github.com/funkadelic/claude-nomad/commit/a8b2636a7e54f0384b5d0d0a931adf29d2a3a8ac))
20
+
3
21
  ## [0.25.4](https://github.com/funkadelic/claude-nomad/compare/v0.25.3...v0.25.4) (2026-05-27)
4
22
 
5
23
 
package/README.md CHANGED
@@ -1,22 +1,40 @@
1
1
  # claude-nomad
2
2
 
3
3
  [![tests](https://img.shields.io/github/actions/workflow/status/funkadelic/claude-nomad/tests.yml?branch=main&label=tests)](https://github.com/funkadelic/claude-nomad/actions/workflows/tests.yml)
4
- [![release](https://img.shields.io/github/v/release/funkadelic/claude-nomad?label=release&sort=semver)](https://github.com/funkadelic/claude-nomad/releases)
5
- [![coverage](https://img.shields.io/codecov/c/github/funkadelic/claude-nomad/main?label=coverage)](https://codecov.io/gh/funkadelic/claude-nomad)
4
+ [![codeql](https://img.shields.io/github/actions/workflow/status/funkadelic/claude-nomad/codeql.yml?branch=main&label=codeql)](https://github.com/funkadelic/claude-nomad/actions/workflows/codeql.yml)
5
+ [![codecov](https://codecov.io/gh/funkadelic/claude-nomad/graph/badge.svg?token=5NML626POS)](https://codecov.io/gh/funkadelic/claude-nomad)
6
+ [![NPM Version](https://img.shields.io/npm/v/claude-nomad?logo=npm)](https://www.npmjs.com/package/claude-nomad)
7
+ [![node](https://img.shields.io/node/v/claude-nomad?logo=nodedotjs)](https://www.npmjs.com/package/claude-nomad)
8
+ [![license](https://img.shields.io/npm/l/claude-nomad)](LICENSE)
6
9
 
7
10
  ![claude-nomad - Sync your Claude Code setup. Same environment. Any machine.](docs/hero.svg)
8
11
 
9
12
  **Your entire Claude Code setup, on every machine. History included, every push secret-scanned.**
10
13
 
11
- Open Claude Code on a second machine and it is a blank slate: none of your custom agents, slash commands, tuned settings, or past conversations. claude-nomad keeps all of it in sync through a private Git repo you control. `nomad push` on one machine, `nomad pull` on the next, and everything is there, conversations included.
12
-
13
- - **Resume your sessions on any machine.** Start a conversation on your desktop and pick it up on your laptop. claude-nomad remaps the file paths Claude Code embeds in every transcript, so your history follows you instead of getting stranded on the box where it started.
14
- - **Secret-scanned, private by default.** Your `~/.claude/` also holds OAuth tokens, MCP credentials, and the full text of every conversation, so claude-nomad is deliberate about what leaves your machine: credentials and ephemeral state never sync, only an explicit allow-list of paths is pushed, and everything that does go up is scanned by [gitleaks](https://github.com/gitleaks/gitleaks) before it leaves your machine; the push aborts on any hit. `nomad init` also disables Actions on your private mirror by default, so transcripts can't leak through CI logs.
15
- - **One setup, every machine.** Your agents, skills, slash commands, and settings live in one place and follow you everywhere. Per-machine tweaks like model choice, MCP URLs, and env vars merge on top instead of clobbering your shared defaults.
16
-
17
- Not dotfiles, not rsync. claude-nomad understands Claude Code's state, so your session history survives different file paths and your secrets never ride along.
18
-
19
- For anyone running Claude Code on more than one machine: a laptop and a desktop, a Mac and a WSL box, a personal rig and a work machine. [Get started in three steps.](#quickstart)
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
16
+ private Git repo you control. `nomad push` on one machine, `nomad pull` on the next, and everything
17
+ is there, conversations included.
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.
22
+ - **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
+ leaves your machine: credentials and ephemeral state never sync, only an explicit allow-list of
25
+ paths is pushed, and everything that does go up is scanned by
26
+ [gitleaks](https://github.com/gitleaks/gitleaks) before it leaves your machine; the push aborts on
27
+ any hit. `nomad init` also disables Actions on your private mirror by default, so transcripts
28
+ can't leak through CI logs.
29
+ - **One setup, every machine.** Your agents, skills, slash commands, and settings live in one place
30
+ and follow you everywhere. Per-machine tweaks like model choice, MCP URLs, and env vars merge on
31
+ top instead of clobbering your shared defaults.
32
+
33
+ Not dotfiles, not rsync. claude-nomad understands Claude Code's state, so your session history
34
+ survives different file paths and your secrets never ride along.
35
+
36
+ For anyone running Claude Code on more than one machine: a laptop and a desktop, a Mac and a WSL
37
+ box, a personal rig and a work machine. [Get started in three steps.](#quickstart)
20
38
 
21
39
  ## Table of contents
22
40
 
@@ -47,7 +65,8 @@ For anyone running Claude Code on more than one machine: a laptop and a desktop,
47
65
 
48
66
  ## Quickstart
49
67
 
50
- If you already have a private claude-nomad mirror (see [Setup](#setup) for the one-time bootstrap), adding a new host is three steps:
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:
51
70
 
52
71
  ```bash
53
72
  $ npm i -g claude-nomad
@@ -73,13 +92,16 @@ $ nomad pull # apply config to ~/.claude/
73
92
  $ nomad push # publish local changes (sessions, settings)
74
93
  ```
75
94
 
76
- First-host bootstrap and the safe-migration sequence for a populated `~/.claude/` are in [Setup](#setup) and [Migrating an existing ~/.claude/](#migrating-an-existing-claude).
95
+ First-host bootstrap and the safe-migration sequence for a populated `~/.claude/` are in
96
+ [Setup](#setup) and [Migrating an existing ~/.claude/](#migrating-an-existing-claude).
77
97
 
78
98
  ## How it works (two-repo model)
79
99
 
80
- claude-nomad is a **tool**, not a config store. You maintain a separate **private** repo that holds your actual config (`CLAUDE.md`, agents, skills, settings overrides, session transcripts). The tool's source and your config end up coexisting in one working tree on each host.
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
102
+ tool's source and your config end up coexisting in one working tree on each host.
81
103
 
82
- ```
104
+ ```text
83
105
  public funkadelic/claude-nomad your private <your-username>/claude-nomad
84
106
  ├── src/ (the CLI) ├── src/ (copy of the CLI)
85
107
  ├── package.json ├── package.json
@@ -96,13 +118,20 @@ public funkadelic/claude-nomad your private <your-username>/claude-noma
96
118
  └── path-map.json
97
119
  ```
98
120
 
99
- You bootstrap once by mirror-pushing this public tool repo into a fresh private repo of your own (see [Setup](#setup)), then layer your config on top. Every host afterward installs the CLI (`npm i -g claude-nomad`), clones your private repo to `~/claude-nomad/`, and runs `nomad pull` to sync.
121
+ You bootstrap once by mirror-pushing this public tool repo into a fresh private repo of your own
122
+ (see [Setup](#setup)), then layer your config on top. Every host afterward installs the CLI
123
+ (`npm i -g claude-nomad`), clones your private repo to `~/claude-nomad/`, and runs `nomad pull` to
124
+ sync.
100
125
 
101
- By default the CLI operates on `~/claude-nomad/` (see `REPO_HOME` in `src/config.ts`). Developers working from an alternate checkout can `export NOMAD_REPO=/path/to/repo` to point the CLI at their working tree without symlink gymnastics; `nomad doctor` surfaces an active override via a trailing `(NOMAD_REPO)` annotation on the repo-state line. Empty `NOMAD_REPO` falls through to the default, so a clobbered dotfile variable does not break the CLI.
126
+ By default the CLI operates on `~/claude-nomad/` (see `REPO_HOME` in `src/config.ts`). Developers
127
+ working from an alternate checkout can `export NOMAD_REPO=/path/to/repo` to point the CLI at their
128
+ working tree without symlink gymnastics; `nomad doctor` surfaces an active override via a trailing
129
+ `(NOMAD_REPO)` annotation on the repo-state line. Empty `NOMAD_REPO` falls through to the default,
130
+ so a clobbered dotfile variable does not break the CLI.
102
131
 
103
132
  ## Repo layout (what `~/claude-nomad/` looks like on a configured host)
104
133
 
105
- ```
134
+ ```text
106
135
  ~/claude-nomad/
107
136
  ├── src/ # the CLI (came from the public tool repo)
108
137
  ├── scripts/ # helper scripts you add
@@ -136,16 +165,21 @@ By default the CLI operates on `~/claude-nomad/` (see `REPO_HOME` in `src/config
136
165
  | **Never synced** | `~/.claude.json` (OAuth, MCP state), `history.jsonl`, `settings.local.json` (per-host overrides), `stats-cache.json`, `todos/`, `shell-snapshots/`, `debug/`, `file-history/`, `plans/`, `session-env/`, `statsig/`, `telemetry/`, `ide/` | Per-host ephemeral state. |
137
166
  | **Auto-rehydrated** | `~/.claude/plugins/cache/<plugin>/...` | Plugin payloads not synced. Claude Code re-downloads them on first use from the `enabledPlugins` list in the regenerated `settings.json`; no manual `claude plugins install ...` per host. |
138
167
 
139
- > [!NOTE]
140
- > Plugins that depend on host-specific state (external binaries, API keys in env, MCP server URLs) still need that side set up on each host. Put them in `hosts/<host>.json` or the plugin's own per-host config.
168
+ > [!NOTE] Plugins that depend on host-specific state (external binaries, API keys in env, MCP server
169
+ > URLs) still need that side set up on each host. Put them in `hosts/<host>.json` or the plugin's
170
+ > own per-host config.
141
171
 
142
- For the rationale behind these choices, see [What does NOT sync (deliberate trade-offs)](#what-does-not-sync-deliberate-trade-offs).
172
+ For the rationale behind these choices, see
173
+ [What does NOT sync (deliberate trade-offs)](#what-does-not-sync-deliberate-trade-offs).
143
174
 
144
175
  ## Path remapping
145
176
 
146
- The hard problem: Claude Code stores sessions in `~/.claude/projects/<encoded-path>/` where the encoded path is the absolute path with `/` replaced by `-`. So the same logical project ends up in different directories on each host.
177
+ The hard problem: Claude Code stores sessions in `~/.claude/projects/<encoded-path>/` where the
178
+ encoded path is the absolute path with `/` replaced by `-`. So the same logical project ends up in
179
+ different directories on each host.
147
180
 
148
- `path-map.json` defines logical names and where the repo lives on each host. The optional `extras` block opts a project into syncing whitelisted directories (or a single root file) at its root:
181
+ `path-map.json` defines logical names and where the repo lives on each host. The optional `extras`
182
+ block opts a project into syncing whitelisted directories (or a single root file) at its root:
149
183
 
150
184
  ```json
151
185
  {
@@ -162,22 +196,43 @@ The hard problem: Claude Code stores sessions in `~/.claude/projects/<encoded-pa
162
196
  }
163
197
  ```
164
198
 
165
- > [!IMPORTANT]
166
- > The host-label keys must match whatever you set `NOMAD_HOST=` to on each host (see [Setup](#setup)). Mismatched labels silently skip remap, so sessions land in the wrong host's encoded dir.
199
+ > [!IMPORTANT] The host-label keys must match whatever you set `NOMAD_HOST=` to on each host (see
200
+ > [Setup](#setup)). Mismatched labels silently skip remap, so sessions land in the wrong host's
201
+ > encoded dir.
167
202
 
168
- Use the literal string `"TBD"` for hosts you haven't onboarded yet; `remapPull` skips TBD entries cleanly instead of creating an orphan `~/.claude/projects/TBD/`. Replace each `"TBD"` with the real path when you bring up that host.
203
+ Use the literal string `"TBD"` for hosts you haven't onboarded yet; `remapPull` skips TBD entries
204
+ cleanly instead of creating an orphan `~/.claude/projects/TBD/`. Replace each `"TBD"` with the real
205
+ path when you bring up that host.
169
206
 
170
- On `push`, sessions in `~/.claude/projects/-Users-you-code-my-example-repo/` get copied to `shared/projects/my-example-repo/`. On `pull` on another machine, they get copied to that host's encoded path. `claude --resume` then finds them (see [What does NOT sync (deliberate trade-offs)](#what-does-not-sync-deliberate-trade-offs) for the cross-OS cwd-binding gotcha).
207
+ 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
210
+ [What does NOT sync (deliberate trade-offs)](#what-does-not-sync-deliberate-trade-offs) for the
211
+ cross-OS cwd-binding gotcha).
171
212
 
172
- The `extras` block is additive and back-compatible: legacy `path-map.json` files without it keep working unchanged. Each value is an array of directory or root-file names (e.g. `.planning`, `CLAUDE.md`) checked against `SUPPORTED_EXTRAS` in `src/config.ts`; anything outside that whitelist is skipped with a log line, so an unrecognized name cannot widen the sync surface.
213
+ The `extras` block is additive and back-compatible: legacy `path-map.json` files without it keep
214
+ working unchanged. Each value is an array of directory or root-file names (e.g. `.planning`,
215
+ `CLAUDE.md`) checked against `SUPPORTED_EXTRAS` in `src/config.ts`; anything outside that whitelist
216
+ is skipped with a log line, so an unrecognized name cannot widen the sync surface.
173
217
 
174
- On `push`, opted-in content at `<localRoot>/<name>` (a directory subtree or a single file) is copied to `shared/extras/<logical>/<name>` and goes through the same staged-tree gitleaks scan as everything else. On `pull`, the reverse copy runs after `git pull --rebase`, and just before it overwrites your working tree a divergence check compares the incoming content against your local copy and prints a per-file WARN naming anything that differs.
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
222
+ copy and prints a per-file WARN naming anything that differs.
175
223
 
176
- Your existing local content is backed up under `~/.cache/claude-nomad/backup/<ts>/extras/` before the pull copy lands, so an unexpected overwrite is always recoverable.
224
+ Your existing local content is backed up under `~/.cache/claude-nomad/backup/<ts>/extras/` before
225
+ the pull copy lands, so an unexpected overwrite is always recoverable.
177
226
 
178
227
  ## Per-host overrides
179
228
 
180
- `settings.base.json` holds portable defaults (model, permissions, plugins). `hosts/<NOMAD_HOST>.json` holds machine-specific patches. They're deep-merged on every pull (scalars override, objects merge recursively, arrays replace). Keys that used to be force-marked per-host because they embedded absolute paths (`statusLine.command`, `hooks`) can live in base if you write the commands with `$HOME` (e.g. `"command": "node \"$HOME/.claude/my-statusline.cjs\""`); Claude Code runs them through a shell so shell expansion applies. Reserve per-host files for truly machine-specific values (env, MCP URLs, host-only model overrides).
229
+ `settings.base.json` holds portable defaults (model, permissions, plugins).
230
+ `hosts/<NOMAD_HOST>.json` holds machine-specific patches. They're deep-merged on every pull (scalars
231
+ 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).
181
236
 
182
237
  `shared/settings.base.json`:
183
238
 
@@ -199,8 +254,9 @@ Your existing local content is backed up under `~/.cache/claude-nomad/backup/<ts
199
254
 
200
255
  Result on that host: opus model, the local Ollama env var, plus the shared permissions array.
201
256
 
202
- > [!CAUTION]
203
- > Never hand-edit `~/.claude/settings.json` on a synced host. It's regenerated on every `nomad pull` from base + host, so your edits will be clobbered. Edit the base or host file in the repo instead.
257
+ > [!CAUTION] Never hand-edit `~/.claude/settings.json` on a synced host. It's regenerated on every
258
+ > `nomad pull` from base + host, so your edits will be clobbered. Edit the base or host file in the
259
+ > repo instead.
204
260
 
205
261
  ## What does NOT sync (deliberate trade-offs)
206
262
 
@@ -209,39 +265,71 @@ Read these before adopting so you opt in with eyes open.
209
265
  - **Last-write-wins on conflicts.** Git surfaces them on merge; no field-level JSON merging.
210
266
  - **Manual push/pull.** No file watcher. Shell hooks recommended.
211
267
  - **OAuth doesn't sync.** You'll log in once per host. Intentional.
212
- - **Only sessions in `path-map.json` are remapped.** Drive-by sessions on un-mapped paths are left alone.
213
- - **Extras are opt-in and whitelisted.** Projects without an `extras` entry in `path-map.json` are unaffected. Names (a directory or a single root file) outside `SUPPORTED_EXTRAS` are skipped with a `skip ... not in SUPPORTED_EXTRAS` log line so an unrecognized name cannot widen the sync surface. Unsafe path-map values (path-traversal in `logical` keys, non-absolute or unnormalized `localRoot` values) abort the run before any file is touched, so a malformed entry fails loudly instead of corrupting state.
214
- - **Cross-OS `claude --resume` cwd binding.** Sessions embed the cwd where they were created, so the picker's `cd ... && claude --resume <id>` line fails on a different host. Use `nomad doctor --resume-cmd <id>` for a host-local equivalent (see [Cross-OS resume](#cross-os-resume)). The sidecar approach preserves transcript byte-equality.
215
- - **Empty directories don't survive sync.** Git doesn't track empty dirs; `nomad doctor` reports them as `missing` (benign). Drop a `.gitkeep` to force materialization.
268
+ - **Only sessions in `path-map.json` are remapped.** Drive-by sessions on un-mapped paths are left
269
+ alone.
270
+ - **Extras are opt-in and whitelisted.** Projects without an `extras` entry in `path-map.json` are
271
+ unaffected. Names (a directory or a single root file) outside `SUPPORTED_EXTRAS` are skipped with
272
+ a `skip ... not in SUPPORTED_EXTRAS` log line so an unrecognized name cannot widen the sync
273
+ surface. Unsafe path-map values (path-traversal in `logical` keys, non-absolute or unnormalized
274
+ `localRoot` values) abort the run before any file is touched, so a malformed entry fails loudly
275
+ 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
+ `nomad doctor --resume-cmd <id>` for a host-local equivalent (see
279
+ [Cross-OS resume](#cross-os-resume)). The sidecar approach preserves transcript byte-equality.
280
+ - **Empty directories don't survive sync.** Git doesn't track empty dirs; `nomad doctor` reports
281
+ them as `missing` (benign). Drop a `.gitkeep` to force materialization.
216
282
 
217
283
  ## Requirements
218
284
 
219
- - Node.js 22.22.1 or newer (24 LTS recommended; the npm `engines` field declares the 22.22.1 floor and surfaces a warning on older runtimes - npm only blocks the install when `engine-strict=true` is configured)
220
- - `tsx` (ships as a runtime dependency of the published package; no separate global install required)
285
+ - Node.js 22.22.1 or newer (24 LTS recommended; the npm `engines` field declares the 22.22.1 floor
286
+ and surfaces a warning on older runtimes - npm only blocks the install when `engine-strict=true`
287
+ is configured)
288
+ - `tsx` (ships as a runtime dependency of the published package; no separate global install
289
+ required)
221
290
  - Git
222
- - [`gitleaks`](https://github.com/gitleaks/gitleaks) (required for `nomad push`, which exits with an error if it is not on PATH; `nomad doctor` also checks it against the pinned 8.30.x and warns when it is absent or mismatched)
291
+ - [`gitleaks`](https://github.com/gitleaks/gitleaks) (required for `nomad push`, which exits with an
292
+ error if it is not on PATH; `nomad doctor` also checks it against the pinned 8.30.x and warns when
293
+ it is absent or mismatched)
223
294
  - A **private** GitHub repo (or any Git remote you control)
224
295
 
225
296
  **Optional:**
226
297
 
227
- - `gh` (GitHub CLI), used only by `nomad init` to auto-disable Actions on the private repo; if it is missing or unauthenticated, init prints a manual fallback tip and continues
228
- - `curl`, used only by the version/update check (the `nomad doctor` latest-release line and the post-`nomad update` check); it degrades silently when curl is absent or offline, so the rest of the CLI works without it
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
229
303
 
230
304
  ## Setup
231
305
 
232
- **Why not just fork?** GitHub doesn't let you flip a public fork to private, and your config (especially session transcripts) must stay private. So the bootstrap is a one-time mirror-push into a fresh private repo, not a fork.
306
+ **Why not just fork?** GitHub doesn't let you flip a public fork to private, and your config
307
+ (especially session transcripts) must stay private. So the bootstrap is a one-time mirror-push into
308
+ a fresh private repo, not a fork.
233
309
 
234
310
  ### Privacy by default
235
311
 
236
- When you mirror-push the tool into your repo, you copy its automation along with its code: the `.github/workflows/` directory holds the public project's own CI (running its test suite, linting, secret and code scanning, release tagging, and npm publishing). That CI is meant for the public project, not your config; if it ran on your private mirror, a job could echo transcript contents into build logs. So your mirror gets two independent layers of defense against that, both applied automatically:
312
+ When you mirror-push the tool into your repo, you copy its automation along with its code: the
313
+ `.github/workflows/` directory holds the public project's own CI (running its test suite, linting,
314
+ secret and code scanning, release tagging, and npm publishing). That CI is meant for the public
315
+ project, not your config; if it ran on your private mirror, a job could echo transcript contents
316
+ into build logs. So your mirror gets two independent layers of defense against that, both applied
317
+ automatically:
237
318
 
238
- 1. **The workflows are written to skip private repos.** Each one carries the run condition `${{ !github.event.repository.private }}` (in plain terms: "run only when this repo is NOT private"), so even with Actions enabled the jobs do not run on your mirror.
239
- 2. **`nomad init` turns Actions off for the whole repo** on first run, via the GitHub API call `gh api -X PUT repos/<owner>/<repo>/actions/permissions -F enabled=false`. This needs the `gh` CLI installed and authed; if it is missing or unauthed, init logs a manual fallback tip and continues.
319
+ 1. **The workflows are written to skip private repos.** Each one carries the run condition
320
+ `${{ !github.event.repository.private }}` (in plain terms: "run only when this repo is NOT
321
+ private"), so even with Actions enabled the jobs do not run on your mirror.
322
+ 2. **`nomad init` turns Actions off for the whole repo** on first run, via the GitHub API call
323
+ `gh api -X PUT repos/<owner>/<repo>/actions/permissions -F enabled=false`. This needs the `gh`
324
+ CLI installed and authed; if it is missing or unauthed, init logs a manual fallback tip and
325
+ continues.
240
326
 
241
- Pass `--keep-actions` to either form of init to skip step 2 (for example, when your org already enforces an Actions policy upstream).
327
+ Pass `--keep-actions` to either form of init to skip step 2 (for example, when your org already
328
+ enforces an Actions policy upstream).
242
329
 
243
- > [!WARNING]
244
- > If you ever flip the mirror to public, both protections evaporate: CI starts firing on every `nomad push` against `main`, and your session transcripts (which include conversation content) become world-readable. Keep it private.
330
+ > [!WARNING] If you ever flip the mirror to public, both protections evaporate: CI starts firing on
331
+ > every `nomad push` against `main`, and your session transcripts (which include conversation
332
+ > content) become world-readable. Keep it private.
245
333
 
246
334
  ### Bootstrap
247
335
 
@@ -267,15 +355,22 @@ $ git clone git@github.com:<your-username>/claude-nomad.git ~/claude-nomad
267
355
  export NOMAD_HOST=<your-host-label> # any short, stable label; nomad reads this instead of os.hostname()
268
356
  ```
269
357
 
270
- `npm i -g claude-nomad` puts a `nomad` binary on your PATH. The bin shim is the existing `src/nomad.ts` entrypoint resolved through tsx (a runtime dependency); no compile step. (The Node version floor and the `engine-strict` caveat are in [Requirements](#requirements).)
358
+ `npm i -g claude-nomad` puts a `nomad` binary on your PATH. The bin shim is the existing
359
+ `src/nomad.ts` entrypoint resolved through tsx (a runtime dependency); no compile step. (The Node
360
+ version floor and the `engine-strict` caveat are in [Requirements](#requirements).)
271
361
 
272
- On every additional host you repeat only steps 3-4; steps 1-2 are already done, since your private repo lives on the remote from step 2.
362
+ On every additional host you repeat only steps 3-4; steps 1-2 are already done, since your private
363
+ repo lives on the remote from step 2.
273
364
 
274
- `NOMAD_HOST` overrides `os.hostname()`, which returns noisy values like `WINDOWS-I5NT6OH` on WSL or `<name>.local` on macOS. Pick a clean label per machine (e.g., `wsl-laptop`, `macbook`, `homelab-nuc`). `nomad doctor` reports the resolved host so you can confirm.
365
+ `NOMAD_HOST` overrides `os.hostname()`, which returns noisy values like `WINDOWS-I5NT6OH` on WSL or
366
+ `<name>.local` on macOS. Pick a clean label per machine (e.g., `wsl-laptop`, `macbook`,
367
+ `homelab-nuc`). `nomad doctor` reports the resolved host so you can confirm.
275
368
 
276
369
  ### Initialize the repo layout
277
370
 
278
- First host only; subsequent hosts just clone and `nomad pull`. Both forms below auto-disable Actions on a detected private GitHub mirror as described in [Privacy by default](#privacy-by-default). Pick one:
371
+ First host only; subsequent hosts just clone and `nomad pull`. Both forms below auto-disable Actions
372
+ on a detected private GitHub mirror as described in [Privacy by default](#privacy-by-default). Pick
373
+ one:
279
374
 
280
375
  ```bash
281
376
  # Fresh start: scaffold an empty shared/, hosts/, path-map.json skeleton.
@@ -290,7 +385,10 @@ $ nomad init --snapshot
290
385
  $ nomad init --keep-actions
291
386
  ```
292
387
 
293
- `nomad init` refuses to clobber existing scaffold artifacts, so re-running on a populated repo is a safe no-op (it errors out naming the offender). `nomad pull` against an unscaffolded repo fails fast with `FATAL: repo not initialized; run 'nomad init' to scaffold` instead of silently leaving a half-state.
388
+ `nomad init` refuses to clobber existing scaffold artifacts, so re-running on a populated repo is a
389
+ safe no-op (it errors out naming the offender). `nomad pull` against an unscaffolded repo fails fast
390
+ with `FATAL: repo not initialized; run 'nomad init' to scaffold` instead of silently leaving a
391
+ half-state.
294
392
 
295
393
  Edit `path-map.json` to add your logical projects (see [Path remapping](#path-remapping)), then:
296
394
 
@@ -302,13 +400,19 @@ $ nomad push # send current state to the private remote
302
400
  $ nomad pull # apply on another host (or this one after a remote update)
303
401
  ```
304
402
 
305
- `nomad pull --dry-run` is the network-aware twin of `nomad diff`: it acquires the lock and runs `git pull` so you see what the next real pull would do given the latest remote, then exits without mutating.
403
+ `nomad pull --dry-run` is the network-aware twin of `nomad diff`: it acquires the lock and runs
404
+ `git pull` so you see what the next real pull would do given the latest remote, then exits without
405
+ mutating.
306
406
 
307
- If the destination host already has populated `~/.claude/{CLAUDE.md, agents/, ...}`, the first `nomad pull` will refuse to overwrite real files. See [Migrating an existing ~/.claude/](#migrating-an-existing-claude) for the safe migration flow.
407
+ If the destination host already has populated `~/.claude/{CLAUDE.md, agents/, ...}`, the first
408
+ `nomad pull` will refuse to overwrite real files. See
409
+ [Migrating an existing ~/.claude/](#migrating-an-existing-claude) for the safe migration flow.
308
410
 
309
411
  ## Migrating an existing ~/.claude/
310
412
 
311
- If a host already has real files at `~/.claude/{CLAUDE.md, agents/, skills/, ...}` and you want to bring them into the sync, the required sequence is `nomad init --snapshot` → `nomad push` → `nomad pull`:
413
+ If a host already has real files at `~/.claude/{CLAUDE.md, agents/, skills/, ...}` and you want to
414
+ bring them into the sync, the required sequence is `nomad init --snapshot` → `nomad push` →
415
+ `nomad pull`:
312
416
 
313
417
  ```bash
314
418
  # From the host that has the canonical config (the originals are not modified):
@@ -319,22 +423,44 @@ $ nomad push # publish the captured state to the private remote
319
423
  $ nomad pull # materializes the symlinks
320
424
  ```
321
425
 
322
- `nomad pull` is what actually migrates the host. `applySharedLinks` runs a two-pass scan: any pre-existing non-symlink at a `SHARED_LINKS` path whose counterpart exists under `shared/` is renamed into `~/.cache/claude-nomad/backup/<ts>/` first, then the symlink is created. Your originals are preserved under that timestamped backup directory, not deleted. Paths whose `shared/<name>` is absent from the remote are left untouched, so a partial publish does not delete data on the destination host.
426
+ `nomad pull` is what actually migrates the host. `applySharedLinks` runs a two-pass scan: any
427
+ pre-existing non-symlink at a `SHARED_LINKS` path whose counterpart exists under `shared/` is
428
+ renamed into `~/.cache/claude-nomad/backup/<ts>/` first, then the symlink is created. Your originals
429
+ are preserved under that timestamped backup directory, not deleted. Paths whose `shared/<name>` is
430
+ absent from the remote are left untouched, so a partial publish does not delete data on the
431
+ destination host.
323
432
 
324
- If the remote has not been populated yet (you skipped `nomad init --snapshot` and `nomad push`), `nomad pull` is a no-op for SHARED_LINKS: there is nothing on the remote to symlink against, so your local `~/.claude/` files stay in place. The auto-move only triggers once the canonical state is published.
433
+ If the remote has not been populated yet (you skipped `nomad init --snapshot` and `nomad push`),
434
+ `nomad pull` is a no-op for SHARED_LINKS: there is nothing on the remote to symlink against, so your
435
+ local `~/.claude/` files stay in place. The auto-move only triggers once the canonical state is
436
+ published.
325
437
 
326
- Prefer an explicit tarball rollback and a confirmation prompt before any deletion? Write the equivalent under `scripts/`: tar the `SHARED_LINKS` entries under `~/.claude/` first, copy into `shared/`, prompt, then `nomad pull`. The auto-move path above is the recommended default.
438
+ Prefer an explicit tarball rollback and a confirmation prompt before any deletion? Write the
439
+ equivalent under `scripts/`: tar the `SHARED_LINKS` entries under `~/.claude/` first, copy into
440
+ `shared/`, prompt, then `nomad pull`. The auto-move path above is the recommended default.
327
441
 
328
442
  ## Upgrading the tool
329
443
 
330
444
  Two different things can fall behind, and they update independently:
331
445
 
332
- - **The `nomad` CLI binary** (what runs when you type `nomad`). If you installed it with `npm i -g claude-nomad`, upgrade it with `npm update -g claude-nomad`. This refreshes only the binary on your PATH; it does not touch anything inside your private `~/claude-nomad/` repo.
333
- - **The synced tool files inside your private repo:** `src/`, `.gitleaks.toml` (the secret-scan allowlist), and the `.github/workflows/` privacy gating. These were copied from the public repo at bootstrap and then froze, so `npm update -g` does not refresh them. `nomad update`, run from `~/claude-nomad/`, is what pulls newer versions of these files in. Topology-aware: detects vanilla vs fork remotes, pulls or merges upstream, and re-runs `npm install` when `package-lock.json` shifted.
334
-
335
- Most people who followed the Quickstart need both: `npm update -g` for the binary, and an occasional `nomad update` for the repo files (notably to receive `.gitleaks.toml` allowlist changes and any update to the privacy gating itself). The mirror-push bootstrap leaves your repo with `origin` on your private mirror and no `upstream` remote; that becomes the "fork" topology `nomad update` expects once you add the upstream remote (the one-time `git remote add upstream ...` step is below).
336
-
337
- Your private repo is not a fork, so GitHub's "Sync fork" UI doesn't apply. The shortcut on a source-checkout host is:
446
+ - **The `nomad` CLI binary** (what runs when you type `nomad`). If you installed it with
447
+ `npm i -g claude-nomad`, upgrade it with `npm update -g claude-nomad`. This refreshes only the
448
+ binary on your PATH; it does not touch anything inside your private `~/claude-nomad/` repo.
449
+ - **The synced tool files inside your private repo:** `src/`, `.gitleaks.toml` (the secret-scan
450
+ allowlist), and the `.github/workflows/` privacy gating. These were copied from the public repo at
451
+ bootstrap and then froze, so `npm update -g` does not refresh them. `nomad update`, run from
452
+ `~/claude-nomad/`, is what pulls newer versions of these files in. Topology-aware: detects vanilla
453
+ vs fork remotes, pulls or merges upstream, and re-runs `npm install` when `package-lock.json`
454
+ shifted.
455
+
456
+ Most people who followed the Quickstart need both: `npm update -g` for the binary, and an occasional
457
+ `nomad update` for the repo files (notably to receive `.gitleaks.toml` allowlist changes and any
458
+ update to the privacy gating itself). The mirror-push bootstrap leaves your repo with `origin` on
459
+ your private mirror and no `upstream` remote; that becomes the "fork" topology `nomad update`
460
+ expects once you add the upstream remote (the one-time `git remote add upstream ...` step is below).
461
+
462
+ Your private repo is not a fork, so GitHub's "Sync fork" UI doesn't apply. The shortcut on a
463
+ source-checkout host is:
338
464
 
339
465
  ```bash
340
466
  $ cd ~/claude-nomad
@@ -344,11 +470,23 @@ $ nomad update
344
470
  `nomad update` detects which layout your `~/claude-nomad/` uses and does the right thing:
345
471
 
346
472
  - **vanilla** (`origin` points at the public repo): `git pull --ff-only origin main`.
347
- - **fork** (`upstream` points at the public repo, `origin` points at your private mirror): `git fetch upstream`, then (before merging) commit any whitelisted `shared/extras/` content that is still untracked locally so an overlap with upstream becomes a normal file merge instead of an untracked-overwrite abort, `git merge upstream/main`, then prompt before pushing the merge to `origin/main`. Pass `--push-origin` to skip the prompt. When the merge is a no-op (HEAD unchanged, nothing new to push) the prompt is skipped entirely and `nomad update` logs `already in sync with origin/main`.
348
-
349
- Pre-flight checks run before any mutation: `REPO_HOME` exists, the topology resolves to `vanilla` or `fork`, the current branch is `main`, the working tree is clean (override with `--force`), and `--push-origin` is rejected on vanilla topology.
350
-
351
- After the merge or pull, `nomad update` re-runs `npm install` only when `package-lock.json` actually shifted, commits the regenerated `package-lock.json` (fork topology) if the reinstall changed it, then invokes `nomad doctor`. The trailing version-check is non-fatal: `✓` when local matches the latest release, `⚠︎` when behind, an informational `ℹ︎ ... ahead of latest release` line when ahead (e.g. a `-dev` build between releases), and silent on network failures.
473
+ - **fork** (`upstream` points at the public repo, `origin` points at your private mirror):
474
+ `git fetch upstream`, then (before merging) commit any whitelisted `shared/extras/` content that
475
+ is still untracked locally so an overlap with upstream becomes a normal file merge instead of an
476
+ untracked-overwrite abort, `git merge upstream/main`, then prompt before pushing the merge to
477
+ `origin/main`. Pass `--push-origin` to skip the prompt. When the merge is a no-op (HEAD unchanged,
478
+ nothing new to push) the prompt is skipped entirely and `nomad update` logs
479
+ `already in sync with origin/main`.
480
+
481
+ Pre-flight checks run before any mutation: `REPO_HOME` exists, the topology resolves to `vanilla` or
482
+ `fork`, the current branch is `main`, the working tree is clean (override with `--force`), and
483
+ `--push-origin` is rejected on vanilla topology.
484
+
485
+ After the merge or pull, `nomad update` re-runs `npm install` only when `package-lock.json` actually
486
+ shifted, commits the regenerated `package-lock.json` (fork topology) if the reinstall changed it,
487
+ then invokes `nomad doctor`. The trailing version-check is non-fatal: `✓` when local matches the
488
+ latest release, `⚠︎` when behind, an informational `ℹ︎ ... ahead of latest release` line when ahead
489
+ (e.g. a `-dev` build between releases), and silent on network failures.
352
490
 
353
491
  Common cases:
354
492
 
@@ -365,9 +503,16 @@ One-time setup if you're running a fork layout and don't have the `upstream` rem
365
503
  $ git remote add upstream git@github.com:funkadelic/claude-nomad.git
366
504
  ```
367
505
 
368
- To pin to a specific release (`vX.Y.Z`, tagged by release-please) instead of tracking `main`, fetch tags from the public repo and check out the tag (detached HEAD). On vanilla topology that's `origin`; on fork topology that's `upstream` (the private mirror at `origin` does not accumulate upstream release tags). Example: `git fetch upstream --tags && git switch --detach vX.Y.Z` (substitute `origin` for vanilla; use `git checkout vX.Y.Z` on older Git).
506
+ To pin to a specific release (`vX.Y.Z`, tagged by release-please) instead of tracking `main`, fetch
507
+ tags from the public repo and check out the tag (detached HEAD). On vanilla topology that's
508
+ `origin`; on fork topology that's `upstream` (the private mirror at `origin` does not accumulate
509
+ upstream release tags). Example: `git fetch upstream --tags && git switch --detach vX.Y.Z`
510
+ (substitute `origin` for vanilla; use `git checkout vX.Y.Z` on older Git).
369
511
 
370
- If you installed an earlier version via `./install.sh` and a shell alias (the pre-npm path), your existing alias keeps working unchanged. Run `npm i -g claude-nomad` whenever you're ready to switch to the global binary, confirm `nomad --version` resolves to the npm install (`which nomad` should point under your npm prefix's `bin/`), then delete the alias line from your shell rc.
512
+ If you installed an earlier version via `./install.sh` and a shell alias (the pre-npm path), your
513
+ existing alias keeps working unchanged. Run `npm i -g claude-nomad` whenever you're ready to switch
514
+ to the global binary, confirm `nomad --version` resolves to the npm install (`which nomad` should
515
+ point under your npm prefix's `bin/`), then delete the alias line from your shell rc.
371
516
 
372
517
  ## Commands
373
518
 
@@ -388,11 +533,23 @@ If you installed an earlier version via `./install.sh` and a shell alias (the pr
388
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). |
389
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. |
390
535
 
391
- The version-check emits ``⚠︎ version: <local> -> <latest> (run `nomad update`)`` when the local install is behind the latest upstream release, and `✓ version: <local> (latest)` when current. It silently skips on network failures.
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.
392
539
 
393
- Two further `⚠︎`-only drift checks run in `nomad doctor`. The gitleaks version-drift line `⚠︎ gitleaks: <local> -> <pinned> (...)` fires when the local gitleaks major.minor differs from the CI-pinned `GITLEAKS_PINNED_VERSION` (gitleaks rule and allowlist behavior tracks the minor line, so a patch-only difference stays `✓`), and is silent when gitleaks is not on PATH. The mirror-Actions line (carrying a `gh api -X PUT repos/<owner>/<repo>/actions/permissions -F enabled=false` remediation hint) fires when origin is a private GitHub mirror that is gh-authed with Actions re-enabled, complementing the auto-disable that runs on `nomad init` (see [Privacy by default](#privacy-by-default)); it is silent on every prerequisite miss (non-GitHub origin, `gh` unauthed, public repo, or Actions already off).
540
+ Two further `⚠︎`-only drift checks run in `nomad doctor`. The gitleaks version-drift line
541
+ `⚠︎ gitleaks: <local> -> <pinned> (...)` fires when the local gitleaks major.minor differs from the
542
+ CI-pinned `GITLEAKS_PINNED_VERSION` (gitleaks rule and allowlist behavior tracks the minor line, so
543
+ a patch-only difference stays `✓`), and is silent when gitleaks is not on PATH. The mirror-Actions
544
+ line (carrying a `gh api -X PUT repos/<owner>/<repo>/actions/permissions -F enabled=false`
545
+ remediation hint) fires when origin is a private GitHub mirror that is gh-authed with Actions
546
+ re-enabled, complementing the auto-disable that runs on `nomad init` (see
547
+ [Privacy by default](#privacy-by-default)); it is silent on every prerequisite miss (non-GitHub
548
+ origin, `gh` unauthed, public repo, or Actions already off).
394
549
 
395
- Every `nomad pull`, `nomad push`, and `nomad diff` run ends with a single `summary:` line. The status glyph (`✓` green / `⚠︎` yellow / `✗` red / `ℹ︎` dim) carries the severity, mirroring `nomad doctor`'s left-gutter format:
550
+ Every `nomad pull`, `nomad push`, and `nomad diff` run ends with a single `summary:` line. The
551
+ status glyph (`✓` green / `⚠︎` yellow / `✗` red / `ℹ︎` dim) carries the severity, mirroring
552
+ `nomad doctor`'s left-gutter format:
396
553
 
397
554
  ```text
398
555
  ✓ summary: clean
@@ -400,32 +557,55 @@ Every `nomad pull`, `nomad push`, and `nomad diff` run ends with a single `summa
400
557
  ⚠︎ summary: 2 unmapped on push, 1 collisions (run nomad doctor to list)
401
558
  ```
402
559
 
403
- `✓` lines go to stdout; `⚠︎` and `✗` lines go to stderr. The summary is suppressed when a fatal (`✗`) fires mid-run so you do not see "summary: clean" stacked under an error. Drive-by projects that have no entry in `path-map.json` for this host count as unmapped; the hint points at `nomad doctor`, which lists them by logical name.
560
+ `✓` lines go to stdout; `⚠︎` and `✗` lines go to stderr. The summary is suppressed when a fatal (`✗`)
561
+ fires mid-run so you do not see "summary: clean" stacked under an error. Drive-by projects that have
562
+ no entry in `path-map.json` for this host count as unmapped; the hint points at `nomad doctor`,
563
+ which lists them by logical name.
404
564
 
405
565
  ## Recovery flows
406
566
 
407
567
  ### `nomad drop-session <id>`
408
568
 
409
- Surgically unstages every `shared/projects/*/<id>.jsonl` plus the sibling `shared/projects/*/<id>/` subagent directory (whose nested transcripts are keyed by the same session id) from the staged tree of `~/claude-nomad/`. The local `~/.claude/projects/<encoded>/<id>.jsonl` and the local `<id>/` tree are never touched.
569
+ Surgically unstages every `shared/projects/*/<id>.jsonl` plus the sibling `shared/projects/*/<id>/`
570
+ subagent directory (whose nested transcripts are keyed by the same session id) from the staged tree
571
+ of `~/claude-nomad/`. The local `~/.claude/projects/<encoded>/<id>.jsonl` and the local `<id>/` tree
572
+ are never touched.
410
573
 
411
574
  ```bash
412
575
  $ nomad drop-session <id>
413
576
  ```
414
577
 
415
- Single positional id (the session filename minus `.jsonl`). Anything else (missing id, leading dash, extra arg) exits 1 with a `usage:` line.
578
+ Single positional id (the session filename minus `.jsonl`). Anything else (missing id, leading dash,
579
+ extra arg) exits 1 with a `usage:` line.
416
580
 
417
- For each match in the staged tree, `cmdDropSession` (in `src/commands.drop-session.ts`) classifies the entry as tracked-in-HEAD vs newly-staged and unstages it via `git restore --staged --worktree --` or `git rm --cached -f --` respectively. The `<id>/` subagent directory is expanded into its staged entries via `git ls-files -z` so every nested transcript flows through the same per-entry classification; a session that has only a subagent directory (no flat `<id>.jsonl`) is still droppable. Idempotent: a second run on the same id sees no matching staged entries and exits 0.
581
+ For each match in the staged tree, `cmdDropSession` (in `src/commands.drop-session.ts`) classifies
582
+ the entry as tracked-in-HEAD vs newly-staged and unstages it via
583
+ `git restore --staged --worktree --` or `git rm --cached -f --` respectively. The `<id>/` subagent
584
+ directory is expanded into its staged entries via `git ls-files -z` so every nested transcript flows
585
+ through the same per-entry classification; a session that has only a subagent directory (no flat
586
+ `<id>.jsonl`) is still droppable. Idempotent: a second run on the same id sees no matching staged
587
+ entries and exits 0.
418
588
 
419
589
  Exit codes:
420
590
 
421
591
  - `0` on any drop, including an idempotent re-run.
422
- - `1` with `✗ no staged session matches <id>` on stderr when neither a `shared/projects/*/<id>.jsonl` nor a `shared/projects/*/<id>/` directory with staged entries matches.
592
+ - `1` with `✗ no staged session matches <id>` on stderr when neither a
593
+ `shared/projects/*/<id>.jsonl` nor a `shared/projects/*/<id>/` directory with staged entries
594
+ matches.
423
595
 
424
- What it does NOT do: touch the local `~/.claude/projects/<encoded>/<id>.jsonl` file or the local `<id>/` subagent tree. The local copies are preserved for `claude --resume`, grep recovery, or whatever the user wants. If the underlying secret is real, scrubbing or removing the local files is REQUIRED for durability, not optional housekeeping: `remapPush` (in `src/remap.ts`) re-mirrors the local content into the staged tree on the next push, so a drop without a local scrub re-stages the same secret.
596
+ What it does NOT do: touch the local `~/.claude/projects/<encoded>/<id>.jsonl` file or the local
597
+ `<id>/` subagent tree. The local copies are preserved for `claude --resume`, grep recovery, or
598
+ whatever the user wants. If the underlying secret is real, scrubbing or removing the local files is
599
+ REQUIRED for durability, not optional housekeeping: `remapPush` (in `src/remap.ts`) re-mirrors the
600
+ local content into the staged tree on the next push, so a drop without a local scrub re-stages the
601
+ same secret.
425
602
 
426
603
  ### Recovery flow: gitleaks FATAL on a session JSONL
427
604
 
428
- `nomad push` runs `gitleaks protect --staged` before commit. To catch the same findings before you push (and without mutating anything), run the read-only preflight `nomad doctor --check-shared`, which stages and scans the exact transcripts a push would publish. When findings live in a session transcript, the push aborts and names every affected session id and the recovery command:
605
+ `nomad push` runs `gitleaks protect --staged` before commit. To catch the same findings before you
606
+ push (and without mutating anything), run the read-only preflight `nomad doctor --check-shared`,
607
+ which stages and scans the exact transcripts a push would publish. When findings live in a session
608
+ transcript, the push aborts and names every affected session id and the recovery command:
429
609
 
430
610
  ```text
431
611
  ✗ gitleaks detected secrets in 1 session transcript(s).
@@ -439,22 +619,38 @@ After recovery, re-run nomad push.
439
619
 
440
620
  Two branches from here:
441
621
 
442
- 1. **Real secret.** Rotate the credential at its provider first (revoke in dashboard, issue replacement) before touching anything else. Running `nomad drop-session <sid-aaaa>` clears the contaminated copy from the current staged tree, but that alone is NOT durable: `remapPush` (in `src/remap.ts`) does a full rm-and-copy mirror of your LOCAL transcripts into `shared/projects/` on every push, so the next `nomad push` re-copies the un-scrubbed local file forward and re-stages the same secret. The durable fix is to rotate AND scrub or remove the local transcript at `~/.claude/projects/<encoded>/<sid-aaaa>.jsonl` (plus the sibling `<sid-aaaa>/` subagent directory under that encoded dir, if present) so the next `remapPush` carries clean content forward. Do not leave the local file un-scrubbed and expect the staged-tree drop to hold.
622
+ 1. **Real secret.** Rotate the credential at its provider first (revoke in dashboard, issue
623
+ replacement) before touching anything else. Running `nomad drop-session <sid-aaaa>` clears the
624
+ contaminated copy from the current staged tree, but that alone is NOT durable: `remapPush` (in
625
+ `src/remap.ts`) does a full rm-and-copy mirror of your LOCAL transcripts into `shared/projects/`
626
+ on every push, so the next `nomad push` re-copies the un-scrubbed local file forward and
627
+ re-stages the same secret. The durable fix is to rotate AND scrub or remove the local transcript
628
+ at `~/.claude/projects/<encoded>/<sid-aaaa>.jsonl` (plus the sibling `<sid-aaaa>/` subagent
629
+ directory under that encoded dir, if present) so the next `remapPush` carries clean content
630
+ forward. Do not leave the local file un-scrubbed and expect the staged-tree drop to hold.
443
631
 
444
- 2. **False positive.** Add an allowlist regex to `.gitleaks.toml` at the repo root that matches the noise pattern but not real-secret formats, commit it, then re-run `nomad push`. The new allowlist propagates to deploy hosts via `nomad update`.
632
+ 2. **False positive.** Add an allowlist regex to `.gitleaks.toml` at the repo root that matches the
633
+ noise pattern but not real-secret formats, commit it, then re-run `nomad push`. The new allowlist
634
+ propagates to deploy hosts via `nomad update`.
445
635
 
446
- `nomad drop-session` only acts on the staged tree of `~/claude-nomad/`. Active Claude Code sessions writing to the local file are not disturbed.
636
+ `nomad drop-session` only acts on the staged tree of `~/claude-nomad/`. Active Claude Code sessions
637
+ writing to the local file are not disturbed.
447
638
 
448
639
  ### `.gitleaks.toml` allowlist policy
449
640
 
450
- `gitleaks protect` runs against the staged tree on every `nomad push` and can flag structurally-distinguishable tool-output noise as `generic-api-key`. The repo-root `.gitleaks.toml` pre-allows four such patterns so routine pushes are not blocked:
641
+ `gitleaks protect` runs against the staged tree on every `nomad push` and can flag
642
+ structurally-distinguishable tool-output noise as `generic-api-key`. The repo-root `.gitleaks.toml`
643
+ pre-allows four such patterns so routine pushes are not blocked:
451
644
 
452
645
  - Sonar issue keys (`AY` prefix + 20+ url-safe chars).
453
646
  - gitleaks fingerprint format (`<context>:<rule>:<line>` emitted by gitleaks's own reports).
454
647
  - npm audit advisory hashes (anchored on the JSON shape `"id":"<40..64 hex>"`).
455
648
  - Coverage-report line-keys (`key=<hex> <path>:<line>`).
456
649
 
457
- The file extends the default gitleaks ruleset, so real high-entropy secrets like `ghp_*`, `sk_live_*`, `xoxb-*`, and `AKIA*` still fire. The allowlist patterns are structurally distinguishable from real-secret formats: a malformed credential cannot match an allowlist regex by accident.
650
+ The file extends the default gitleaks ruleset, so real high-entropy secrets like `ghp_*`,
651
+ `sk_live_*`, `xoxb-*`, and `AKIA*` still fire. The allowlist patterns are structurally
652
+ distinguishable from real-secret formats: a malformed credential cannot match an allowlist regex by
653
+ accident.
458
654
 
459
655
  ```toml
460
656
  [extend]
@@ -469,13 +665,23 @@ regexes = [
469
665
  ]
470
666
  ```
471
667
 
472
- File location: `.gitleaks.toml` at the public repo root (alongside `package.json`). At runtime both `probeGitleaks` (in `src/push-checks.ts`) and `runGitleaksScan` (in `src/push-gitleaks.ts`) conditionally pass `--config <REPO_HOME>/.gitleaks.toml` when the file exists. Hosts that have not yet run `nomad update` (or fresh clones predating the allowlist) fall back silently to the default gitleaks ruleset; there is no warning. Run `nomad update` to receive the latest allowlist.
668
+ File location: `.gitleaks.toml` at the public repo root (alongside `package.json`). At runtime both
669
+ `probeGitleaks` (in `src/push-checks.ts`) and `runGitleaksScan` (in `src/push-gitleaks.ts`)
670
+ conditionally pass `--config <REPO_HOME>/.gitleaks.toml` when the file exists. Hosts that have not
671
+ yet run `nomad update` (or fresh clones predating the allowlist) fall back silently to the default
672
+ gitleaks ruleset; there is no warning. Run `nomad update` to receive the latest allowlist.
473
673
 
474
- Editing: amend `.gitleaks.toml` in this repo, open a PR, and merge to `main`. Use TOML literal strings (triple single quotes, `'''regex'''`) for new regex entries so backslashes do not need escaping. Verify the new pattern does not match real-secret formats (`ghp_<36>`, `sk_live_*`, `xoxb-*`, `AKIA[A-Z0-9]{16}`, etc.) before merging. The propagation path is the same as any other repo update: `nomad update` on each host pulls the new file in.
674
+ Editing: amend `.gitleaks.toml` in this repo, open a PR, and merge to `main`. Use TOML literal
675
+ strings (triple single quotes, `'''regex'''`) for new regex entries so backslashes do not need
676
+ escaping. Verify the new pattern does not match real-secret formats (`ghp_<36>`, `sk_live_*`,
677
+ `xoxb-*`, `AKIA[A-Z0-9]{16}`, etc.) before merging. The propagation path is the same as any other
678
+ repo update: `nomad update` on each host pulls the new file in.
475
679
 
476
680
  ## Cross-OS resume
477
681
 
478
- Claude Code embeds the original `cwd` in each session transcript. When you resume on a different host where that path doesn't exist, the picker prints a `cd <orig-cwd> && claude --resume <id>` line that fails (the source-host path isn't there).
682
+ Claude Code embeds the original `cwd` in each session transcript. When you resume on a different
683
+ host where that path doesn't exist, the picker prints a `cd <orig-cwd> && claude --resume <id>` line
684
+ that fails (the source-host path isn't there).
479
685
 
480
686
  Run this instead:
481
687
 
@@ -489,7 +695,10 @@ Or pipe through bash:
489
695
  $ nomad doctor --resume-cmd <session-id> | bash
490
696
  ```
491
697
 
492
- `nomad doctor --resume-cmd <id>` reads the `.jsonl`'s recorded `cwd`, reverse-looks up the logical project in `path-map.json`, finds your current host's abspath for that logical, and prints `cd <local-abspath> && claude --resume <id>` to stdout. The command is read-only: it never modifies any transcript byte.
698
+ `nomad doctor --resume-cmd <id>` reads the `.jsonl`'s recorded `cwd`, reverse-looks up the logical
699
+ project in `path-map.json`, finds your current host's abspath for that logical, and prints
700
+ `cd <local-abspath> && claude --resume <id>` to stdout. The command is read-only: it never modifies
701
+ any transcript byte.
493
702
 
494
703
  If the session isn't mapped on this host, you'll see:
495
704
 
@@ -497,7 +706,10 @@ If the session isn't mapped on this host, you'll see:
497
706
  ✗ session <id> not mapped on this host; add the logical to path-map.json
498
707
  ```
499
708
 
500
- Other fatal surfaces: missing `~/.claude/projects/`, session id absent from every encoded dir, no `cwd` field anywhere in the transcript, missing `path-map.json`, recorded cwd not present in any logical's host map. All errors go to stderr prefixed with the red `✗` fail glyph; the success line goes to stdout as a bare shell command (no glyph) so `eval` works.
709
+ Other fatal surfaces: missing `~/.claude/projects/`, session id absent from every encoded dir, no
710
+ `cwd` field anywhere in the transcript, missing `path-map.json`, recorded cwd not present in any
711
+ logical's host map. All errors go to stderr prefixed with the red `✗` fail glyph; the success line
712
+ goes to stdout as a bare shell command (no glyph) so `eval` works.
501
713
 
502
714
  ## Run tests
503
715
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-nomad",
3
- "version": "0.25.4",
3
+ "version": "0.25.5",
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": [
@@ -42,6 +42,7 @@
42
42
  "typecheck": "tsc --noEmit",
43
43
  "lint": "eslint .",
44
44
  "lint:fix": "eslint . --fix",
45
+ "lint:md": "markdownlint-cli2",
45
46
  "format": "prettier --write .",
46
47
  "format:check": "prettier --check .",
47
48
  "prepublishOnly": "npm run lint && npm run typecheck && npm run test && node scripts/verify-tarball.cjs",
@@ -52,7 +53,11 @@
52
53
  "eslint --fix",
53
54
  "prettier --write"
54
55
  ],
55
- "*.{js,mjs,cjs,json,md}": [
56
+ "*.{js,mjs,cjs,json}": [
57
+ "prettier --write"
58
+ ],
59
+ "*.md": [
60
+ "markdownlint-cli2 --fix",
56
61
  "prettier --write"
57
62
  ]
58
63
  },
@@ -64,9 +69,11 @@
64
69
  "@vitest/coverage-v8": "^4.1.6",
65
70
  "eslint": "^10.4.0",
66
71
  "eslint-config-prettier": "^10.1.8",
72
+ "eslint-plugin-sonarjs": "^4.0.3",
67
73
  "globals": "^17.6.0",
68
74
  "husky": "^9.1.7",
69
75
  "lint-staged": "^17.0.5",
76
+ "markdownlint-cli2": "^0.22.1",
70
77
  "prettier": "^3.8.3",
71
78
  "typescript": "^6.0.3",
72
79
  "typescript-eslint": "^8.59.4",
@@ -83,6 +83,56 @@ export function reportRepoState(section: DoctorSection): void {
83
83
  }
84
84
  }
85
85
 
86
+ /**
87
+ * Resolve the display item and optional exit-code side-effect for a single
88
+ * shared-link path. Returns `{ line, fail }` where `fail` true means the
89
+ * caller should set `process.exitCode = 1`.
90
+ *
91
+ * Extracted from `reportSharedLinks` to reduce cognitive complexity: the lstat
92
+ * try/catch and the inner symlink-target try/catch each count against the
93
+ * parent function's score.
94
+ */
95
+ function classifySharedLink(name: string, p: string): { line: string; fail: boolean } {
96
+ let stat;
97
+ try {
98
+ stat = lstatSync(p);
99
+ } catch (err) {
100
+ const code = (err as NodeJS.ErrnoException).code;
101
+ if (code === 'ENOENT') {
102
+ return { line: `${yellow(warnGlyph)} ${name}: missing`, fail: false };
103
+ }
104
+ return { line: `${red(failGlyph)} ${name}: could not stat (${String(code)})`, fail: true };
105
+ }
106
+ if (!stat.isSymbolicLink()) {
107
+ return { line: `${red(failGlyph)} ${name}: NOT a symlink (blocks sync)`, fail: true };
108
+ }
109
+ return classifySymlinkTarget(name, p);
110
+ }
111
+
112
+ /**
113
+ * Resolve the display item for a path already confirmed to be a symlink.
114
+ * 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).
116
+ */
117
+ function classifySymlinkTarget(name: string, p: string): { line: string; fail: boolean } {
118
+ try {
119
+ statSync(p);
120
+ return { line: `${green(okGlyph)} ${name}: symlink`, fail: false };
121
+ } catch (err) {
122
+ const code = (err as NodeJS.ErrnoException).code;
123
+ if (code === 'ENOENT') {
124
+ return {
125
+ line: `${yellow(warnGlyph)} ${name}: broken symlink (target missing)`,
126
+ fail: false,
127
+ };
128
+ }
129
+ return {
130
+ line: `${yellow(warnGlyph)} ${name}: symlink target unreadable (${String(code)})`,
131
+ fail: false,
132
+ };
133
+ }
134
+ }
135
+
86
136
  /**
87
137
  * Emits a per-entry status line for each name in SHARED_LINKS
88
138
  * (okGlyph/warnGlyph/failGlyph). A non-symlink blocks sync and FAILs via
@@ -96,38 +146,8 @@ export function reportRepoState(section: DoctorSection): void {
96
146
  export function reportSharedLinks(section: DoctorSection): void {
97
147
  for (const name of SHARED_LINKS) {
98
148
  const p = join(CLAUDE_HOME, name);
99
- let stat;
100
- try {
101
- stat = lstatSync(p);
102
- } catch (err) {
103
- const code = (err as NodeJS.ErrnoException).code;
104
- if (code === 'ENOENT') {
105
- addItem(section, `${yellow(warnGlyph)} ${name}: missing`);
106
- } else {
107
- addItem(section, `${red(failGlyph)} ${name}: could not stat (${String(code)})`);
108
- process.exitCode = 1;
109
- }
110
- continue;
111
- }
112
- if (stat.isSymbolicLink()) {
113
- try {
114
- // statSync follows the link; a throw means the target does not resolve.
115
- statSync(p);
116
- addItem(section, `${green(okGlyph)} ${name}: symlink`);
117
- } catch (err) {
118
- const code = (err as NodeJS.ErrnoException).code;
119
- if (code === 'ENOENT') {
120
- addItem(section, `${yellow(warnGlyph)} ${name}: broken symlink (target missing)`);
121
- } else {
122
- addItem(
123
- section,
124
- `${yellow(warnGlyph)} ${name}: symlink target unreadable (${String(code)})`,
125
- );
126
- }
127
- }
128
- } else {
129
- addItem(section, `${red(failGlyph)} ${name}: NOT a symlink (blocks sync)`);
130
- process.exitCode = 1;
131
- }
149
+ const { line, fail } = classifySharedLink(name, p);
150
+ addItem(section, line);
151
+ if (fail) process.exitCode = 1;
132
152
  }
133
153
  }
@@ -52,9 +52,14 @@ export function reportMirrorActions(section: DoctorSection, run: SpawnSyncFn = e
52
52
  const ref = parseGitHubRemote(remote);
53
53
  if (ref === null) return;
54
54
 
55
- // Gate 3: gh available and authed. Doctor stays silent on both miss reasons
56
- // (init prints a tip here; doctor does not, per the read-only contract).
57
- if (ghAuthStatus(run) !== null) return;
55
+ // Gate 3: gh available and authed. A definitive gh-not-installed / gh-not-authed
56
+ // result is a silent skip (init prints a tip here; doctor does not, per the
57
+ // read-only contract). A gh-probe-error (the auth-status call timed out or
58
+ // hiccuped) is NOT definitive, so fall through: gates 4-5 run their own probes
59
+ // and silently skip if the network is genuinely down, but the drift WARN can
60
+ // still fire when only the auth-status call blipped on an authed host (#124).
61
+ const auth = ghAuthStatus(run);
62
+ if (auth === 'gh-not-installed' || auth === 'gh-not-authed') return;
58
63
 
59
64
  // Gate 4: private mirror. A public repo, or a probe that throws, is a skip.
60
65
  let isPrivate: boolean;
package/src/gh-actions.ts CHANGED
@@ -7,10 +7,13 @@ import { execFileSync, type ExecFileSyncOptions } from 'node:child_process';
7
7
  export type GhRepoRef = { owner: string; repo: string };
8
8
 
9
9
  /**
10
- * Reason `ghAuthStatus` returned without success. Distinguishes the two
11
- * actionable failure modes so callers can print useful tips.
10
+ * Reason `ghAuthStatus` returned without success. Distinguishes three
11
+ * actionable failure modes so callers decide how to treat each:
12
+ * `gh-not-installed` (the binary is missing), `gh-not-authed` (gh ran and
13
+ * reported no authentication), and `gh-probe-error` (the probe itself failed,
14
+ * e.g. a timeout or transient spawn error, so the auth state is unknown).
12
15
  */
13
- export type GhUnavailableReason = 'gh-not-installed' | 'gh-not-authed';
16
+ export type GhUnavailableReason = 'gh-not-installed' | 'gh-not-authed' | 'gh-probe-error';
14
17
 
15
18
  /**
16
19
  * Injectable subprocess runner so tests can mock without `vi.doMock` and
@@ -47,8 +50,16 @@ export function parseGitHubRemote(remoteUrl: string): GhRepoRef | null {
47
50
  /**
48
51
  * Check `gh` CLI availability and auth status in one call. Returns null on
49
52
  * success or a structured reason string. `gh auth status` exits 0 when the
50
- * user is authed against github.com and non-zero otherwise; ENOENT signals
51
- * the binary itself is missing.
53
+ * user is authed against github.com and non-zero otherwise.
54
+ *
55
+ * The catch separates a definitive answer from an indeterminate one so callers
56
+ * are not forced to treat a transient probe failure as "not authed":
57
+ * - `ENOENT`: the binary is missing, so `gh-not-installed`.
58
+ * - the child ran and exited with a numeric code (`typeof status === 'number'`,
59
+ * which by spawnSync semantics means it was not signal-killed): the only
60
+ * definitive unauthenticated answer, so `gh-not-authed`.
61
+ * - anything else (a timeout SIGTERM-kills the child so `status` is null, an
62
+ * `ETIMEDOUT`, a spawn hiccup): the probe itself failed, so `gh-probe-error`.
52
63
  */
53
64
  export function ghAuthStatus(run: SpawnSyncFn = execFileSync): GhUnavailableReason | null {
54
65
  try {
@@ -58,9 +69,10 @@ export function ghAuthStatus(run: SpawnSyncFn = execFileSync): GhUnavailableReas
58
69
  });
59
70
  return null;
60
71
  } catch (err) {
61
- const e = err as { code?: string };
72
+ const e = err as { code?: string; status?: number | null };
62
73
  if (e.code === 'ENOENT') return 'gh-not-installed';
63
- return 'gh-not-authed';
74
+ if (typeof e.status === 'number') return 'gh-not-authed';
75
+ return 'gh-probe-error';
64
76
  }
65
77
  }
66
78
 
package/src/init.ts CHANGED
@@ -159,6 +159,11 @@ function maybeDisableMirrorActions(repoHome: string, run?: SpawnSyncFn): void {
159
159
  );
160
160
  return;
161
161
  }
162
+ // A gh-probe-error (auth-status timed out or hiccuped) is deliberately left to
163
+ // fall through: auth state is unknown, so the privacy probe below tries
164
+ // optimistically with its own catch + tip. This avoids the misleading
165
+ // 'gh auth login' tip a transient failure used to trigger when the user may
166
+ // in fact be authed (#124).
162
167
 
163
168
  let isPrivate: boolean;
164
169
  try {
package/src/nomad.help.ts CHANGED
@@ -5,37 +5,67 @@
5
5
  * cold invocation of `nomad` is self-describing without forcing the user
6
6
  * into the README. Channel is stderr, exit code is 1.
7
7
  */
8
+
9
+ /**
10
+ * Column (0-indexed) at which every command and flag description starts. Sized
11
+ * to clear the longest label (`--resume-cmd <id>`, which ends at column 24)
12
+ * with a two-space gutter. A single constant is what keeps every row aligned;
13
+ * padding lines by hand is how a description drifts out of column.
14
+ */
15
+ const DESC_COL = 26;
16
+
17
+ /**
18
+ * Render a `label` + `desc` help row, padding the label out to DESC_COL so the
19
+ * description lands in the shared column. `padEnd` is a no-op when a label is
20
+ * already at or past the column, so no row can throw or fall out of alignment.
21
+ */
22
+ const row = (label: string, desc: string): string => label.padEnd(DESC_COL) + desc;
23
+
24
+ /**
25
+ * Indent a continuation line (wrapped description text with no label of its
26
+ * own) to DESC_COL so it sits directly under the description column.
27
+ */
28
+ const cont = (text: string): string => ' '.repeat(DESC_COL) + text;
29
+
8
30
  export const DEFAULT_HELP = [
9
31
  'usage: nomad <command> [flags]',
10
32
  '',
11
33
  'Commands:',
12
- ' pull Sync ~/.claude/ from the shared repo (settings, symlinks, sessions).',
13
- ' --dry-run Run lock + git pull, then preview every mutation without writing.',
34
+ row(' pull', 'Sync ~/.claude/ from the shared repo (settings, symlinks, sessions).'),
35
+ row(' --dry-run', 'Run lock + git pull, then preview every mutation without writing.'),
36
+ '',
37
+ row(' push', 'Rebase, run safety checks (gitleaks, gitlinks, allow-list), commit, push.'),
38
+ row(' --dry-run', 'Run pre-checks (rebase, gitleaks probe, gitlink scan) and preview'),
39
+ cont('remap, without staging or pushing.'),
14
40
  '',
15
- ' push Rebase, run safety checks (gitleaks, gitlinks, allow-list), commit, push.',
16
- ' --dry-run Run pre-checks (rebase, gitleaks probe, gitlink scan) and preview',
17
- ' remap, without staging or pushing.',
41
+ row(' diff', 'Offline preview of what `pull` would change against local repo state.'),
42
+ cont('No git pull, no lock acquired.'),
18
43
  '',
19
- ' diff Offline preview of what `pull` would change against local repo state.',
20
- ' No git pull, no lock acquired.',
44
+ row(' init', 'Scaffold an empty ~/claude-nomad/ repo (shared/, hosts/, path-map).'),
45
+ row(' --snapshot', 'Overlay the current ~/.claude/ into shared/ as the initial seed.'),
46
+ row(' --keep-actions', 'Skip auto-disabling GitHub Actions on the private mirror.'),
21
47
  '',
22
- ' init Scaffold an empty ~/claude-nomad/ repo (shared/, hosts/, path-map).',
23
- ' --snapshot Overlay the current ~/.claude/ into shared/ as the initial seed.',
24
- ' --keep-actions Skip auto-disabling GitHub Actions on the private mirror.',
48
+ row(' doctor', 'Read-only health check (symlinks, host file, path-map,'),
49
+ cont('gitleaks, gitlinks).'),
50
+ row(' --check-shared', 'Preflight gitleaks scan of the session transcripts a'),
51
+ cont('`nomad push` would stage (a temp copy, never the live dir).'),
52
+ row(' --resume-cmd <id>', 'Print `cd <abspath> && claude --resume <id>` for a session id'),
53
+ cont('from ~/.claude/projects/.'),
25
54
  '',
26
- ' doctor Read-only health check (symlinks, host file, path-map,',
27
- ' gitleaks, gitlinks).',
28
- ' --check-shared Preflight gitleaks scan of the session transcripts a',
29
- ' `nomad push` would stage (a temp copy, never the live dir).',
30
- ' --resume-cmd <id> Print `cd <abspath> && claude --resume <id>` for a session id',
31
- ' from ~/.claude/projects/.',
55
+ row(
56
+ ' drop-session <id>',
57
+ 'Unstage shared/projects/<logical>/<id>.jsonl from the staged tree (local ~/.claude/projects is never touched).',
58
+ ),
32
59
  '',
33
- ' drop-session <id> Unstage shared/projects/<logical>/<id>.jsonl from the staged tree (local ~/.claude/projects is never touched).',
60
+ row(' update', 'Topology-aware upgrade of ~/claude-nomad/ to the latest upstream.'),
61
+ row(' --dry-run', 'Detect topology + pre-flight, print would-be git commands only.'),
62
+ row(' --force', 'Proceed even when the working tree is not clean.'),
63
+ row(
64
+ ' --push-origin',
65
+ 'Fork topology only: push the merge to origin/main without prompting.',
66
+ ),
34
67
  '',
35
- ' update Topology-aware upgrade of ~/claude-nomad/ to the latest upstream.',
36
- ' --dry-run Detect topology + pre-flight, print would-be git commands only.',
37
- ' --force Proceed even when the working tree is not clean.',
38
- ' --push-origin Fork topology only: push the merge to origin/main without prompting.',
68
+ row(' --version', 'Print the installed CLI version as bare semver to stdout; exits 0.'),
39
69
  '',
40
70
  'Run `nomad doctor` to validate your setup. Edit shared/ or hosts/<HOST>.json',
41
71
  'in the repo, never ~/.claude/settings.json directly (it is regenerated on',
package/src/nomad.ts CHANGED
@@ -154,15 +154,11 @@ try {
154
154
  // Single positional argv; cmdDropSession revalidates id at entry as
155
155
  // defense-in-depth (the function may be called from non-argv paths
156
156
  // in tests). The argv regex mirrors the function-entry allowlist
157
- // (`[A-Za-z0-9_-]`) but additionally rejects ids starting with `-`
157
+ // (`[\w-]`) but additionally rejects ids starting with `-`
158
158
  // so a typo like `nomad drop-session --bogus` shows the usage line,
159
159
  // not a FATAL. The length bound matches cmdDropSession.
160
160
  const id = process.argv[3];
161
- if (
162
- process.argv.length !== 4 ||
163
- typeof id !== 'string' ||
164
- !/^[A-Za-z0-9_][A-Za-z0-9_-]{0,127}$/.test(id)
165
- ) {
161
+ if (process.argv.length !== 4 || typeof id !== 'string' || !/^\w[\w-]{0,127}$/.test(id)) {
166
162
  console.error('usage: nomad drop-session <id>');
167
163
  process.exit(1);
168
164
  }
package/src/preview.ts CHANGED
@@ -66,6 +66,42 @@ function readJsonOrNull(path: string): Record<string, unknown> | null {
66
66
  }
67
67
  }
68
68
 
69
+ /**
70
+ * Emit the settings.json section of the dry-run preview. Reads base, host
71
+ * overrides, and current settings; logs a unified diff or a skip message.
72
+ *
73
+ * Extracted from `computePreview` to reduce cognitive complexity: the nested
74
+ * base-null / malformed-host / malformed-current branches each add score.
75
+ */
76
+ function previewSettings(basePath: string, hostPath: string, settingsPath: string): void {
77
+ const base = readJsonOrNull(basePath);
78
+ if (base === null) {
79
+ log('settings.json: section skipped (base or current missing)');
80
+ return;
81
+ }
82
+ // Tolerate a malformed hosts/<HOST>.json: log once and fall back to no overrides.
83
+ const hostOverrides = readJsonOrNull(hostPath);
84
+ if (hostOverrides === null && existsSync(hostPath)) {
85
+ log(`settings.json: malformed hosts/${HOST}.json; ignoring overrides`);
86
+ }
87
+ const merged = deepMerge(base, hostOverrides ?? {});
88
+ const current = readJsonOrNull(settingsPath);
89
+ if (current === null && existsSync(settingsPath)) {
90
+ log('settings.json: malformed; skipping diff');
91
+ return;
92
+ }
93
+ const diff = diffJsonStrings(
94
+ JSON.stringify(current ?? {}, null, 2),
95
+ JSON.stringify(merged, null, 2),
96
+ );
97
+ if (diff === '') {
98
+ log('settings.json: no changes');
99
+ } else {
100
+ log('settings.json:');
101
+ for (const line of diff.split('\n')) log(line);
102
+ }
103
+ }
104
+
69
105
  /**
70
106
  * Orchestrate the dry-run preview across all three sync modalities:
71
107
  * symlinks (via applySharedLinks dry-run), settings.json (via deepMerge +
@@ -83,7 +119,7 @@ function readJsonOrNull(path: string): Record<string, unknown> | null {
83
119
  * preview may run against a partially-scaffolded repo (e.g. right after a
84
120
  * fresh clone before `nomad init`).
85
121
  *
86
- * Settings diff output goes through `log()` so each line gets the ℹ︎-prefixed
122
+ * Settings diff output goes through `log()` so each line gets the info-prefixed
87
123
  * prefix, keeping output channels consistent across the three sections.
88
124
  */
89
125
  export function computePreview(ts: string): { unmapped: number; collisions: number } {
@@ -93,47 +129,11 @@ export function computePreview(ts: string): { unmapped: number; collisions: numb
93
129
  // lines. dryRun:true is mandatory; a real call here would mutate disk.
94
130
  applySharedLinks(ts, { dryRun: true });
95
131
 
96
- // Settings section: skip-with-log when base or current is missing. Per the
97
- // locked phrasing decision, the message text is fixed so cmdDiff users see
98
- // the same line regardless of which side is missing. Calling
99
- // regenerateSettings(ts, { dryRun: true }) would only emit a generic
100
- // "would write" intent line; we want the unified diff here, so we compute
101
- // it directly from base + host-override + current.
102
- const basePath = join(REPO_HOME, 'shared', 'settings.base.json');
103
- const hostPath = join(REPO_HOME, 'hosts', `${HOST}.json`);
104
- const settingsPath = join(CLAUDE_HOME, 'settings.json');
105
- const base = readJsonOrNull(basePath);
106
- if (base === null) {
107
- // Base is the load-bearing input here. Per the locked phrasing decision,
108
- // emit one canonical message and skip the diff. The current-side missing
109
- // case (no ~/.claude/settings.json) is handled below by treating current
110
- // as `{}` and producing a normal diff; only base-missing is fatal-ish.
111
- log('settings.json: section skipped (base or current missing)');
112
- } else {
113
- // Tolerate a malformed hosts/<HOST>.json the same way base and current
114
- // are tolerated: log once and fall back to no overrides so the preview
115
- // keeps rendering instead of crashing the dry-run.
116
- const hostOverrides = readJsonOrNull(hostPath);
117
- if (hostOverrides === null && existsSync(hostPath)) {
118
- log(`settings.json: malformed hosts/${HOST}.json; ignoring overrides`);
119
- }
120
- const overrides = hostOverrides ?? {};
121
- const merged = deepMerge(base, overrides);
122
- const current = readJsonOrNull(settingsPath);
123
- if (current === null && existsSync(settingsPath)) {
124
- log('settings.json: malformed; skipping diff');
125
- } else {
126
- const currentText = JSON.stringify(current ?? {}, null, 2);
127
- const mergedText = JSON.stringify(merged, null, 2);
128
- const diff = diffJsonStrings(currentText, mergedText);
129
- if (diff === '') {
130
- log('settings.json: no changes');
131
- } else {
132
- log('settings.json:');
133
- for (const line of diff.split('\n')) log(line);
134
- }
135
- }
136
- }
132
+ previewSettings(
133
+ join(REPO_HOME, 'shared', 'settings.base.json'),
134
+ join(REPO_HOME, 'hosts', `${HOST}.json`),
135
+ join(CLAUDE_HOME, 'settings.json'),
136
+ );
137
137
 
138
138
  // Projects: remapPull emits its own would-overwrite lines and returns the
139
139
  // skipped count.