claude-nomad 0.24.0 → 0.25.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +46 -0
- package/README.md +31 -29
- package/package.json +1 -1
- package/src/commands.doctor.check-shared.scan.ts +158 -0
- package/src/commands.doctor.check-shared.ts +58 -189
- package/src/commands.doctor.checks.pathmap.ts +101 -0
- package/src/commands.doctor.checks.repo.ts +101 -0
- package/src/commands.doctor.checks.repository.ts +105 -0
- package/src/commands.doctor.checks.settings.ts +88 -0
- package/src/commands.doctor.format.ts +18 -0
- package/src/commands.doctor.gitleaks-version.ts +132 -0
- package/src/commands.doctor.mirror-actions.ts +83 -0
- package/src/commands.doctor.ts +18 -10
- package/src/commands.drop-session.git.ts +81 -0
- package/src/commands.drop-session.ts +89 -107
- package/src/commands.pull.ts +3 -2
- package/src/commands.push.allowlist.ts +119 -0
- package/src/commands.push.ts +13 -116
- package/src/commands.update.git.ts +90 -0
- package/src/commands.update.resolve.ts +138 -0
- package/src/commands.update.test-helpers.git.ts +107 -0
- package/src/commands.update.ts +31 -223
- package/src/config.ts +23 -10
- package/src/diff.ts +2 -1
- package/src/extras-sync.diff.ts +40 -0
- package/src/extras-sync.guards.ts +52 -0
- package/src/extras-sync.ts +166 -227
- package/src/init.classify.ts +1 -1
- package/src/init.snapshot.ts +3 -1
- package/src/init.ts +2 -1
- package/src/links.ts +3 -10
- package/src/nomad.dispatch.ts +25 -0
- package/src/nomad.help.ts +43 -0
- package/src/nomad.ts +6 -68
- package/src/preview.ts +2 -1
- package/src/push-gitleaks.scan.ts +115 -0
- package/src/push-gitleaks.ts +66 -120
- package/src/remap.ts +3 -1
- package/src/resume.ts +2 -1
- package/src/update.fork-extras.ts +102 -0
- package/src/utils.fs.ts +152 -0
- package/src/utils.json.ts +55 -0
- package/src/utils.lockfile.ts +131 -0
- package/src/utils.ts +23 -330
- package/src/commands.doctor.checks.ts +0 -350
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,51 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.25.1](https://github.com/funkadelic/claude-nomad/compare/v0.25.0...v0.25.1) (2026-05-26)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Fixed
|
|
7
|
+
|
|
8
|
+
* **doctor:** scan --check-shared like push to fix false negatives ([#134](https://github.com/funkadelic/claude-nomad/issues/134)) ([5063028](https://github.com/funkadelic/claude-nomad/commit/5063028c6a695709f7f2d2dfe3cceabedecc17f9))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
* split over-cap source and test files under the ~200-line cap ([#136](https://github.com/funkadelic/claude-nomad/issues/136)) ([0cc7eed](https://github.com/funkadelic/claude-nomad/commit/0cc7eed13abbc85084b04cfc8b686e53b26133c6))
|
|
14
|
+
|
|
15
|
+
## [0.25.0](https://github.com/funkadelic/claude-nomad/compare/v0.24.0...v0.25.0) (2026-05-25)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
* **doctor:** add gitleaks version and mirror-Actions drift checks ([#125](https://github.com/funkadelic/claude-nomad/issues/125)) ([8a16e0c](https://github.com/funkadelic/claude-nomad/commit/8a16e0c274bb7b961c6fd2df9912874c572023ee))
|
|
21
|
+
* **extras:** support a single root file as an extras entry ([#132](https://github.com/funkadelic/claude-nomad/issues/132)) ([6303b62](https://github.com/funkadelic/claude-nomad/commit/6303b62de84406da6a7fa2b7eb7af28a0473d0aa))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
* **drop-session:** cascade unstage into subagent transcript directory ([#120](https://github.com/funkadelic/claude-nomad/issues/120)) ([7f10d51](https://github.com/funkadelic/claude-nomad/commit/7f10d5135a7eaa9b6e74fb1aa863506a7b914a09))
|
|
27
|
+
* **push,update:** handle untracked extras in allow-list and fork merge ([#122](https://github.com/funkadelic/claude-nomad/issues/122)) ([e710a48](https://github.com/funkadelic/claude-nomad/commit/e710a48973a7c4e7479a9671d5891458a182dac7))
|
|
28
|
+
* **update:** skip push prompt when fork merge is a no-op ([#123](https://github.com/funkadelic/claude-nomad/issues/123)) ([77fdb20](https://github.com/funkadelic/claude-nomad/commit/77fdb20244a096e44a1fecd0a283e3401a78972d))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
|
|
33
|
+
* pin workflow actions to SHAs and drop persisted CI credentials ([#130](https://github.com/funkadelic/claude-nomad/issues/130)) ([b60109c](https://github.com/funkadelic/claude-nomad/commit/b60109c0cbb5dff1b13c4155cc12c3d98ce1b141))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
### Documentation
|
|
37
|
+
|
|
38
|
+
* add CONTRIBUTING, PR template, and SECURITY policy ([#131](https://github.com/funkadelic/claude-nomad/issues/131)) ([774eb58](https://github.com/funkadelic/claude-nomad/commit/774eb58a36af032f7d1cd109d59f313c4ff6affa))
|
|
39
|
+
* lead README with a benefit-first pitch ([#133](https://github.com/funkadelic/claude-nomad/issues/133)) ([289e299](https://github.com/funkadelic/claude-nomad/commit/289e2994fd7dffb10818fe02aad64b193ad800b6))
|
|
40
|
+
* **readme:** foreground secret-safety in the intro ([#126](https://github.com/funkadelic/claude-nomad/issues/126)) ([e661e9f](https://github.com/funkadelic/claude-nomad/commit/e661e9f02247fc30630fd90d5facc65aa7b4f2a0))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
### Dependencies
|
|
44
|
+
|
|
45
|
+
* bump SonarSource/sonarqube-scan-action from 8.0.0 to 8.1.0 ([#127](https://github.com/funkadelic/claude-nomad/issues/127)) ([603f20d](https://github.com/funkadelic/claude-nomad/commit/603f20d9a22528c8df19d328c91a02012690836c))
|
|
46
|
+
* bump the dev-dependencies group across 1 directory with 2 updates ([#128](https://github.com/funkadelic/claude-nomad/issues/128)) ([50b1b87](https://github.com/funkadelic/claude-nomad/commit/50b1b87b7288eed60280843504e6ccc2598f10b0))
|
|
47
|
+
* bump tsx from 4.22.2 to 4.22.3 in the prod-dependencies group ([#129](https://github.com/funkadelic/claude-nomad/issues/129)) ([a7ada00](https://github.com/funkadelic/claude-nomad/commit/a7ada000ec2d3dbee132c7b61dc207012e53c1c3))
|
|
48
|
+
|
|
3
49
|
## [0.24.0](https://github.com/funkadelic/claude-nomad/compare/v0.23.0...v0.24.0) (2026-05-24)
|
|
4
50
|
|
|
5
51
|
|
package/README.md
CHANGED
|
@@ -6,17 +6,17 @@
|
|
|
6
6
|
|
|
7
7
|

|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
**Your entire Claude Code setup, on every machine. History included, secrets excluded.**
|
|
10
10
|
|
|
11
|
-
claude-nomad keeps all of it in sync through a private Git repo you control. `nomad push` on one machine, `nomad pull` on
|
|
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
12
|
|
|
13
|
-
**
|
|
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
|
+
- **Private by default.** Your `~/.claude/` also holds OAuth tokens, MCP credentials, and the full text of every conversation. Every push is secret-scanned before it leaves your machine, credentials and ephemeral state never sync, and `nomad init` disables CI on your private mirror by default, so transcripts can't leak through Actions 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.
|
|
14
16
|
|
|
15
|
-
|
|
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.
|
|
16
18
|
|
|
17
|
-
|
|
18
|
-
- **Per-host settings via deep merge.** Shared defaults live in one file; machine-specific overrides (model choice, MCP server URLs, env vars, hooks) live in a per-host file. They're merged on every pull instead of overwriting each other.
|
|
19
|
-
- **Per-project content rides along, opt-in.** Whitelisted directories at a project's root (declared via `path-map.json`'s `extras` field) sync alongside session transcripts, so project-attached state like `.planning/` follows you across hosts. Off by default; projects without an `extras` entry behave exactly as before.
|
|
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)
|
|
20
20
|
|
|
21
21
|
## Table of contents
|
|
22
22
|
|
|
@@ -114,7 +114,7 @@ By default the CLI operates on `~/claude-nomad/` (see `REPO_HOME` in `src/config
|
|
|
114
114
|
│ ├── commands/
|
|
115
115
|
│ ├── rules/
|
|
116
116
|
│ ├── my-statusline.cjs # any script you want symlinked into ~/.claude/
|
|
117
|
-
│ ├── .gitignore # defense-in-depth: blocks .claude.json, settings.local.json, *.token, *.key, .env
|
|
117
|
+
│ ├── .gitignore # defense-in-depth: blocks .claude.json, settings.local.json, *.token, *.key, *.pem, id_rsa, id_ed25519, .env, .env.*
|
|
118
118
|
│ ├── projects/ # session transcripts under logical names
|
|
119
119
|
│ └── extras/ # opt-in per-project content (materializes when path-map.json declares extras)
|
|
120
120
|
├── hosts/
|
|
@@ -127,14 +127,14 @@ By default the CLI operates on `~/claude-nomad/` (see `REPO_HOME` in `src/config
|
|
|
127
127
|
|
|
128
128
|
## What gets synced vs. not
|
|
129
129
|
|
|
130
|
-
| Category | Items | Behavior
|
|
131
|
-
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
132
|
-
| **Synced** | `CLAUDE.md`, `agents/`, `skills/`, `commands/`, `rules/`, `my-statusline.cjs` | Symlinked into `~/.claude/` from `shared/` (see `SHARED_LINKS` in `src/config.ts`).
|
|
133
|
-
| **Generated** | `settings.json` | Deep-merge of `settings.base.json` with `hosts/<hostname>.json`. Rewritten on every pull.
|
|
134
|
-
| **Remapped** | `projects/` session transcripts | Copied with path translation per `path-map.json`.
|
|
135
|
-
| **Per-project extras** | `<localRoot>/.planning/` and other directories whitelisted by `SUPPORTED_EXTRAS` in `src/config.ts`
|
|
136
|
-
| **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
|
-
| **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.
|
|
130
|
+
| Category | Items | Behavior |
|
|
131
|
+
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
132
|
+
| **Synced** | `CLAUDE.md`, `agents/`, `skills/`, `commands/`, `rules/`, `my-statusline.cjs` | Symlinked into `~/.claude/` from `shared/` (see `SHARED_LINKS` in `src/config.ts`). |
|
|
133
|
+
| **Generated** | `settings.json` | Deep-merge of `settings.base.json` with `hosts/<hostname>.json`. Rewritten on every pull. |
|
|
134
|
+
| **Remapped** | `projects/` session transcripts | Copied with path translation per `path-map.json`. |
|
|
135
|
+
| **Per-project extras** | `<localRoot>/.planning/` and other directories, or a single root file like `CLAUDE.md`, whitelisted by `SUPPORTED_EXTRAS` in `src/config.ts` | Opt-in via the `extras` field in `path-map.json`. Mirrored to/from `shared/extras/<logical>/<name>` (directory subtree or single file). Pre-pull divergence WARN flags local edits before they get overwritten. |
|
|
136
|
+
| **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
|
+
| **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
138
|
|
|
139
139
|
> [!NOTE]
|
|
140
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.
|
|
@@ -145,7 +145,7 @@ For the rationale behind these choices, see [What does NOT sync (deliberate trad
|
|
|
145
145
|
|
|
146
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.
|
|
147
147
|
|
|
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 at its root:
|
|
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:
|
|
149
149
|
|
|
150
150
|
```json
|
|
151
151
|
{
|
|
@@ -157,7 +157,7 @@ The hard problem: Claude Code stores sessions in `~/.claude/projects/<encoded-pa
|
|
|
157
157
|
}
|
|
158
158
|
},
|
|
159
159
|
"extras": {
|
|
160
|
-
"ha-acwd": [".planning"]
|
|
160
|
+
"ha-acwd": [".planning", "CLAUDE.md"]
|
|
161
161
|
}
|
|
162
162
|
}
|
|
163
163
|
```
|
|
@@ -169,7 +169,7 @@ Use the literal string `"TBD"` for hosts you haven't onboarded yet; `remapPull`
|
|
|
169
169
|
|
|
170
170
|
On `push`, sessions in `~/.claude/projects/-Users-you-code-ha-acwd/` get copied to `shared/projects/ha-acwd/`. 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).
|
|
171
171
|
|
|
172
|
-
The `extras` block is additive and back-compatible: legacy `path-map.json` files without it continue to work unchanged. Each value is an array of directory names validated against `SUPPORTED_EXTRAS` in `src/config.ts`; values outside the whitelist are skipped with a log line so an unrecognized name cannot widen the sync surface. On `push`, opted-in
|
|
172
|
+
The `extras` block is additive and back-compatible: legacy `path-map.json` files without it continue to work unchanged. Each value is an array of directory or root-file names (e.g. `.planning`, `CLAUDE.md`) validated against `SUPPORTED_EXTRAS` in `src/config.ts`; values outside the whitelist are skipped with a log line so an unrecognized name cannot widen the sync surface. On `push`, opted-in content at `<localRoot>/<name>` (a directory subtree or a single file) is copied to `shared/extras/<logical>/<name>` and inherits the staged-tree gitleaks scan. On `pull`, the reverse copy runs after `git pull --rebase`; just before it overwrites your working tree, a divergence check compares the incoming content against your local copy and emits a per-file WARN naming the diverging files. The existing local content is backed up to `~/.cache/claude-nomad/backup/<ts>/extras/<encoded-localRoot>/<rel>/` before the pull copy lands (`<encoded-localRoot>` is the `localRoot` with `/` rewritten as `-`, so two opted-in projects with the same relative extras path do not collide in one backup run).
|
|
173
173
|
|
|
174
174
|
## Per-host overrides
|
|
175
175
|
|
|
@@ -206,7 +206,7 @@ Read these before adopting so you opt in with eyes open.
|
|
|
206
206
|
- **Manual push/pull.** No file watcher. Shell hooks recommended.
|
|
207
207
|
- **OAuth doesn't sync.** You'll log in once per host. Intentional.
|
|
208
208
|
- **Only sessions in `path-map.json` are remapped.** Drive-by sessions on un-mapped paths are left alone.
|
|
209
|
-
- **Extras are opt-in and whitelisted.** Projects without an `extras` entry in `path-map.json` are unaffected.
|
|
209
|
+
- **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) FATAL before any filesystem mutation via `assertSafeLogical` / `assertSafeLocalRoot` in `src/extras-sync.ts`.
|
|
210
210
|
- **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.
|
|
211
211
|
- **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.
|
|
212
212
|
|
|
@@ -334,9 +334,9 @@ nomad update
|
|
|
334
334
|
`nomad update` (see `cmdUpdate` in `src/commands.update.ts`) detects which layout your `~/claude-nomad/` uses and does the right thing:
|
|
335
335
|
|
|
336
336
|
- **vanilla** (`origin` points at the public repo): `git pull --ff-only origin main`.
|
|
337
|
-
- **fork** (`upstream` points at the public repo, `origin` points at your private mirror): `git fetch upstream`, `git merge upstream/main`, then prompt before pushing the merge to `origin/main`. Pass `--push-origin` to skip the prompt.
|
|
337
|
+
- **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`.
|
|
338
338
|
|
|
339
|
-
Pre-flight checks run before any mutation: `REPO_HOME` exists, topology resolves to `vanilla` or `fork`, current branch is `main`, working tree is clean per `git status --porcelain -z` (override with `--force`), and `--push-origin` is rejected on vanilla topology. After the merge or pull, `nomad update` re-runs `npm install` only when `package-lock.json` actually shifted, 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.
|
|
339
|
+
Pre-flight checks run before any mutation: `REPO_HOME` exists, topology resolves to `vanilla` or `fork`, current branch is `main`, working tree is clean per `git status --porcelain -z` (override with `--force`), and `--push-origin` is rejected on vanilla topology. 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.
|
|
340
340
|
|
|
341
341
|
Common cases:
|
|
342
342
|
|
|
@@ -369,15 +369,17 @@ If you installed an earlier version via `./install.sh` and a shell alias (the pr
|
|
|
369
369
|
| `nomad diff` | Offline, lockless twin of `pull --dry-run`. No network, no lock. Works against the current local repo state. |
|
|
370
370
|
| `nomad push` | Export local sessions and opted-in per-project extras to logical names, commit (`chore: sync from <NOMAD_HOST>`), push. |
|
|
371
371
|
| `nomad push --dry-run` | Run pre-push safety checks (gitleaks probe, rebase, remap preview, gitlink scan, allow-list); skip stage, scan, commit, and push. |
|
|
372
|
-
| `nomad drop-session <id>` | Surgically unstage every `shared/projects/*/<id>.jsonl` from the staged tree of `~/claude-nomad/`. Idempotent; the local `~/.claude/projects/<encoded>/<id>.jsonl`
|
|
372
|
+
| `nomad drop-session <id>` | Surgically unstage every `shared/projects/*/<id>.jsonl` and the sibling `shared/projects/*/<id>/` subagent directory from the staged tree of `~/claude-nomad/`. Idempotent; the local `~/.claude/projects/<encoded>/<id>.jsonl` and `<id>/` tree are preserved. See [Recovery flows](#recovery-flows). |
|
|
373
373
|
| `nomad update` | Topology-aware upgrade to the latest upstream. Flags: `--dry-run`, `--force`, `--push-origin`. See [Upgrading the tool](#upgrading-the-tool). |
|
|
374
|
-
| `nomad doctor` | Read-only health check. Each line carries a status glyph (`✓` pass, `✗` fail, `⚠︎` warn); any `✗` sets `process.exitCode = 1` (`⚠︎` does not). Includes an offline-tolerant release-version staleness check.
|
|
374
|
+
| `nomad doctor` | Read-only health check. Each line carries a status glyph (`✓` pass, `✗` fail, `⚠︎` warn); any `✗` sets `process.exitCode = 1` (`⚠︎` does not). Includes an offline-tolerant release-version staleness check plus two `⚠︎`-only drift checks: gitleaks version drift and, on a private GitHub mirror, re-enabled Actions. |
|
|
375
375
|
| `nomad doctor --resume-cmd <id>` | Print a host-local `cd ... && claude --resume <id>` line for a session (see [Cross-OS resume](#cross-os-resume)). |
|
|
376
376
|
| `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). |
|
|
377
377
|
| `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. |
|
|
378
378
|
|
|
379
379
|
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.
|
|
380
380
|
|
|
381
|
+
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).
|
|
382
|
+
|
|
381
383
|
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:
|
|
382
384
|
|
|
383
385
|
```text
|
|
@@ -392,7 +394,7 @@ Every `nomad pull`, `nomad push`, and `nomad diff` run ends with a single `summa
|
|
|
392
394
|
|
|
393
395
|
### `nomad drop-session <id>`
|
|
394
396
|
|
|
395
|
-
Surgically unstages every `shared/projects/*/<id>.jsonl` from the staged tree of `~/claude-nomad/`. The local `~/.claude/projects/<encoded>/<id>.jsonl`
|
|
397
|
+
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.
|
|
396
398
|
|
|
397
399
|
```bash
|
|
398
400
|
nomad drop-session <id>
|
|
@@ -400,14 +402,14 @@ nomad drop-session <id>
|
|
|
400
402
|
|
|
401
403
|
Single positional id (the session filename minus `.jsonl`). Anything else (missing id, leading dash, extra arg) exits 1 with a `usage:` line.
|
|
402
404
|
|
|
403
|
-
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. Idempotent: a second run on the same id sees no matching staged
|
|
405
|
+
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.
|
|
404
406
|
|
|
405
407
|
Exit codes:
|
|
406
408
|
|
|
407
409
|
- `0` on any drop, including an idempotent re-run.
|
|
408
|
-
- `1` with `✗ no staged session matches <id>` on stderr when
|
|
410
|
+
- `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.
|
|
409
411
|
|
|
410
|
-
What it does NOT do: touch the local `~/.claude/projects/<encoded>/<id>.jsonl` file. The local
|
|
412
|
+
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.
|
|
411
413
|
|
|
412
414
|
### Recovery flow: gitleaks FATAL on a session JSONL
|
|
413
415
|
|
|
@@ -425,7 +427,7 @@ After recovery, re-run nomad push.
|
|
|
425
427
|
|
|
426
428
|
Two branches from here:
|
|
427
429
|
|
|
428
|
-
1. **Real secret.** Rotate the credential at its provider (revoke in dashboard, issue replacement)
|
|
430
|
+
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.
|
|
429
431
|
|
|
430
432
|
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`.
|
|
431
433
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scan-result classification and row emission for the `nomad doctor
|
|
3
|
+
* --check-shared` preflight. Split out of `commands.doctor.check-shared.ts` to
|
|
4
|
+
* keep both files under the line cap; `reportCheckShared` (the public reporter)
|
|
5
|
+
* stays in the sibling and calls `scanAndReport` after staging the temp tree.
|
|
6
|
+
*
|
|
7
|
+
* Owns the post-stage block: run the shared `scanStagedTree` (the same git
|
|
8
|
+
* init + add + `gitleaks protect --staged` mechanism push uses), classify the
|
|
9
|
+
* findings via `partitionFindings`, and emit the doctor glyph rows (clean,
|
|
10
|
+
* per-session leak with rotate-and-scrub guidance, and the nested "other"
|
|
11
|
+
* bucket). All external work flows through `scanStagedTree`; this module spawns
|
|
12
|
+
* nothing itself.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
|
|
17
|
+
import { green, red, okGlyph, failGlyph } from './color.ts';
|
|
18
|
+
import { addItem, type DoctorSection } from './commands.doctor.format.ts';
|
|
19
|
+
import { CLAUDE_HOME } from './config.ts';
|
|
20
|
+
import { type Finding, partitionFindings, scanStagedTree } from './push-gitleaks.ts';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Recover the absolute live transcript path
|
|
24
|
+
* `~/.claude/projects/<encoded>/<sid>.jsonl` by mapping the finding's
|
|
25
|
+
* `<logical>` through the staging association, falling back to the logical name
|
|
26
|
+
* when the association is missing (defensive; the temp-tree build guarantees a hit).
|
|
27
|
+
*/
|
|
28
|
+
function scrubPath(logical: string, sid: string, logicalToEncoded: Map<string, string>): string {
|
|
29
|
+
/* c8 ignore next -- the `?? logical` fallback is defensive; the temp-tree build keys every staged logical */
|
|
30
|
+
const encoded = logicalToEncoded.get(logical) ?? logical;
|
|
31
|
+
return join(CLAUDE_HOME, 'projects', encoded, `${sid}.jsonl`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Emit one fail row per affected session plus rotate-and-scrub + allowlist
|
|
36
|
+
* guidance, and set `process.exitCode = 1`. `logicalBySession` carries the
|
|
37
|
+
* `<logical>` captured from the same match that keyed `bySession`, so the
|
|
38
|
+
* scrub-path hint reuses the authoritative parse. The hint guard omits a row
|
|
39
|
+
* rather than print a wrong path if the invariant ever breaks; the leak row is
|
|
40
|
+
* always emitted.
|
|
41
|
+
*/
|
|
42
|
+
function reportSessionFindings(
|
|
43
|
+
section: DoctorSection,
|
|
44
|
+
bySession: Map<string, Map<string, number>>,
|
|
45
|
+
logicalBySession: Map<string, string>,
|
|
46
|
+
logicalToEncoded: Map<string, string>,
|
|
47
|
+
): void {
|
|
48
|
+
for (const [sid, counts] of bySession) {
|
|
49
|
+
const summary = [...counts.entries()].map(([rule, n]) => `${rule} (${n})`).join(', ');
|
|
50
|
+
addItem(section, `${red(failGlyph)} session ${sid}: ${summary}`);
|
|
51
|
+
const logical = logicalBySession.get(sid);
|
|
52
|
+
/* c8 ignore next -- false branch is defensive; every bySession sid is keyed in logicalBySession */
|
|
53
|
+
if (logical !== undefined) {
|
|
54
|
+
addItem(
|
|
55
|
+
section,
|
|
56
|
+
` rotate the credential, then scrub ${scrubPath(logical, sid, logicalToEncoded)}`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
addItem(section, ` false positive? add a pattern to .gitleaks.toml`);
|
|
60
|
+
}
|
|
61
|
+
process.exitCode = 1;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Emit one fail row per non-session ("other"-bucket) finding and set
|
|
66
|
+
* `process.exitCode = 1`. These are findings whose `File` did not match the
|
|
67
|
+
* flat `SESSION_PATH` shape (nested transcripts under `subagents/`, `memory/`,
|
|
68
|
+
* which `nomad push` would still stage). Names the repo-relative path and
|
|
69
|
+
* RuleID only, never the matched secret.
|
|
70
|
+
*/
|
|
71
|
+
function reportOtherFindings(section: DoctorSection, other: Finding[]): void {
|
|
72
|
+
for (const f of other) {
|
|
73
|
+
addItem(section, `${red(failGlyph)} leak in ${f.File}: ${f.RuleID}`);
|
|
74
|
+
}
|
|
75
|
+
process.exitCode = 1;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Captures both the `<logical>` segment and the `<sid>` from a repo-relative
|
|
80
|
+
* `shared/projects/<logical>/<sid>.jsonl` path. The session-id group matches
|
|
81
|
+
* the exported `SESSION_PATH` shape; the `<logical>` group lets the scrub-path
|
|
82
|
+
* hint reuse this single authoritative parse.
|
|
83
|
+
*/
|
|
84
|
+
const SESSION_PATH_LOGICAL = /^shared\/projects\/([^/]+)\/([^/]+)\.jsonl$/;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Emit the single canonical clean row reporting the scanned-project count
|
|
88
|
+
* (`staged` is the number of mapped project dirs staged, not a transcript
|
|
89
|
+
* total). Centralizing the literal keeps every clean path (zero-staged,
|
|
90
|
+
* scanned-clean, findings-but-no-`other`) phrased consistently.
|
|
91
|
+
*/
|
|
92
|
+
export function emitClean(section: DoctorSection, staged: number): void {
|
|
93
|
+
addItem(section, `${green(okGlyph)} ${staged} project(s) scanned, no leaks`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Build the `sid -> <logical>` association from the findings, capturing both
|
|
98
|
+
* groups from the same `SESSION_PATH_LOGICAL` match so the scrub-path hint
|
|
99
|
+
* never re-derives the logical name. First match per sid wins (the scrub path
|
|
100
|
+
* is per session, not per finding).
|
|
101
|
+
*/
|
|
102
|
+
function buildLogicalBySession(findings: Finding[]): Map<string, string> {
|
|
103
|
+
const logicalBySession = new Map<string, string>();
|
|
104
|
+
for (const f of findings) {
|
|
105
|
+
const m = SESSION_PATH_LOGICAL.exec(f.File);
|
|
106
|
+
if (m?.[2] !== undefined && !logicalBySession.has(m[2])) {
|
|
107
|
+
/* c8 ignore next -- `?? ''` is defensive; group 1 is always captured when the match succeeds */
|
|
108
|
+
logicalBySession.set(m[2], m[1] ?? '');
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return logicalBySession;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Scan the staged temp tree through the shared `scanStagedTree` and emit the
|
|
116
|
+
* result rows. Isolates the deepest nesting from `reportCheckShared`: the scan
|
|
117
|
+
* try/catch (failure -> fail row + exit 1, carrying `err.message` only, never
|
|
118
|
+
* stderr/stdout), the unparseable `findings === null` branch, `partitionFindings`,
|
|
119
|
+
* and the clean / `other` / `bySession` rows. BOTH buckets gate the clean row: a
|
|
120
|
+
* finding in `other` (nested transcripts matching neither the flat `SESSION_PATH`
|
|
121
|
+
* nor any session) is still a stageable secret push would catch, so a
|
|
122
|
+
* `bySession`-only gate would make the preflight weaker than the push scan.
|
|
123
|
+
*/
|
|
124
|
+
export function scanAndReport(
|
|
125
|
+
section: DoctorSection,
|
|
126
|
+
tmpRoot: string,
|
|
127
|
+
staged: number,
|
|
128
|
+
logicalToEncoded: Map<string, string>,
|
|
129
|
+
): void {
|
|
130
|
+
let findings: Finding[] | null;
|
|
131
|
+
try {
|
|
132
|
+
findings = scanStagedTree(tmpRoot);
|
|
133
|
+
} catch (err) {
|
|
134
|
+
// ENOENT (binary vanished mid-flow) or a git failure. The top-of-flow probe
|
|
135
|
+
// WARN-skips a truly missing gitleaks; this catch reports a scan-failed FAIL
|
|
136
|
+
// row with err.message only (never stderr/stdout, which can echo
|
|
137
|
+
// redacted-but-sensitive scan output).
|
|
138
|
+
addItem(section, `${red(failGlyph)} scan failed: ${(err as Error).message}`);
|
|
139
|
+
process.exitCode = 1;
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (findings === null) {
|
|
143
|
+
// Non-zero gitleaks exit with no parseable report. Carry no stream output,
|
|
144
|
+
// matching runGitleaksScan on the push side.
|
|
145
|
+
addItem(section, `${red(failGlyph)} scan failed: no parseable gitleaks report`);
|
|
146
|
+
process.exitCode = 1;
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const { bySession, other } = partitionFindings(findings);
|
|
150
|
+
if (bySession.size === 0 && other.length === 0) {
|
|
151
|
+
emitClean(section, staged);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (other.length > 0) reportOtherFindings(section, other);
|
|
155
|
+
if (bySession.size > 0) {
|
|
156
|
+
reportSessionFindings(section, bySession, buildLogicalBySession(findings), logicalToEncoded);
|
|
157
|
+
}
|
|
158
|
+
}
|