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 +18 -0
- package/README.md +305 -93
- package/package.json +9 -2
- package/src/commands.doctor.checks.repo.ts +53 -33
- package/src/commands.doctor.mirror-actions.ts +8 -3
- package/src/gh-actions.ts +19 -7
- package/src/init.ts +5 -0
- package/src/nomad.help.ts +51 -21
- package/src/nomad.ts +2 -6
- package/src/preview.ts +42 -42
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
|
[](https://github.com/funkadelic/claude-nomad/actions/workflows/tests.yml)
|
|
4
|
-
[](https://github.com/funkadelic/claude-nomad/actions/workflows/codeql.yml)
|
|
5
|
+
[](https://codecov.io/gh/funkadelic/claude-nomad)
|
|
6
|
+
[](https://www.npmjs.com/package/claude-nomad)
|
|
7
|
+
[](https://www.npmjs.com/package/claude-nomad)
|
|
8
|
+
[](LICENSE)
|
|
6
9
|
|
|
7
10
|

|
|
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
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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),
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
>
|
|
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
|
|
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
|
|
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`
|
|
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
|
-
>
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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).
|
|
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
|
-
>
|
|
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
|
|
213
|
-
|
|
214
|
-
- **
|
|
215
|
-
|
|
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
|
|
220
|
-
|
|
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
|
|
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
|
|
228
|
-
|
|
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
|
|
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
|
|
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
|
|
239
|
-
|
|
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
|
|
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
|
-
>
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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`),
|
|
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
|
|
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
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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):
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 (`✗`)
|
|
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>/`
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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_*`,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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.
|
|
56
|
-
// (init prints a tip here; doctor does not, per the
|
|
57
|
-
|
|
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
|
|
11
|
-
* actionable failure modes so callers
|
|
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
|
|
51
|
-
*
|
|
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
|
|
13
|
-
' --dry-run
|
|
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
|
-
'
|
|
16
|
-
'
|
|
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
|
-
'
|
|
20
|
-
'
|
|
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
|
-
'
|
|
23
|
-
'
|
|
24
|
-
' --
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
'
|
|
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
|
-
'
|
|
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
|
-
// (`[
|
|
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
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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.
|