claude-nomad 0.28.0 → 0.29.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.29.1](https://github.com/funkadelic/claude-nomad/compare/v0.29.0...v0.29.1) (2026-05-29)
4
+
5
+
6
+ ### Fixed
7
+
8
+ * **doctor:** degrade gitleaks-absent probe to WARN, not FAIL ([#173](https://github.com/funkadelic/claude-nomad/issues/173)) ([320bb8a](https://github.com/funkadelic/claude-nomad/commit/320bb8a5d6f6c1f02207be8e186fe678f8e6f8bd))
9
+
10
+ ## [0.29.0](https://github.com/funkadelic/claude-nomad/compare/v0.28.0...v0.29.0) (2026-05-29)
11
+
12
+
13
+ ### Added
14
+
15
+ * sync hook scripts and tool support dirs across hosts ([#171](https://github.com/funkadelic/claude-nomad/issues/171)) ([e340fd2](https://github.com/funkadelic/claude-nomad/commit/e340fd221882f9107f4e10c3a64ccd7be4061a14))
16
+
3
17
  ## [0.28.0](https://github.com/funkadelic/claude-nomad/compare/v0.27.0...v0.28.0) (2026-05-28)
4
18
 
5
19
 
package/README.md CHANGED
@@ -45,6 +45,7 @@ box, a personal rig and a work machine. [Get started in three steps.](#quickstar
45
45
  - [Repo layout](#repo-layout-what-claude-nomad-looks-like-on-a-configured-host)
46
46
  - [What gets synced vs. not](#what-gets-synced-vs-not)
47
47
  - [Path remapping](#path-remapping)
48
+ - [Shared support dirs (sharedDirs)](#shared-support-dirs-shareddirs)
48
49
  - [Per-host overrides](#per-host-overrides)
49
50
  - [What does NOT sync (deliberate trade-offs)](#what-does-not-sync-deliberate-trade-offs)
50
51
  - **Getting started**
@@ -113,6 +114,7 @@ public funkadelic/claude-nomad your private <your-username>/claude-noma
113
114
  │ ├── skills/
114
115
  │ ├── commands/
115
116
  │ ├── rules/
117
+ │ ├── hooks/
116
118
  │ ├── settings.base.json
117
119
  │ └── projects/
118
120
  ├── hosts/<hostname>.json
@@ -143,6 +145,7 @@ so a clobbered dotfile variable does not break the CLI.
143
145
  │ ├── skills/
144
146
  │ ├── commands/
145
147
  │ ├── rules/
148
+ │ ├── hooks/ # hook scripts, symlinked into ~/.claude/hooks/
146
149
  │ ├── my-statusline.cjs # any script you want symlinked into ~/.claude/
147
150
  │ ├── .gitignore # defense-in-depth: blocks .claude.json, settings.local.json, *.token, *.key, *.pem, id_rsa, id_ed25519, .env, .env.*
148
151
  │ ├── projects/ # session transcripts under logical names
@@ -157,22 +160,29 @@ so a clobbered dotfile variable does not break the CLI.
157
160
 
158
161
  ## What gets synced vs. not
159
162
 
160
- | Category | Items | Behavior |
161
- | ---------------------- | ----------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
162
- | **Synced** | `CLAUDE.md`, `agents/`, `skills/`, `commands/`, `rules/`, `my-statusline.cjs` | Symlinked into `~/.claude/` from `shared/`. |
163
- | **Generated** | `settings.json` | Deep-merge of `settings.base.json` with `hosts/<hostname>.json`; rewritten every pull. |
164
- | **Remapped** | `projects/` session transcripts | Copied with path translation per `path-map.json`. |
165
- | **Per-project extras** | Whitelisted dirs like `.planning/`, or a root file like `CLAUDE.md` | Opt-in via the `extras` field in `path-map.json`; mirrored to/from `shared/extras/<logical>/`. |
166
- | **Never synced** | OAuth and MCP state, shell history, per-host overrides, caches, scratch dirs | Per-host ephemeral state; left untouched in both directions. |
167
- | **Auto-rehydrated** | `~/.claude/plugins/cache/<plugin>/...` | Re-downloaded by Claude Code from the `enabledPlugins` list; no per-host install. |
163
+ | Category | Items | Behavior |
164
+ | ----------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
165
+ | **Synced** | `CLAUDE.md`, `agents/`, `skills/`, `commands/`, `rules/`, `hooks/`, `my-statusline.cjs` | Symlinked into `~/.claude/` from `shared/`. |
166
+ | **Generated** | `settings.json` | Deep-merge of `settings.base.json` with `hosts/<hostname>.json`; rewritten every pull. |
167
+ | **Remapped** | `projects/` session transcripts | Copied with path translation per `path-map.json`. |
168
+ | **Per-project extras** | Whitelisted dirs like `.planning/`, or a root file like `CLAUDE.md` | Opt-in via the `extras` field in `path-map.json`; mirrored to/from `shared/extras/<logical>/`. |
169
+ | **Shared support dirs** | Opt-in global `~/.claude/` dirs like a tool's `get-shit-done/` | Opt-in via the `sharedDirs` field in `path-map.json`; symlinked into `~/.claude/` from `shared/`. See [Shared support dirs](#shared-support-dirs-shareddirs). |
170
+ | **Never synced** | OAuth and MCP state, shell history, per-host overrides, caches, scratch dirs | Per-host ephemeral state; left untouched in both directions. |
171
+ | **Auto-rehydrated** | `~/.claude/plugins/cache/<plugin>/...` | Re-downloaded by Claude Code from the `enabledPlugins` list; no per-host install. |
168
172
 
169
173
  Pointers and specifics:
170
174
 
171
- - **Synced** link names live in `SHARED_LINKS`, **whitelisted extras** names in `SUPPORTED_EXTRAS`,
172
- and the full **never-synced** set in `NEVER_SYNC` (all in `src/config.ts`).
173
- - **Never synced**, in full: `~/.claude.json` (OAuth, MCP state), `history.jsonl`,
174
- `settings.local.json` (per-host overrides), `stats-cache.json`, `todos/`, `shell-snapshots/`,
175
- `debug/`, `file-history/`, `plans/`, `session-env/`, `statsig/`, `telemetry/`, `ide/`.
175
+ - **Synced** link names live in `SHARED_LINKS` (and the optional `sharedDirs` field in
176
+ `path-map.json` -- see [Shared support dirs](#shared-support-dirs-shareddirs)), **whitelisted
177
+ extras** names in `SUPPORTED_EXTRAS`, and the full **never-synced** set in `NEVER_SYNC` (all in
178
+ `src/config.ts`).
179
+ - **Never synced**, in full: `~/.claude.json` (OAuth, MCP state), `.credentials.json` (OAuth
180
+ credential store), `history.jsonl`, `settings.local.json` (per-host overrides),
181
+ `stats-cache.json`, `todos/`, `shell-snapshots/`, `debug/`, `file-history/`, `plans/`,
182
+ `session-env/`, `statsig/`, `telemetry/`, `ide/`, plus host-local caches and runtime state
183
+ (`cache/`, `backups/`, `paste-cache/`, `daemon/`, `jobs/`, `tasks/`, `security/`, `sessions/`).
184
+ This set is also the deny-list the `sharedDirs` opt-in is checked against, so one of these names
185
+ cannot be symlinked into the shared repo by mistake.
176
186
  - **Per-project extras** run a pre-pull divergence WARN that flags local edits before they get
177
187
  overwritten.
178
188
 
@@ -239,6 +249,55 @@ copy and prints a per-file WARN naming anything that differs.
239
249
  Your existing local content is backed up under `~/.cache/claude-nomad/backup/<ts>/extras/` before
240
250
  the pull copy lands, so an unexpected overwrite is always recoverable.
241
251
 
252
+ ## Shared support dirs (sharedDirs)
253
+
254
+ Some tools install a `hooks` block into `settings.json` whose commands point at scripts under
255
+ `~/.claude/hooks/` (and sometimes a support directory such as `~/.claude/get-shit-done/`). Because
256
+ `settings.json` is regenerated on every pull, that hook configuration travels to every host, but the
257
+ scripts it points at did not, so hooks broke on a freshly configured host. `~/.claude/hooks/` is now
258
+ a built-in synced link (it rides the same symlink model as `skills/` and `agents/`), so hook scripts
259
+ travel automatically.
260
+
261
+ For any other global `~/.claude/` support directory a tool needs, the optional top-level
262
+ `sharedDirs` field in `path-map.json` opts it into the same symlink sync:
263
+
264
+ ```json
265
+ {
266
+ "projects": {
267
+ "my-example-repo": {
268
+ "<your-mac>": "/Users/you/code/my-example-repo"
269
+ }
270
+ },
271
+ "sharedDirs": ["get-shit-done"]
272
+ }
273
+ ```
274
+
275
+ What this means for you: each listed name is symlinked from `shared/<name>` into `~/.claude/<name>`
276
+ (the same model as the built-in synced links, not a copy), so editing it on any host updates the one
277
+ shared copy. The field is additive and back-compatible: a `path-map.json` without it behaves exactly
278
+ as before.
279
+
280
+ Entries are validated before anything is linked. A name is accepted only if it is a single path
281
+ segment (no `/`, no `..`), is not one of the never-synced names, and does not collide with a
282
+ reserved `shared/` name (`settings.base.json`, the built-in synced links, `hooks`, `hosts`,
283
+ `path-map.json`). An invalid entry is dropped with a warning rather than aborting the run. The
284
+ contents still go through the same gitleaks scan as everything else on push, so do not point
285
+ `sharedDirs` at a directory that holds credentials.
286
+
287
+ First-time setup on an already-configured repo: a symlink can only form once the directory exists
288
+ under `shared/`. On a fresh repo `nomad init --snapshot` handles this for you. To add `hooks/` (or a
289
+ new `sharedDirs` entry) to a repo that is already set up, move it into `shared/` once on the host
290
+ that has it, then let the normal flow take over:
291
+
292
+ ```bash
293
+ $ mv ~/.claude/hooks ~/claude-nomad/shared/hooks # one-time, on the source host
294
+ $ nomad pull # re-creates ~/.claude/hooks as a symlink
295
+ $ nomad push # shares it with your other hosts
296
+ ```
297
+
298
+ `nomad pull` never writes back to the remote, so it will not seed `shared/` for you; the one-time
299
+ move is deliberate.
300
+
242
301
  ## Per-host overrides
243
302
 
244
303
  `settings.base.json` holds portable defaults (model, permissions, plugins).
@@ -540,28 +599,35 @@ point under your npm prefix's `bin/`), then delete the alias line from your shel
540
599
 
541
600
  ## Commands
542
601
 
543
- | Command | Description |
544
- | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
545
- | `nomad init` | Scaffold empty `shared/`, `hosts/`, `path-map.json` on a fresh clone. Refuses to clobber existing scaffold. Auto-disables Actions on a detected private GitHub mirror (see [Privacy by default](#privacy-by-default)). |
546
- | `nomad init --snapshot` | Overlay current host's `~/.claude/` into `shared/` and write `~/.claude/settings.json` verbatim into `hosts/<NOMAD_HOST>.json`. Originals not modified. Same auto-disable behavior as `nomad init`. |
547
- | `nomad init --keep-actions` | Skip the auto-disable. Combinable with `--snapshot`. Use when an upstream org policy already governs Actions, or you intentionally want CI on the private mirror. |
548
- | `nomad pull` | `git pull --rebase --autostash`, apply symlinks, regenerate `settings.json`, remap session paths, and pull opted-in per-project extras. Errors out if scaffold missing. |
549
- | `nomad pull --dry-run` | Network-aware preview: acquire lock + `git pull --rebase`, print planned changes (symlink moves, `settings.json` diff, transcript overwrites), exit without writing. |
550
- | `nomad diff` | Offline, lockless twin of `pull --dry-run`. No network, no lock. Works against the current local repo state. |
551
- | `nomad push` | Export local sessions and opted-in per-project extras to logical names, commit (`chore: sync from <NOMAD_HOST>`), push. |
552
- | `nomad push --dry-run` | Run pre-push safety checks (gitleaks probe, rebase, remap preview, gitlink scan, allow-list) and a read-only gitleaks leak preview over a throwaway temp copy of the sessions and extras this host would stage; skip stage, commit, and push. Exits 1 if a leak is found in the preview. Nothing is written to the sync repo. |
553
- | `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). |
554
- | `nomad update` | Topology-aware upgrade to the latest upstream. Flags: `--dry-run`, `--force`, `--push-origin`. See [Upgrading the tool](#upgrading-the-tool). |
555
- | `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. |
556
- | `nomad doctor --resume-cmd <id>` | Print a host-local `cd ... && claude --resume <id>` line for a session (see [Cross-OS resume](#cross-os-resume)). |
557
- | `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). |
558
- | `nomad doctor --check-schema` | Read-only: fetches the live Claude Code settings schema and lists any `~/.claude/settings.json` key absent from it (candidates for the hand-maintained `APP_ONLY_KEYS` list). Non-fatal and offline-tolerant: skips with a `⚠︎` when curl is missing or the schema is unreachable. |
559
- | `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. |
602
+ | Command | Description |
603
+ | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
604
+ | `nomad init` | Scaffold empty `shared/`, `hosts/`, `path-map.json` on a fresh clone. Refuses to clobber existing scaffold. Auto-disables Actions on a detected private GitHub mirror (see [Privacy by default](#privacy-by-default)). |
605
+ | `nomad init --snapshot` | Overlay current host's `~/.claude/` into `shared/` and write `~/.claude/settings.json` verbatim into `hosts/<NOMAD_HOST>.json`. Originals not modified. Same auto-disable behavior as `nomad init`. |
606
+ | `nomad init --keep-actions` | Skip the auto-disable. Combinable with `--snapshot`. Use when an upstream org policy already governs Actions, or you intentionally want CI on the private mirror. |
607
+ | `nomad pull` | `git pull --rebase --autostash`, apply symlinks, regenerate `settings.json`, remap session paths, and pull opted-in per-project extras. Errors out if scaffold missing. |
608
+ | `nomad pull --dry-run` | Network-aware preview: acquire lock + `git pull --rebase`, print planned changes (symlink moves, `settings.json` diff, transcript overwrites), exit without writing. |
609
+ | `nomad diff` | Offline, lockless twin of `pull --dry-run`. No network, no lock. Works against the current local repo state. |
610
+ | `nomad push` | Export local sessions and opted-in per-project extras to logical names, commit (`chore: sync from <NOMAD_HOST>`), push. |
611
+ | `nomad push --dry-run` | Run pre-push safety checks (gitleaks probe, rebase, remap preview, gitlink scan, allow-list) and a read-only gitleaks leak preview over a throwaway temp copy of the sessions and extras this host would stage; skip stage, commit, and push. Exits 1 if a leak is found in the preview. Nothing is written to the sync repo. |
612
+ | `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). |
613
+ | `nomad update` | Topology-aware upgrade to the latest upstream. Flags: `--dry-run`, `--force`, `--push-origin`. See [Upgrading the tool](#upgrading-the-tool). |
614
+ | `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, a Hook targets check that fails (`✗`, exit 1) when `settings.json` references a hook command whose script under `~/.claude/` is missing on this host, plus two `⚠︎`-only drift checks: gitleaks version drift and, on a private GitHub mirror, re-enabled Actions. |
615
+ | `nomad doctor --resume-cmd <id>` | Print a host-local `cd ... && claude --resume <id>` line for a session (see [Cross-OS resume](#cross-os-resume)). |
616
+ | `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). |
617
+ | `nomad doctor --check-schema` | Read-only: fetches the live Claude Code settings schema and lists any `~/.claude/settings.json` key absent from it (candidates for the hand-maintained `APP_ONLY_KEYS` list). Non-fatal and offline-tolerant: skips with a `⚠︎` when curl is missing or the schema is unreachable. |
618
+ | `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. |
560
619
 
561
620
  The version-check emits ``⚠︎ claude-nomad: <local> -> <latest> (run `nomad update`)`` when the local
562
621
  install is behind the latest upstream release, and `✓ claude-nomad: <local> (latest)` when current.
563
622
  It silently skips on network failures.
564
623
 
624
+ The Hook targets check reads the live `~/.claude/settings.json` `hooks` block and fails (`✗`, exit
625
+
626
+ 1. when a hook command points at a script under `~/.claude/` that is missing on this host (the
627
+ freshly-configured-host symptom that motivated syncing `hooks/`). It deliberately skips any
628
+ command it cannot resolve to a `~/.claude/` path (bare binaries like `jq`, unresolved env vars),
629
+ so it never false-fails on a command that does not reference a local script.
630
+
565
631
  Two further `⚠︎`-only drift checks run in `nomad doctor`. The gitleaks version-drift line
566
632
  `⚠︎ gitleaks: <local> -> <pinned> (...)` fires when the local gitleaks major.minor differs from the
567
633
  CI-pinned `GITLEAKS_PINNED_VERSION` (gitleaks rule and allowlist behavior tracks the minor line, so
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-nomad",
3
- "version": "0.28.0",
3
+ "version": "0.29.1",
4
4
  "type": "module",
5
5
  "description": "Sync Claude Code config (~/.claude/) across machines via a private Git repo, with path remapping and per-host settings overrides.",
6
6
  "keywords": [
@@ -83,10 +83,10 @@ function reportRemediation(
83
83
  const logical = logicalBySession.get(sid);
84
84
  /* c8 ignore next -- false branch is defensive; every bySession sid is keyed in logicalBySession */
85
85
  if (logical !== undefined) {
86
- addItem(
87
- section,
88
- ` ${dim(`- rotate the credential, then scrub ${scrubPath(logical, sid, logicalToEncoded)}`)}`,
86
+ const rotateLine = dim(
87
+ `- rotate the credential, then scrub ${scrubPath(logical, sid, logicalToEncoded)}`,
89
88
  );
89
+ addItem(section, ` ${rotateLine}`);
90
90
  }
91
91
  }
92
92
  addItem(section, ` ${dim('- false positive? add a pattern to .gitleaks.toml')}`);
@@ -147,7 +147,8 @@ function emitDescriptionLegend(section: DoctorSection, findings: Finding[]): voi
147
147
  addItem(section, '');
148
148
  addItem(section, bold('Finding types'));
149
149
  for (const [rule, desc] of descByRule) {
150
- addItem(section, ` ${red(`- [${rule}]`)}: ${dim(desc)}`);
150
+ const ruleLabel = red(`- [${rule}]`);
151
+ addItem(section, ` ${ruleLabel}: ${dim(desc)}`);
151
152
  }
152
153
  }
153
154
 
@@ -0,0 +1,176 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ import { dim, failGlyph, green, infoGlyph, okGlyph, red } from './color.ts';
5
+ import { addItem, readJsonSafe, type DoctorSection } from './commands.doctor.format.ts';
6
+ import { CLAUDE_HOME, HOME } from './config.ts';
7
+
8
+ /**
9
+ * Always-on `nomad doctor` reporter. Reads `~/.claude/settings.json`, walks
10
+ * every `{ type: "command", command }` entry in the `hooks` block, and FAILs
11
+ * with `process.exitCode = 1` for each command token that confidently resolves
12
+ * to a path under `~/.claude` but is missing on disk. Commands with no
13
+ * resolvable `~/.claude` path (bare binaries, unresolved env vars) are silently
14
+ * skipped per D-09: the check only surfaces the issue-#170 case of synced hook
15
+ * config pointing at unsynced local scripts.
16
+ */
17
+
18
+ /**
19
+ * Expand a leading `~`, `$HOME`, or `${HOME}` to the resolved HOME directory so
20
+ * the resulting path can be passed to `existsSync` and compared against the
21
+ * absolute `~/.claude` location. A token with no home-relative prefix (a bare
22
+ * binary, an already-absolute path) is returned unchanged.
23
+ *
24
+ * @param token - A raw path token extracted from a hook command string.
25
+ * @returns The path with any leading home-relative syntax resolved to HOME.
26
+ */
27
+ function expandHome(token: string): string {
28
+ return token
29
+ .replace(/^\$\{HOME\}/, HOME)
30
+ .replace(/^\$HOME/, HOME)
31
+ .replace(/^~/, HOME);
32
+ }
33
+
34
+ /**
35
+ * Strip shell quoting and trailing control punctuation from a raw command
36
+ * token so a real path is not mistaken for a missing one. Without this, a
37
+ * quoted compound command like `bash -c 'a.sh; ~/.claude/hooks/run.sh'` yields
38
+ * the token `~/.claude/hooks/run.sh'` (trailing quote), and `existsSync` would
39
+ * FAIL on a script that is actually present (a D-09 false-FAIL). Removes
40
+ * leading quotes and any trailing run of `'"`;)|&>` characters. A genuine path
41
+ * never carries these on its boundary, so stripping them is safe.
42
+ *
43
+ * @param token - A raw whitespace-delimited token from a command segment.
44
+ * @returns The token with boundary shell punctuation removed.
45
+ */
46
+ function stripShellPunctuation(token: string): string {
47
+ return token.replace(/^['"]+/, '').replace(/['"`;)|&>]+$/, '');
48
+ }
49
+
50
+ /**
51
+ * Yield every command token that resolves to a path under `~/.claude`. Each
52
+ * `&&`-, `||`-, `;`-, or `|`-separated sub-command is scanned token by token
53
+ * (not just its leading word), so wrappers like `bash ~/.claude/hooks/run.sh`
54
+ * are caught and not just `~/.claude/hooks/run.sh` on its own. Every token is
55
+ * stripped of shell quoting and home-expanded before comparison, so the
56
+ * literal `~`, `$HOME`, and `${HOME}` forms collapse to one check. Tokens that
57
+ * do not resolve under `~/.claude` (bare binaries, flags, unresolved env vars)
58
+ * are skipped per D-09, so the check only ever FAILs on a real `~/.claude`
59
+ * target.
60
+ *
61
+ * @param command - The raw `command` string from a hook entry.
62
+ * @returns Iterable of absolute resolved paths under `~/.claude`.
63
+ */
64
+ function* claudePathsIn(command: string): Iterable<string> {
65
+ const claudePrefix = `${CLAUDE_HOME}/`;
66
+ for (const segment of command.split(/&&|\|\||;|\|/)) {
67
+ for (const raw of segment.trim().split(/\s+/).filter(Boolean)) {
68
+ const expanded = expandHome(stripShellPunctuation(raw));
69
+ if (expanded.startsWith(claudePrefix)) yield expanded;
70
+ }
71
+ }
72
+ }
73
+
74
+ /**
75
+ * A hook entry in flat format: `{ type: "command"; command: string }`.
76
+ * Used internally by `commandsFromFlat` to narrow the parsed JSON shape.
77
+ */
78
+ type FlatEntry = { type: unknown; command?: unknown };
79
+
80
+ /**
81
+ * Yield command strings from a flat-format entry list (each element is
82
+ * directly `{ type: "command", command: string }`). Skips non-object and
83
+ * non-command entries silently (T-25-07 defence).
84
+ *
85
+ * @param entries - Array of flat hook entries to walk.
86
+ */
87
+ function* commandsFromFlat(entries: unknown[]): Iterable<string> {
88
+ for (const entry of entries) {
89
+ if (typeof entry !== 'object' || entry === null) continue;
90
+ const e = entry as FlatEntry;
91
+ if (e.type === 'command' && typeof e.command === 'string') yield e.command;
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Yield every `{ type: "command"; command: string }` entry from a single
97
+ * hook group, which may be a flat array entry or a grouped object with a
98
+ * nested `hooks` array. Non-object / non-command entries are silently skipped
99
+ * (D-09 / T-25-07 defence: malformed input degrades to skips, never throws).
100
+ *
101
+ * @param group - One element of a hooks event array.
102
+ * @returns Iterable of command strings from command-type entries.
103
+ */
104
+ function* commandsFromGroup(group: unknown): Iterable<string> {
105
+ if (typeof group !== 'object' || group === null) return;
106
+ const g = group as Record<string, unknown>;
107
+ // Grouped shape: { matcher?, hooks: HookEntry[] }
108
+ if (Array.isArray(g.hooks)) {
109
+ yield* commandsFromFlat(g.hooks);
110
+ return;
111
+ }
112
+ // Flat shape: the group itself is { type: "command", command: string }
113
+ if (g.type === 'command' && typeof g.command === 'string') yield g.command;
114
+ }
115
+
116
+ /**
117
+ * Walk all hook groups for a single event and emit FAIL items for every
118
+ * resolved-but-missing `~/.claude` target. Returns true when at least one
119
+ * FAIL was emitted (used by the caller to suppress the OK summary line).
120
+ *
121
+ * @param section - Doctor section to append items to.
122
+ * @param event - Hook event name (e.g. `PostToolUse`).
123
+ * @param groups - Array of hook groups for this event.
124
+ * @returns True when any missing target was found.
125
+ */
126
+ function checkEventGroups(section: DoctorSection, event: string, groups: unknown[]): boolean {
127
+ let anyFail = false;
128
+ for (const group of groups) {
129
+ for (const cmd of commandsFromGroup(group)) {
130
+ for (const resolved of claudePathsIn(cmd)) {
131
+ if (existsSync(resolved)) continue;
132
+ addItem(section, `${red(failGlyph)} hooks/${event}: command target missing: ${resolved}`);
133
+ process.exitCode = 1;
134
+ anyFail = true;
135
+ }
136
+ }
137
+ }
138
+ return anyFail;
139
+ }
140
+
141
+ /**
142
+ * Append the Hook-targets check result to the supplied section. Reads
143
+ * `~/.claude/settings.json`, walks every command entry in the `hooks` block,
144
+ * and emits a `✗` FAIL for each `~/.claude` target that is absent on disk.
145
+ * Commands with no resolvable local path are silently skipped (D-09).
146
+ * Emits a `✓` OK line when all resolvable targets exist (or none were found).
147
+ * Emits a `ℹ︎` info skip when `settings.json` is absent.
148
+ *
149
+ * @param section - The doctor section to append items to.
150
+ */
151
+ export function reportHooksTargetCheck(section: DoctorSection): void {
152
+ const settingsPath = join(CLAUDE_HOME, 'settings.json');
153
+ if (!existsSync(settingsPath)) {
154
+ addItem(section, `${dim(infoGlyph)} no ~/.claude/settings.json; skipping hook target check`);
155
+ return;
156
+ }
157
+
158
+ const settings = readJsonSafe<Record<string, unknown>>(settingsPath, settingsPath, section);
159
+ if (settings === null) return;
160
+
161
+ const hooks = settings.hooks;
162
+ if (typeof hooks !== 'object' || hooks === null || Array.isArray(hooks)) {
163
+ addItem(section, `${green(okGlyph)} hooks: all command targets present`);
164
+ return;
165
+ }
166
+
167
+ let anyFail = false;
168
+ for (const [event, groups] of Object.entries(hooks as Record<string, unknown>)) {
169
+ if (!Array.isArray(groups)) continue;
170
+ if (checkEventGroups(section, event, groups)) anyFail = true;
171
+ }
172
+
173
+ if (!anyFail) {
174
+ addItem(section, `${green(okGlyph)} hooks: all command targets present`);
175
+ }
176
+ }
@@ -13,7 +13,7 @@ import {
13
13
  warnGlyph,
14
14
  yellow,
15
15
  } from './color.ts';
16
- import { CLAUDE_HOME, HOST, REPO_HOME, SHARED_LINKS } from './config.ts';
16
+ import { allSharedLinks, CLAUDE_HOME, HOST, REPO_HOME, type PathMap } from './config.ts';
17
17
  import { addItem, type DoctorSection } from './commands.doctor.format.ts';
18
18
  import { classifyRepoState, reasonForPartial } from './init.classify.ts';
19
19
 
@@ -157,8 +157,9 @@ function classifySymlinkTarget(name: string, p: string): { line: string; fail: b
157
157
  }
158
158
 
159
159
  /**
160
- * Emits a per-entry status line for each name in SHARED_LINKS
161
- * (okGlyph/warnGlyph/infoGlyph/failGlyph). A non-symlink blocks sync and FAILs
160
+ * Emits a per-entry status line for each name in `allSharedLinks(map)` (the
161
+ * static shared-link set plus any validated `sharedDirs` entries) using
162
+ * okGlyph/warnGlyph/infoGlyph/failGlyph. A non-symlink blocks sync and FAILs
162
163
  * via process.exitCode. TOCTOU-safe: lstatSync is wrapped in try/catch so a path
163
164
  * that vanishes or becomes unreadable between the probe and the stat yields a
164
165
  * row instead of an unhandled throw that aborts the whole doctor run. Severity
@@ -168,8 +169,8 @@ function classifySymlinkTarget(name: string, p: string): { line: string; fail: b
168
169
  * sync). A symlink whose target cannot be resolved is never a healthy OK, so a
169
170
  * dangling or unreadable link is not masked.
170
171
  */
171
- export function reportSharedLinks(section: DoctorSection): void {
172
- for (const name of SHARED_LINKS) {
172
+ export function reportSharedLinks(section: DoctorSection, map: PathMap): void {
173
+ for (const name of allSharedLinks(map)) {
173
174
  const p = join(CLAUDE_HOME, name);
174
175
  const { line, fail } = classifySharedLink(name, p);
175
176
  addItem(section, line);
@@ -28,10 +28,16 @@ import { gitStatusPorcelainZ } from './utils.ts';
28
28
  */
29
29
 
30
30
  /**
31
- * Probes for gitleaks on PATH; emits okGlyph with version, or failGlyph with
32
- * ENOENT vs other-error distinction (sets exitCode=1). Returns `true` when a
33
- * usable binary was found so the caller can skip a redundant second `version`
34
- * probe (e.g. the `--check-shared` Shared scan section).
31
+ * Probes for gitleaks on PATH. Emits okGlyph with version when found. When
32
+ * gitleaks is absent (ENOENT), emits warnGlyph and does NOT set exitCode:
33
+ * gitleaks is required for `nomad push` but is an optional dependency for the
34
+ * read-only doctor check, so its absence degrades to WARN per the project
35
+ * convention that optional-dependency absence must never affect exit code. A
36
+ * non-ENOENT error (broken binary, permission denied) still emits failGlyph
37
+ * and sets exitCode=1 because a present-but-unrunnable gitleaks is a real
38
+ * defect that would break `nomad push`. Returns `true` when a usable binary
39
+ * was found so the caller can skip a redundant second `version` probe (e.g.
40
+ * the `--check-shared` Shared scan section).
35
41
  */
36
42
  export function reportGitleaksProbe(section: DoctorSection): boolean {
37
43
  try {
@@ -42,11 +48,11 @@ export function reportGitleaksProbe(section: DoctorSection): boolean {
42
48
  return true;
43
49
  } catch (err) {
44
50
  if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
45
- addItem(section, `${red(failGlyph)} gitleaks: not on PATH (required for nomad push)`);
51
+ addItem(section, `${yellow(warnGlyph)} gitleaks: not on PATH (required for nomad push)`);
46
52
  } else {
47
53
  addItem(section, `${red(failGlyph)} gitleaks: probe failed: ${(err as Error).message}`);
54
+ process.exitCode = 1;
48
55
  }
49
- process.exitCode = 1;
50
56
  return false;
51
57
  }
52
58
  }
@@ -1,3 +1,6 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
1
4
  import {
2
5
  reportHostAndPaths,
3
6
  reportRepoState,
@@ -17,8 +20,10 @@ import {
17
20
  } from './commands.doctor.checks.repository.ts';
18
21
  import { reportCheckSchema } from './commands.doctor.check-schema.ts';
19
22
  import { reportCheckShared } from './commands.doctor.check-shared.ts';
23
+ import { reportHooksTargetCheck } from './commands.doctor.checks.hooks.ts';
24
+ import { REPO_HOME, type PathMap } from './config.ts';
20
25
  import { reportNodeEngineCheck } from './commands.doctor.engine.ts';
21
- import { renderDoctor, section } from './commands.doctor.format.ts';
26
+ import { readJsonSafe, renderDoctor, section } from './commands.doctor.format.ts';
22
27
  import { reportGitleaksVersionCheck } from './commands.doctor.gitleaks-version.ts';
23
28
  import { reportMirrorActions } from './commands.doctor.mirror-actions.ts';
24
29
  import { reportVersionCheck } from './commands.doctor.version.ts';
@@ -47,7 +52,16 @@ export function cmdDoctor(opts: { checkShared?: boolean; checkSchema?: boolean }
47
52
  reportRepoState(host);
48
53
 
49
54
  const links = section('Shared links');
50
- reportSharedLinks(links);
55
+ // Tolerantly read path-map.json for sharedDirs: doctor is read-only and
56
+ // must not throw on a missing or malformed map. Fall back to { projects: {} }
57
+ // so hooks + static SHARED_LINKS rows still emit on a fresh host.
58
+ const mapPath = join(REPO_HOME, 'path-map.json');
59
+ const rawMap = existsSync(mapPath) ? readJsonSafe<PathMap>(mapPath, mapPath, links) : null;
60
+ const map: PathMap = rawMap ?? { projects: {} };
61
+ reportSharedLinks(links, map);
62
+
63
+ const hooksScan = section('Hook targets');
64
+ reportHooksTargetCheck(hooksScan);
51
65
 
52
66
  const settings = section('Settings');
53
67
  const base = loadBaseSettings(settings);
@@ -86,6 +100,7 @@ export function cmdDoctor(opts: { checkShared?: boolean; checkSchema?: boolean }
86
100
  version,
87
101
  host,
88
102
  links,
103
+ hooksScan,
89
104
  settings,
90
105
  pathMap,
91
106
  neverSync,
@@ -6,7 +6,7 @@ import {
6
6
  buildSessionsSection,
7
7
  buildSettingsSection,
8
8
  } from './commands.push.sections.ts';
9
- import { HOME, HOST, REPO_HOME } from './config.ts';
9
+ import { HOME, HOST, REPO_HOME, type PathMap } from './config.ts';
10
10
  import { divergenceCheckExtras, remapExtrasPull } from './extras-sync.ts';
11
11
  import { applySharedLinks, regenerateSettings } from './links.ts';
12
12
  import { renderTree, section, addItem } from './output-tree.ts';
@@ -16,6 +16,7 @@ import { emitSummary, summaryRow } from './summary.ts';
16
16
  import { die, fail, gitOrFatal, log, NomadFatal } from './utils.ts';
17
17
  import { freshBackupTs } from './utils.fs.ts';
18
18
  import { acquireLock, releaseLock } from './utils.lockfile.ts';
19
+ import { readPathMap } from './utils.json.ts';
19
20
 
20
21
  /**
21
22
  * Run the WET (non-dry-run) pull side effects in order and render the
@@ -30,8 +31,8 @@ import { acquireLock, releaseLock } from './utils.lockfile.ts';
30
31
  *
31
32
  * @param ts - backup timestamp namespace shared by every WET side effect.
32
33
  */
33
- function applyWetPull(ts: string): void {
34
- applySharedLinks(ts);
34
+ function applyWetPull(ts: string, map: PathMap): void {
35
+ applySharedLinks(ts, map);
35
36
  const { label } = regenerateSettings(ts);
36
37
  const remapResult = remapPull(ts);
37
38
  const extrasResult = remapExtrasPull(ts);
@@ -133,20 +134,25 @@ export function cmdPull(opts: { dryRun?: boolean } = {}): void {
133
134
  : `pull on host=${HOST} (backup=${ts})`,
134
135
  );
135
136
  gitOrFatal(['pull', '--rebase', '--autostash'], 'git pull --rebase', REPO_HOME);
137
+ // Read path-map.json for sharedDirs/symlink threading. Falls back to a
138
+ // no-sharedDirs map when the file is absent (fresh-clone before init).
139
+ // A parse failure routes through NomadFatal -> catch -> lock release.
140
+ const mapPath = join(REPO_HOME, 'path-map.json');
141
+ const map: PathMap = existsSync(mapPath) ? readPathMap(mapPath) : { projects: {} };
136
142
  // Read-only pre-pull check: fires in BOTH wet and dry modes (D-08).
137
143
  // Runs AFTER the rebase (so origin content is fetched) and BEFORE any
138
144
  // mutation (so local state is intact for byte-level comparison). The
139
145
  // function itself silently skips when no `extras` key is declared.
140
146
  divergenceCheckExtras(ts);
141
147
  if (dryRun) {
142
- const previewResult = computePreview(ts);
148
+ const previewResult = computePreview(ts, map);
143
149
  // dryRun deliberately omits remapExtrasPull to preserve the
144
150
  // zero-mutation contract; users still see the divergence WARN above.
145
151
  // BYTE-IDENTICAL dry-run output: standalone emitSummary, no tree.
146
152
  log('dry-run complete; no mutation');
147
153
  emitSummary('pull', previewResult.unmapped);
148
154
  } else {
149
- applyWetPull(ts);
155
+ applyWetPull(ts, map);
150
156
  }
151
157
  } catch (err) {
152
158
  // Catch fatal errors here so the finally block runs and releases the
@@ -1,4 +1,5 @@
1
1
  import { NEVER_SYNC, PUSH_ALLOWED_STATIC, SUPPORTED_EXTRAS, type PathMap } from './config.ts';
2
+ import { isValidSharedDir } from './config.sharedDirs.guard.ts';
2
3
  import { fail, NomadFatal } from './utils.ts';
3
4
 
4
5
  /**
@@ -98,6 +99,7 @@ export function enforceAllowList(statusPorcelain: string, map: PathMap): void {
98
99
  .filter((n) => extrasWhitelist.includes(n))
99
100
  .flatMap((n) => [`shared/extras/${l}/${n}`, `shared/extras/${l}/${n}/`]),
100
101
  ),
102
+ ...(map.sharedDirs ?? []).filter((d) => isValidSharedDir(d)).map((d) => `shared/${d}/`),
101
103
  ];
102
104
  const neverSyncHits: string[] = [];
103
105
  const violations: string[] = [];
@@ -0,0 +1,55 @@
1
+ import { NEVER_SYNC } from './config.ts';
2
+
3
+ /**
4
+ * Single-segment path characters allowed in a `sharedDirs` entry. Mirrors
5
+ * `SAFE_LOGICAL` in `extras-sync.guards.ts` but applied to global support
6
+ * directory names rather than per-project logical names. Must match
7
+ * `^[A-Za-z0-9._-]+$` so no path separator, no shell-special character, no
8
+ * leading dot that would collide with a hidden state directory.
9
+ */
10
+ const SAFE_SEGMENT = /^[A-Za-z0-9._-]+$/;
11
+
12
+ /**
13
+ * Names that already exist under `shared/` (as repo-structural files or as
14
+ * members of `SHARED_LINKS`) that a `sharedDirs` entry must not collide with.
15
+ * Adding a `sharedDirs` entry matching one of these would either shadow a
16
+ * structural file or create a duplicate symlink pointing at the same target.
17
+ */
18
+ const RESERVED_SHARED = new Set([
19
+ 'settings.base.json',
20
+ 'CLAUDE.md',
21
+ 'agents',
22
+ 'skills',
23
+ 'commands',
24
+ 'rules',
25
+ 'my-statusline.cjs',
26
+ 'hooks',
27
+ 'hosts',
28
+ 'path-map.json',
29
+ 'extras',
30
+ 'projects',
31
+ ]);
32
+
33
+ /**
34
+ * Returns `true` when `entry` is a valid `sharedDirs` path segment: a single
35
+ * path segment (no `/` or `..`), not present in `NEVER_SYNC`, and not a
36
+ * reserved `shared/` name. Invalid entries are dropped with a WARN by the
37
+ * caller (`allSharedLinks` in `config.ts`) rather than throwing a fatal error,
38
+ * mirroring the resilience of the existing extras path.
39
+ *
40
+ * Accepts `unknown` because `path-map.json` is runtime input: a malformed
41
+ * `sharedDirs` array can hold non-string values (numbers, objects, null) that
42
+ * `SAFE_SEGMENT.test` would otherwise string-coerce (e.g. `42` -> `"42"`).
43
+ * Rejecting non-strings first drops those shapes deterministically, and the
44
+ * `entry is string` predicate narrows the value for callers that filter on it.
45
+ *
46
+ * @param entry - Candidate `sharedDirs` value from `path-map.json`.
47
+ * @returns `true` if the entry is safe to use as a symlink target under `~/.claude/`.
48
+ */
49
+ export function isValidSharedDir(entry: unknown): entry is string {
50
+ if (typeof entry !== 'string') return false;
51
+ if (!SAFE_SEGMENT.test(entry) || entry === '.' || entry === '..') return false;
52
+ if (NEVER_SYNC.has(entry)) return false;
53
+ if (RESERVED_SHARED.has(entry)) return false;
54
+ return true;
55
+ }
package/src/config.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  import { homedir, hostname } from 'node:os';
2
2
  import { resolve } from 'node:path';
3
3
 
4
+ import { isValidSharedDir } from './config.sharedDirs.guard.ts';
5
+ import { warn } from './utils.ts';
6
+
4
7
  /**
5
8
  * Resolved home directory. Uses Node's `os.homedir()` which reads `$HOME` on
6
9
  * POSIX and falls back to `getpwuid_r()` when the env var is unset. Returns
@@ -74,8 +77,34 @@ export const SHARED_LINKS = [
74
77
  'commands',
75
78
  'rules',
76
79
  'my-statusline.cjs',
80
+ 'hooks',
77
81
  ] as const;
78
82
 
83
+ /**
84
+ * Returns the union of `SHARED_LINKS` and any validated entries from
85
+ * `map.sharedDirs`. Entries that fail the `isValidSharedDir` guard (path
86
+ * separators, NEVER_SYNC names, reserved shared/ names) are dropped with a
87
+ * single WARN per entry; the remaining valid entries are appended after the
88
+ * static `SHARED_LINKS` names. Callers iterate the result with `for...of` to
89
+ * apply the same symlink machinery to both built-in and user-configured dirs.
90
+ *
91
+ * @param map - Parsed `path-map.json` content.
92
+ * @returns Array of link names to symlink under `~/.claude/`.
93
+ */
94
+ export function allSharedLinks(map: PathMap): string[] {
95
+ const extras: string[] = [];
96
+ for (const entry of map.sharedDirs ?? []) {
97
+ if (isValidSharedDir(entry)) {
98
+ extras.push(entry);
99
+ } else {
100
+ warn(
101
+ `sharedDirs entry ${JSON.stringify(entry)} is invalid (path separator, reserved name, or NEVER_SYNC); skipping`,
102
+ );
103
+ }
104
+ }
105
+ return [...SHARED_LINKS, ...extras];
106
+ }
107
+
79
108
  /**
80
109
  * Whitelist of names allowed in `path-map.json`'s top-level `extras` field.
81
110
  * Each entry is either a directory name (e.g. `.planning`) OR a single
@@ -94,9 +123,13 @@ export const SUPPORTED_EXTRAS = ['.planning', 'CLAUDE.md'] as const;
94
123
  * Path segments that must never cross the sync boundary in either direction.
95
124
  * Defense-in-depth pair with `PUSH_ALLOWED_STATIC`: even if the allow-list
96
125
  * misses a path, anything containing one of these segments is hard-blocked.
126
+ * Also the deny-list the `sharedDirs` opt-in is validated against, so a user
127
+ * cannot symlink a host-local secret or cache into the shared repo by naming
128
+ * it in `path-map.json`.
97
129
  */
98
130
  export const NEVER_SYNC = new Set([
99
131
  '.claude.json',
132
+ '.credentials.json',
100
133
  'history.jsonl',
101
134
  'settings.local.json',
102
135
  'stats-cache.json',
@@ -109,6 +142,16 @@ export const NEVER_SYNC = new Set([
109
142
  'statsig',
110
143
  'telemetry',
111
144
  'ide',
145
+ // Host-local caches and runtime state: never useful to share, and named here
146
+ // so the sharedDirs guard rejects an accidental opt-in.
147
+ 'cache',
148
+ 'backups',
149
+ 'paste-cache',
150
+ 'daemon',
151
+ 'jobs',
152
+ 'tasks',
153
+ 'security',
154
+ 'sessions',
112
155
  ]);
113
156
 
114
157
  // Schema-drift baseline for `~/.claude/settings.json`; top-level keys not in
@@ -134,6 +177,7 @@ export const PUSH_ALLOWED_STATIC = [
134
177
  'shared/commands/',
135
178
  'shared/rules/',
136
179
  'shared/.gitignore',
180
+ 'shared/hooks/',
137
181
  'hosts/',
138
182
  'path-map.json',
139
183
  ] as const;
@@ -150,8 +194,16 @@ export const PUSH_ALLOWED_STATIC = [
150
194
  * consumers against `SUPPORTED_EXTRAS`. Absence of the field is equivalent
151
195
  * to no extras for any project; legacy `path-map.json` files without an
152
196
  * `extras` block continue to work unchanged (no migration required).
197
+ *
198
+ * Optional `sharedDirs` field (additive, top-level): opt-in global support
199
+ * directories under `~/.claude/` to include in the `SHARED_LINKS` symlink set.
200
+ * Each entry is a single path segment (e.g. `"get-shit-done"`); entries that
201
+ * fail the `isValidSharedDir` guard are dropped with a WARN and never reach the
202
+ * filesystem. Absence of the field is equivalent to no extra shared dirs; legacy
203
+ * `path-map.json` files without a `sharedDirs` block continue to work unchanged.
153
204
  */
154
205
  export type PathMap = {
155
206
  projects: Record<string, Record<string, string>>;
156
207
  extras?: Record<string, string[]>;
208
+ sharedDirs?: string[];
157
209
  };
package/src/diff-lines.ts CHANGED
@@ -27,14 +27,13 @@ export function diffLinesToUnified(oldStr: string, newStr: string): string[] {
27
27
  for (const part of parts) {
28
28
  const partLines = part.value.split('\n');
29
29
  // A part value ending in '\n' yields a trailing '' after split; drop it.
30
- if (partLines[partLines.length - 1] === '') {
30
+ if (partLines.at(-1) === '') {
31
31
  partLines.pop();
32
32
  }
33
- const prefix = part.removed
34
- ? (line: string) => red(`-${line}`)
35
- : part.added
36
- ? (line: string) => green(`+${line}`)
37
- : (line: string) => ` ${line}`;
33
+ let prefix: (line: string) => string;
34
+ if (part.removed) prefix = (line) => red(`-${line}`);
35
+ else if (part.added) prefix = (line) => green(`+${line}`);
36
+ else prefix = (line) => ` ${line}`;
38
37
  for (const line of partLines) {
39
38
  lines.push(prefix(line));
40
39
  }
package/src/diff.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
 
4
- import { HOME, REPO_HOME } from './config.ts';
4
+ import { HOME, REPO_HOME, type PathMap } from './config.ts';
5
5
  import { computePreview } from './preview.ts';
6
6
  import { emitSummary } from './summary.ts';
7
7
  import { die, fail, NomadFatal } from './utils.ts';
8
8
  import { freshBackupTs } from './utils.fs.ts';
9
+ import { readPathMap } from './utils.json.ts';
9
10
 
10
11
  /**
11
12
  * `nomad diff` command. Offline-safe, read-only preview surface that runs
@@ -37,7 +38,11 @@ export function cmdDiff(): void {
37
38
  const ts = freshBackupTs(backupBase);
38
39
  // Preview log lines reference `ts` so output stays consistent with
39
40
  // pull --dry-run; the backup root itself is intentionally NOT created.
40
- const result = computePreview(ts);
41
+ // Read the map tolerantly (offline-safe: fall back to no-sharedDirs when
42
+ // path-map.json is absent from a partially-scaffolded repo).
43
+ const mapPath = join(REPO_HOME, 'path-map.json');
44
+ const map: PathMap = existsSync(mapPath) ? readPathMap(mapPath) : { projects: {} };
45
+ const result = computePreview(ts, map);
41
46
  emitSummary('diff', result.unmapped);
42
47
  } catch (err) {
43
48
  if (err instanceof NomadFatal) {
@@ -3,21 +3,17 @@ import { join } from 'node:path';
3
3
 
4
4
  import { HOME, REPO_HOME, SUPPORTED_EXTRAS, type PathMap } from './config.ts';
5
5
  import { listDivergingFiles } from './extras-sync.diff.ts';
6
- import {
7
- copyExtras,
8
- eachExtrasTarget,
9
- loadValidatedExtras,
10
- type ExtrasCounts,
11
- type ValidatedExtras,
12
- } from './extras-sync.core.ts';
6
+ import { eachExtrasTarget, loadValidatedExtras, type ExtrasCounts } from './extras-sync.core.ts';
13
7
  import { assertSafeLogical } from './extras-sync.guards.ts';
14
8
  import { warn } from './utils.ts';
15
9
  import { encodePath } from './utils.json.ts';
16
10
 
17
11
  // Re-export the shared primitives so existing import sites that pull them from
18
12
  // `./extras-sync.ts` (tests call `copyExtras` directly) keep working unchanged.
19
- export { copyExtras, eachExtrasTarget, loadValidatedExtras };
20
- export type { ExtrasCounts, ValidatedExtras };
13
+ export { copyExtras } from './extras-sync.core.ts';
14
+ export type { ValidatedExtras } from './extras-sync.core.ts';
15
+ export { eachExtrasTarget, loadValidatedExtras };
16
+ export type { ExtrasCounts };
21
17
 
22
18
  // The two public remap ops live in the sibling module to hold the soft
23
19
  // line-cap; re-exported here so `./extras-sync.ts` stays the public surface.
@@ -1,26 +1,27 @@
1
1
  import { copyFileSync, cpSync, existsSync, rmSync, statSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
 
4
- import { CLAUDE_HOME, HOST, REPO_HOME, SHARED_LINKS } from './config.ts';
4
+ import { allSharedLinks, CLAUDE_HOME, HOST, REPO_HOME, type PathMap } from './config.ts';
5
5
  import { die, log } from './utils.ts';
6
6
  import { writeJsonAtomic } from './utils.fs.ts';
7
7
  import { readJson } from './utils.json.ts';
8
8
 
9
9
  /**
10
- * Overlay `~/.claude/` SHARED_LINKS onto the freshly-written scaffold under
11
- * `REPO_HOME/shared/`. Regular files (`CLAUDE.md`, `my-statusline.cjs`) go
12
- * through `copyFileSync` so the placeholder is overwritten; directories
13
- * (`agents`, `skills`, `commands`, `rules`) drop their just-written
14
- * `.gitkeep` marker first and then go through `cpSync` with `force: false`,
15
- * so any unexpected pre-existing destination content (an out-of-band write
16
- * between the preflight check and this step) surfaces as an error. Also
17
- * translates `~/.claude/settings.json` (when present) into `hosts/<HOST>.json`
18
- * via `writeJsonAtomic`. Does NOT modify `~/.claude/`; the caller emits the
19
- * user-visible next-step + originals-not-removed log lines so the canonical
20
- * phrasing stays co-located with `cmdInit` itself.
10
+ * Overlay `~/.claude/` entries for every name in `allSharedLinks(map)` (the
11
+ * static shared-link set plus any validated `sharedDirs` entries) onto the
12
+ * freshly-written scaffold under `REPO_HOME/shared/`. Regular files
13
+ * (`CLAUDE.md`, `my-statusline.cjs`) go through `copyFileSync` so the
14
+ * placeholder is overwritten; directories (`agents`, `skills`, `commands`,
15
+ * `rules`) drop their just-written `.gitkeep` marker first and then go through
16
+ * `cpSync` with `force: false`, so any unexpected pre-existing destination
17
+ * content surfaces as an error. Also translates `~/.claude/settings.json`
18
+ * (when present) into `hosts/<HOST>.json` via `writeJsonAtomic`. Does NOT
19
+ * modify `~/.claude/`; the caller emits the user-visible next-step +
20
+ * originals-not-removed log lines so the canonical phrasing stays co-located
21
+ * with `cmdInit` itself.
21
22
  */
22
- export function snapshotIntoShared(): void {
23
- for (const name of SHARED_LINKS) {
23
+ export function snapshotIntoShared(map: PathMap): void {
24
+ for (const name of allSharedLinks(map)) {
24
25
  const src = join(CLAUDE_HOME, name);
25
26
  if (!existsSync(src)) continue;
26
27
  const dst = join(REPO_HOME, 'shared', name);
package/src/init.ts CHANGED
@@ -29,7 +29,7 @@ const SHARED_CLAUDE_MD =
29
29
  * with the SHARED_LINKS contract in `src/config.ts` (those same names are
30
30
  * symlinked into `~/.claude/` on every pull).
31
31
  */
32
- const SHARED_KEEP_DIRS = ['agents', 'skills', 'commands', 'rules'] as const;
32
+ const SHARED_KEEP_DIRS = ['agents', 'skills', 'commands', 'rules', 'hooks'] as const;
33
33
 
34
34
  /**
35
35
  * Pre-flight refuse-to-clobber list. If ANY of these absolute paths
@@ -112,7 +112,10 @@ export function cmdInit(
112
112
  log('created path-map.json');
113
113
 
114
114
  if (snapshot) {
115
- snapshotIntoShared();
115
+ // In the init path, path-map.json was just written as `{ projects: {} }`
116
+ // (preflight refuses a pre-existing one), so sharedDirs is empty by
117
+ // construction. Pass the minimal map literal to satisfy the type.
118
+ snapshotIntoShared({ projects: {} });
116
119
  log(`snapshot staged in shared/; review, then 'nomad push' to share with other hosts.`);
117
120
  log('~/.claude/ originals were NOT removed.');
118
121
  }
package/src/links.ts CHANGED
@@ -1,19 +1,21 @@
1
1
  import { existsSync, lstatSync, rmSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
 
4
- import { CLAUDE_HOME, HOST, REPO_HOME, SHARED_LINKS } from './config.ts';
4
+ import { allSharedLinks, CLAUDE_HOME, HOST, REPO_HOME, type PathMap } from './config.ts';
5
5
  import { die, log, warn } from './utils.ts';
6
6
  import { backupBeforeWrite, ensureSymlink, writeJsonAtomic } from './utils.fs.ts';
7
7
  import { deepMerge, readJson } from './utils.json.ts';
8
8
 
9
9
  /**
10
- * Symlink the `SHARED_LINKS` names from the repo's `shared/` dir into
11
- * `~/.claude/`. Two-pass: first back up and remove any pre-existing
12
- * non-symlink at each link path (auto-move using `ts` as the backup
13
- * timestamp), then create the symlinks. Skips a link entirely when the repo
14
- * has no counterpart, so a host where `shared/commands/` does not exist
15
- * keeps its local `~/.claude/commands/` instead of having it silently
16
- * deleted.
10
+ * Symlink every name in `allSharedLinks(map)` (the static shared-link set
11
+ * plus any validated `sharedDirs` entries from `path-map.json`) from the
12
+ * repo's `shared/` dir into `~/.claude/`. Two-pass: first back up and remove
13
+ * any pre-existing non-symlink at each link path (auto-move using `ts` as the
14
+ * backup timestamp), then create the symlinks. Skips a link entirely when the
15
+ * repo has no `shared/<name>` counterpart, so a host where `shared/commands/`
16
+ * does not exist keeps its local `~/.claude/commands/` instead of having it
17
+ * silently deleted. `sharedDirs` entries route through the identical two-pass
18
+ * logic (refuse-non-symlink / backup / dryRun-log behavior is unchanged).
17
19
  *
18
20
  * `opts.dryRun` (default `false`): when `true`, no disk mutation occurs. The
19
21
  * function logs `would auto-move non-symlink:` and `would create symlink:`
@@ -21,9 +23,12 @@ import { deepMerge, readJson } from './utils.json.ts';
21
23
  * call with no opts arg or with `dryRun: false` keeps the prior mutating
22
24
  * behavior.
23
25
  */
24
- export function applySharedLinks(ts: string, opts: { dryRun?: boolean } = {}): void {
26
+ export function applySharedLinks(ts: string, map: PathMap, opts: { dryRun?: boolean } = {}): void {
25
27
  const dryRun = opts.dryRun === true;
26
- for (const name of SHARED_LINKS) {
28
+ // Derive once: allSharedLinks emits a WARN per invalid sharedDirs entry, so
29
+ // calling it per loop would double every such warning in a single run.
30
+ const linkNames = allSharedLinks(map);
31
+ for (const name of linkNames) {
27
32
  const linkPath = join(CLAUDE_HOME, name);
28
33
  const target = join(REPO_HOME, 'shared', name);
29
34
  if (!existsSync(linkPath)) continue;
@@ -36,7 +41,7 @@ export function applySharedLinks(ts: string, opts: { dryRun?: boolean } = {}): v
36
41
  backupBeforeWrite(linkPath, ts);
37
42
  rmSync(linkPath, { recursive: true, force: true });
38
43
  }
39
- for (const name of SHARED_LINKS) {
44
+ for (const name of linkNames) {
40
45
  const target = join(REPO_HOME, 'shared', name);
41
46
  if (!existsSync(target)) continue;
42
47
  if (dryRun) {
package/src/preview.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
 
4
- import { CLAUDE_HOME, HOST, REPO_HOME } from './config.ts';
4
+ import { CLAUDE_HOME, HOST, REPO_HOME, type PathMap } from './config.ts';
5
5
  import { diffLinesToUnified } from './diff-lines.ts';
6
6
  import { applySharedLinks } from './links.ts';
7
7
  import { remapPull } from './remap.ts';
@@ -102,13 +102,16 @@ function previewSettings(basePath: string, hostPath: string, settingsPath: strin
102
102
  *
103
103
  * Settings diff output goes through `log()` so each line gets the info-prefixed
104
104
  * prefix, keeping output channels consistent across the three sections.
105
+ *
106
+ * @param map - parsed path-map.json; callers fall back to `{ projects: {} }`
107
+ * when the file is absent so the offline/fresh-clone contract holds.
105
108
  */
106
- export function computePreview(ts: string): { unmapped: number; collisions: number } {
109
+ export function computePreview(ts: string, map: PathMap): { unmapped: number; collisions: number } {
107
110
  log(`would pull on host=${HOST} (dry-run; no mutation)`);
108
111
 
109
112
  // Symlinks: applySharedLinks emits its own would-create / would-auto-move
110
113
  // lines. dryRun:true is mandatory; a real call here would mutate disk.
111
- applySharedLinks(ts, { dryRun: true });
114
+ applySharedLinks(ts, map, { dryRun: true });
112
115
 
113
116
  previewSettings(
114
117
  join(REPO_HOME, 'shared', 'settings.base.json'),
package/src/summary.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  import { green, okGlyph, warnGlyph, yellow } from './color.ts';
2
2
  import { ok, warn } from './utils.ts';
3
3
 
4
+ /** The three originating commands that share the end-of-run summary line. */
5
+ type SummaryVerb = 'pull' | 'push' | 'diff';
6
+
4
7
  /**
5
8
  * Pure phrasing core for the end-of-run summary line shared by cmdPull,
6
9
  * cmdPush, and cmdDiff. Returns the message `text` (without any status glyph)
@@ -27,7 +30,7 @@ import { ok, warn } from './utils.ts';
27
30
  * @returns `{ text, clean }` where `clean` is true on the no-warning outcome.
28
31
  */
29
32
  export function summaryText(
30
- verb: 'pull' | 'push' | 'diff',
33
+ verb: SummaryVerb,
31
34
  unmapped: number,
32
35
  collisions = 0,
33
36
  extrasSkipped = 0,
@@ -63,7 +66,7 @@ export function summaryText(
63
66
  * @returns the rendered row string for the Summary section.
64
67
  */
65
68
  export function summaryRow(
66
- verb: 'pull' | 'push' | 'diff',
69
+ verb: SummaryVerb,
67
70
  unmapped: number,
68
71
  collisions = 0,
69
72
  extrasSkipped = 0,
@@ -85,7 +88,7 @@ export function summaryRow(
85
88
  * contract). `cmdDiff` still calls this for its standalone summary line.
86
89
  */
87
90
  export function emitSummary(
88
- verb: 'pull' | 'push' | 'diff',
91
+ verb: SummaryVerb,
89
92
  unmapped: number,
90
93
  collisions = 0,
91
94
  extrasSkipped = 0,