claude-nomad 0.54.0 → 0.55.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.55.0](https://github.com/funkadelic/claude-nomad/compare/v0.54.0...v0.55.0) (2026-06-28)
4
+
5
+
6
+ ### Added
7
+
8
+ * **doctor:** warn on misaligned host key when NOMAD_HOST is unset ([#356](https://github.com/funkadelic/claude-nomad/issues/356)) ([a13e833](https://github.com/funkadelic/claude-nomad/commit/a13e833a09636f98f1e9c51e9c41f01a4323dea2))
9
+ * **init:** offer to snapshot an existing ~/.claude on init ([#357](https://github.com/funkadelic/claude-nomad/issues/357)) ([92e90b1](https://github.com/funkadelic/claude-nomad/commit/92e90b1977fff51d565fa5d11b3c640790ee1b57))
10
+
11
+
12
+ ### Fixed
13
+
14
+ * make sync warning messages user-friendly ([#345](https://github.com/funkadelic/claude-nomad/issues/345)) ([002f57d](https://github.com/funkadelic/claude-nomad/commit/002f57de240dfb1349742fa4c2b4ac5e51b5f7ba))
15
+
16
+
17
+ ### Documentation
18
+
19
+ * clarify onboarding (two-dir model, session sync, push order) ([#355](https://github.com/funkadelic/claude-nomad/issues/355)) ([396fbb8](https://github.com/funkadelic/claude-nomad/commit/396fbb875b4331800ee74834944da17decbcad92))
20
+
3
21
  ## [0.54.0](https://github.com/funkadelic/claude-nomad/compare/v0.53.3...v0.54.0) (2026-06-27)
4
22
 
5
23
 
package/README.md CHANGED
@@ -39,7 +39,9 @@ survives different file paths and your secrets never ride along.
39
39
  live settings syncs normally via `nomad capture-settings`.
40
40
  - **Every push is secret-scanned.** Only an explicit allow-list of paths ever leaves the machine,
41
41
  credentials never sync, and gitleaks scans the exact files about to be published. The push aborts
42
- on any hit, with an interactive menu to redact, allow, or drop the finding.
42
+ on any hit, with an interactive menu to redact, allow, or drop the finding. Always publish through
43
+ `nomad push`: the sync repo is an ordinary Git repo, so a manual `git push` from it skips the scan
44
+ entirely and can leak a secret that `nomad push` would have caught.
43
45
  - **Preview before you trust it.** `nomad diff` shows offline what a pull would change (gsd-owned
44
46
  hook churn is filtered the same as on pull, so the preview matches what a real pull writes), and
45
47
  `--dry-run` on pull and push prints the plan without writing anything.
@@ -47,13 +49,16 @@ survives different file paths and your secrets never ride along.
47
49
  repo, broken hook references, hooks that would crash on session start because of a missing
48
50
  `--preserve-symlinks-main` flag, version drift, oversized backup cache, missing git committer
49
51
  identity in the sync repo (a push fails at commit time without one), path-map entries whose local
50
- project folder no longer exists on this machine, synced skills with local edits that differ from
51
- the shared copy, and settings drift in both directions: keys present in the repo merge but absent
52
- from your live `settings.json` (behind; the next `nomad pull` will restore them, fix:
53
- `nomad pull`) and keys present locally but not yet in the repo (ahead; local-only additions, fix:
54
- `nomad capture-settings`). Each issue includes a fix hint. By default the report is compact: it
55
- shows only checks that need action plus a one-line verdict. Add `--verbose` (or `--all` / `-v`) to
56
- see the full per-check tree, including everything that passed.
52
+ project folder no longer exists on this machine, a multi-host repo where this machine's
53
+ hostname-derived key matches no `hosts/<HOST>.json` or path-map entry (a sign `NOMAD_HOST` is
54
+ unset here, so per-host settings and session sync will not line up with the other hosts), synced
55
+ skills with local edits that differ from the shared copy, and settings drift in both directions:
56
+ keys present in the repo merge but absent from your live `settings.json` (behind; the next
57
+ `nomad pull` will restore them, fix: `nomad pull`) and keys present locally but not yet in the
58
+ repo (ahead; local-only additions, fix: `nomad capture-settings`). Each issue includes a fix hint.
59
+ By default the report is compact: it shows only checks that need action plus a one-line verdict.
60
+ Add `--verbose` (or `--all` / `-v`) to see the full per-check tree, including everything that
61
+ passed.
57
62
  - **Self-healing sync.** Every overwrite is backed up first, and `nomad pull --force-remote`
58
63
  recovers two kinds of stuck sync repo: a repo stuck mid-rebase or mid-merge (aborts the operation,
59
64
  parks stranded work on a branch, refuses if shared config is at risk), and a repo where the rebase
@@ -68,15 +73,27 @@ opt-in per-project sync, transcript redaction, backup pruning, and more.
68
73
 
69
74
  ## Quickstart
70
75
 
76
+ nomad works with two directories, and the difference is the one thing worth learning up front:
77
+
78
+ - **`~/claude-nomad/`** is your private sync repo. This is the one you edit.
79
+ - **`~/.claude/`** is Claude Code's live config. nomad regenerates it on every `pull`.
80
+
81
+ Edit the repo, never the live config. In particular, never hand-edit `~/.claude/settings.json`: it
82
+ is rebuilt from the repo on every pull and your changes are lost. Change `shared/settings.base.json`
83
+ (or `hosts/<HOST>.json`) in the repo instead, or run `nomad capture-settings` to pull local changes
84
+ back into the repo (see [Changing settings](#changing-settings)).
85
+
71
86
  **First host** (once, ever):
72
87
 
73
88
  ```bash
74
89
  # 1. Install the CLI.
75
90
  $ npm i -g claude-nomad
76
91
 
77
- # 2. Create your private sync repo and scaffold it.
92
+ # 2. Create your private sync repo and scaffold it. If you already have a
93
+ # ~/.claude/ worth keeping, init offers to seed the repo from it.
78
94
  $ nomad init # prompts for a repo name (default: claude-nomad-config)
79
- $ nomad init --repo my-config # non-interactive
95
+ $ nomad init --repo my-config # set the repo name without the prompt
96
+ $ nomad init --snapshot # seed from existing ~/.claude/ without being asked
80
97
 
81
98
  # 3. Add a stable host label to ~/.zshrc or ~/.bashrc, then reload.
82
99
  export NOMAD_HOST=<your-host-label>
@@ -102,6 +119,45 @@ $ nomad pull # apply config to ~/.claude/
102
119
  $ nomad push # publish local changes (sessions, settings)
103
120
  ```
104
121
 
122
+ Pull before you push whenever both machines may have changed. Sync is last-write-wins, so pushing
123
+ stale local state over newer remote state silently overwrites it. The
124
+ [FAQ](https://funkadelic.github.io/claude-nomad/faq/) covers the full push/pull order when both
125
+ sides have changed.
126
+
127
+ ### Make your sessions follow you
128
+
129
+ Session history only syncs for projects you list in `path-map.json`, and a fresh `init` starts with
130
+ none, so no sessions sync until you add a mapping. Each entry maps a logical project name to the
131
+ absolute path it lives at on each host:
132
+
133
+ ```json
134
+ {
135
+ "projects": {
136
+ "my-app": {
137
+ "laptop": "/Users/you/code/my-app",
138
+ "desktop": "/home/you/projects/my-app"
139
+ }
140
+ }
141
+ }
142
+ ```
143
+
144
+ The host keys (`laptop`, `desktop`) are the same labels you set in `NOMAD_HOST` on each machine.
145
+ After editing `path-map.json`, `nomad push` publishes the matching sessions and `nomad pull` on
146
+ another host copies them into place, rewriting the embedded file paths so `claude --resume` finds
147
+ them at that host's path.
148
+
149
+ ### Changing settings
150
+
151
+ There are two ways a settings change reaches the repo, and the right one depends on where you made
152
+ it:
153
+
154
+ - **You are deciding the change:** edit `shared/settings.base.json` (shared by every host) or
155
+ `hosts/<HOST>.json` (one machine only) in the repo, then `nomad push`.
156
+ - **Something else already wrote it** (Claude Code or a tool added keys to your live
157
+ `~/.claude/settings.json`): run `nomad capture-settings` to promote those keys into the repo
158
+ before the next `nomad pull` overwrites them. Add `--host` to land machine-specific values (such
159
+ as absolute paths) in `hosts/<HOST>.json` instead of the shared base.
160
+
105
161
  During `nomad push` and `nomad pull`, long-running steps (rebase, secret scan, git push, session
106
162
  sync) show an animated progress indicator on an interactive terminal so the CLI does not look hung.
107
163
  In CI and when output is piped, only plain text lines are printed, with no ANSI control codes, so
package/dist/nomad.mjs CHANGED
@@ -780,7 +780,7 @@ var init_push_gitleaks_config = __esm({
780
780
 
781
781
  // src/push-checks.ts
782
782
  import { execFileSync as execFileSync4 } from "node:child_process";
783
- import { readdirSync as readdirSync4, rmSync as rmSync5 } from "node:fs";
783
+ import { readdirSync as readdirSync5, rmSync as rmSync5 } from "node:fs";
784
784
  import { homedir as homedir2, platform } from "node:os";
785
785
  import { join as join17 } from "node:path";
786
786
  function gitleaksInstallHint() {
@@ -820,7 +820,7 @@ function findGitlinks(dir) {
820
820
  function walk(current) {
821
821
  let entries;
822
822
  try {
823
- entries = readdirSync4(current, { withFileTypes: true });
823
+ entries = readdirSync5(current, { withFileTypes: true });
824
824
  } catch {
825
825
  return;
826
826
  }
@@ -1505,6 +1505,14 @@ function partitionByCaptureExclusion(keys) {
1505
1505
  }
1506
1506
  return { promotable, excluded };
1507
1507
  }
1508
+ function describeSettings(keys) {
1509
+ const one = keys.length === 1;
1510
+ return {
1511
+ phrase: `${keys.length} ${one ? "setting" : "settings"} (${keys.join(", ")})`,
1512
+ pronoun: one ? "it" : "them",
1513
+ verb: one ? "is" : "are"
1514
+ };
1515
+ }
1508
1516
  var BIN_NODE_RE = /^"?(?:[A-Za-z]:)?[\\/](?:.*[\\/])?bin[\\/]node"?$/;
1509
1517
  var LEADING_BIN_NODE_RE = /^"?(?:[A-Za-z]:)?[\\/](?:[^"\s]*[\\/])?bin[\\/]node"?(?=\s)/;
1510
1518
  function normalizeNodePathsDeep(value) {
@@ -1614,14 +1622,16 @@ function regenerateSettings(ts, opts = {}) {
1614
1622
  const existing = readJson(settingsPath);
1615
1623
  const drift = classifySettingsDrift(merged, existing);
1616
1624
  if (drift.behind.length > 0) {
1625
+ const { phrase, pronoun } = describeSettings(drift.behind);
1617
1626
  warn(
1618
- `existing settings.json is missing merged keys ${JSON.stringify(drift.behind)}. Run 'nomad pull' to restore them.`
1627
+ `your settings.json is missing ${phrase} that the synced copy has; run 'nomad pull' to restore ${pronoun}.`
1619
1628
  );
1620
1629
  }
1621
1630
  const { promotable } = partitionByCaptureExclusion(drift.ahead);
1622
1631
  if (promotable.length > 0) {
1632
+ const { phrase, pronoun, verb } = describeSettings(promotable);
1623
1633
  warn(
1624
- `existing settings.json has local-only keys ${JSON.stringify(promotable)}. Run 'nomad capture-settings' to promote them into the repo before they are overwritten.`
1634
+ `your settings.json has ${phrase} that ${verb} not yet synced; run 'nomad capture-settings' to save ${pronoun} to the repo before the next pull overwrites ${pronoun}.`
1625
1635
  );
1626
1636
  }
1627
1637
  } catch {
@@ -2069,7 +2079,7 @@ import { join as join36 } from "node:path";
2069
2079
  // src/commands.doctor.checks.repo.ts
2070
2080
  init_color();
2071
2081
  init_config();
2072
- import { existsSync as existsSync9, lstatSync as lstatSync6, statSync as statSync3 } from "node:fs";
2082
+ import { existsSync as existsSync9, lstatSync as lstatSync6, readdirSync as readdirSync2, statSync as statSync3 } from "node:fs";
2073
2083
  import { join as join11 } from "node:path";
2074
2084
 
2075
2085
  // src/commands.doctor.format.ts
@@ -2189,6 +2199,7 @@ function reasonForPartial(repoHome2, host) {
2189
2199
  }
2190
2200
 
2191
2201
  // src/commands.doctor.checks.repo.ts
2202
+ init_utils_json();
2192
2203
  function isOverrideActive() {
2193
2204
  return Boolean(process.env.NOMAD_REPO);
2194
2205
  }
@@ -2206,6 +2217,46 @@ function reportHostAndPaths(section2) {
2206
2217
  `${existsSync9(claude) ? green(okGlyph) : yellow(warnGlyph)} claude home: ${blue(claude)}`
2207
2218
  );
2208
2219
  }
2220
+ function pathMapHostKeys() {
2221
+ const mapPath = join11(repoHome(), "path-map.json");
2222
+ if (!existsSync9(mapPath)) return /* @__PURE__ */ new Set();
2223
+ let raw;
2224
+ try {
2225
+ raw = readJson(mapPath);
2226
+ } catch {
2227
+ return /* @__PURE__ */ new Set();
2228
+ }
2229
+ if (validatePathMapShape(raw) !== null) return /* @__PURE__ */ new Set();
2230
+ const keys = /* @__PURE__ */ new Set();
2231
+ for (const hosts of Object.values(raw.projects)) {
2232
+ for (const key of Object.keys(hosts)) keys.add(key);
2233
+ }
2234
+ return keys;
2235
+ }
2236
+ function hostOverrideLabels() {
2237
+ let entries;
2238
+ try {
2239
+ entries = readdirSync2(join11(repoHome(), "hosts"));
2240
+ } catch {
2241
+ return /* @__PURE__ */ new Set();
2242
+ }
2243
+ const labels = /* @__PURE__ */ new Set();
2244
+ for (const entry of entries) {
2245
+ if (entry.endsWith(".json")) labels.add(entry.slice(0, -".json".length));
2246
+ }
2247
+ return labels;
2248
+ }
2249
+ function reportHostKeyAlignment(section2) {
2250
+ if (process.env.NOMAD_HOST) return;
2251
+ const overrideLabels = hostOverrideLabels();
2252
+ const mapKeys = pathMapHostKeys();
2253
+ if (overrideLabels.has(HOST) || mapKeys.has(HOST)) return;
2254
+ if (overrideLabels.size === 0 && mapKeys.size === 0) return;
2255
+ addItem(
2256
+ section2,
2257
+ `${yellow(warnGlyph)} NOMAD_HOST unset: this repo configures other hosts, but the hostname key ${cyan(HOST)} matches no hosts/${HOST}.json or path-map entry; set NOMAD_HOST to the label this host should use so per-host settings and session sync line up`
2258
+ );
2259
+ }
2209
2260
  function reportRepoState(section2) {
2210
2261
  const repo = repoHome();
2211
2262
  const state = classifyRepoState(repo, HOST);
@@ -2301,7 +2352,7 @@ function reportDroppedNamesMigration(section2) {
2301
2352
  // src/commands.doctor.checks.settings.ts
2302
2353
  init_color();
2303
2354
  init_config();
2304
- import { existsSync as existsSync10, readdirSync as readdirSync2 } from "node:fs";
2355
+ import { existsSync as existsSync10, readdirSync as readdirSync3 } from "node:fs";
2305
2356
  import { join as join12 } from "node:path";
2306
2357
  function loadBaseSettings(section2) {
2307
2358
  const basePath = join12(repoHome(), "shared", "settings.base.json");
@@ -2347,7 +2398,7 @@ function reportHostOverrides(section2, base, settings) {
2347
2398
  );
2348
2399
  const hostsDir = join12(repo, "hosts");
2349
2400
  if (existsSync10(hostsDir)) {
2350
- const cands = readdirSync2(hostsDir).filter((f) => f.endsWith(".json"));
2401
+ const cands = readdirSync3(hostsDir).filter((f) => f.endsWith(".json"));
2351
2402
  if (cands.length > 0) addItem(section2, `${dim(infoGlyph)} candidates: ${cands.join(", ")}`);
2352
2403
  }
2353
2404
  process.exitCode = 1;
@@ -2362,7 +2413,7 @@ function reportHostOverrides(section2, base, settings) {
2362
2413
  // src/commands.doctor.checks.pathmap.ts
2363
2414
  init_color();
2364
2415
  init_config();
2365
- import { existsSync as existsSync11, readdirSync as readdirSync3 } from "node:fs";
2416
+ import { existsSync as existsSync11, readdirSync as readdirSync4 } from "node:fs";
2366
2417
  import { join as join13 } from "node:path";
2367
2418
  init_utils_json();
2368
2419
  function reportMappedProjects(section2, map) {
@@ -2377,7 +2428,7 @@ function reportUnmappedProjects(section2, map) {
2377
2428
  if (!existsSync11(localProjects)) return;
2378
2429
  let localDirs;
2379
2430
  try {
2380
- localDirs = readdirSync3(localProjects);
2431
+ localDirs = readdirSync4(localProjects);
2381
2432
  } catch {
2382
2433
  return;
2383
2434
  }
@@ -2666,13 +2717,13 @@ function reportGitIdentity(section2) {
2666
2717
 
2667
2718
  // src/commands.doctor.checks.backups.ts
2668
2719
  init_color();
2669
- import { existsSync as existsSync16, lstatSync as lstatSync7, readdirSync as readdirSync5 } from "node:fs";
2720
+ import { existsSync as existsSync16, lstatSync as lstatSync7, readdirSync as readdirSync6 } from "node:fs";
2670
2721
  import { join as join19 } from "node:path";
2671
2722
  init_config();
2672
2723
  var TS_SHAPE2 = /^\d{8}-\d{6}(-\d+)?$/;
2673
2724
  function safeReaddir(dir) {
2674
2725
  try {
2675
- return readdirSync5(dir);
2726
+ return readdirSync6(dir);
2676
2727
  } catch {
2677
2728
  return [];
2678
2729
  }
@@ -2848,7 +2899,7 @@ function reportCheckSchema(section2) {
2848
2899
  init_color();
2849
2900
  import { randomBytes } from "node:crypto";
2850
2901
  import { execFileSync as execFileSync9 } from "node:child_process";
2851
- import { existsSync as existsSync19, mkdirSync as mkdirSync5, readdirSync as readdirSync7, rmSync as rmSync8 } from "node:fs";
2902
+ import { existsSync as existsSync19, mkdirSync as mkdirSync5, readdirSync as readdirSync8, rmSync as rmSync8 } from "node:fs";
2852
2903
  import { homedir as homedir4 } from "node:os";
2853
2904
  import { join as join24 } from "node:path";
2854
2905
 
@@ -2947,7 +2998,7 @@ init_config();
2947
2998
 
2948
2999
  // src/remap.ts
2949
3000
  init_config_sharedDirs_guard();
2950
- import { cpSync as cpSync4, existsSync as existsSync18, mkdirSync as mkdirSync4, readdirSync as readdirSync6, renameSync as renameSync3, rmSync as rmSync7, statSync as statSync4 } from "node:fs";
3001
+ import { cpSync as cpSync4, existsSync as existsSync18, mkdirSync as mkdirSync4, readdirSync as readdirSync7, renameSync as renameSync3, rmSync as rmSync7, statSync as statSync4 } from "node:fs";
2951
3002
  import { join as join23, relative as relative3, sep as sep3 } from "node:path";
2952
3003
 
2953
3004
  // src/extras-sync.guards.ts
@@ -3087,7 +3138,7 @@ function remapPush(ts, opts = {}) {
3087
3138
  const reverse = buildReverseMap(map);
3088
3139
  if (!existsSync18(localProjects)) return { unmapped, collisions: 0, pushed, wouldPush };
3089
3140
  if (!dryRun) mkdirSync4(repoProjects, { recursive: true });
3090
- for (const dir of readdirSync6(localProjects)) {
3141
+ for (const dir of readdirSync7(localProjects)) {
3091
3142
  if (dir.endsWith(TMP_SUFFIX)) continue;
3092
3143
  const logical = reverse.get(dir);
3093
3144
  if (!logical) {
@@ -3133,7 +3184,7 @@ function buildScanTree(tmpRoot) {
3133
3184
  }
3134
3185
  const localProjects = join24(claudeHome(), "projects");
3135
3186
  if (!existsSync19(localProjects)) return { logicalToEncoded, staged, malformed: false };
3136
- for (const dir of readdirSync7(localProjects)) {
3187
+ for (const dir of readdirSync8(localProjects)) {
3137
3188
  const logical = reverse.get(dir);
3138
3189
  if (!logical) continue;
3139
3190
  copyDirJsonlOnly(join24(localProjects, dir), join24(tmpRoot, "shared", "projects", logical));
@@ -3192,7 +3243,7 @@ function reportCheckShared(section2, gitleaksReady) {
3192
3243
 
3193
3244
  // src/commands.doctor.checks.hooks.scope.ts
3194
3245
  init_color();
3195
- import { existsSync as existsSync20, readFileSync as readFileSync6, readdirSync as readdirSync8, realpathSync as realpathSync2 } from "node:fs";
3246
+ import { existsSync as existsSync20, readFileSync as readFileSync6, readdirSync as readdirSync9, realpathSync as realpathSync2 } from "node:fs";
3196
3247
  import { dirname as dirname3, extname, join as join25 } from "node:path";
3197
3248
  init_config();
3198
3249
  function typeFromPackageJson(pkgPath) {
@@ -3245,7 +3296,7 @@ function remedy(family) {
3245
3296
  }
3246
3297
  function safeReaddir2(dir) {
3247
3298
  try {
3248
- return readdirSync8(dir);
3299
+ return readdirSync9(dir);
3249
3300
  } catch {
3250
3301
  return [];
3251
3302
  }
@@ -3811,7 +3862,7 @@ import { existsSync as existsSync26, statSync as statSync6 } from "node:fs";
3811
3862
  import { dirname as dirname5, join as join31 } from "node:path";
3812
3863
 
3813
3864
  // src/commands.redact.subtree.ts
3814
- import { existsSync as existsSync25, lstatSync as lstatSync8, readFileSync as readFileSync11, readdirSync as readdirSync9, statSync as statSync5, writeFileSync as writeFileSync4 } from "node:fs";
3865
+ import { existsSync as existsSync25, lstatSync as lstatSync8, readFileSync as readFileSync11, readdirSync as readdirSync10, statSync as statSync5, writeFileSync as writeFileSync4 } from "node:fs";
3815
3866
  import { join as join30 } from "node:path";
3816
3867
  init_utils_fs();
3817
3868
  init_utils();
@@ -3819,7 +3870,7 @@ function collectFiles(dir, out) {
3819
3870
  if (!existsSync25(dir)) return;
3820
3871
  const st = lstatSync8(dir);
3821
3872
  if (!st.isDirectory()) return;
3822
- for (const entry of readdirSync9(dir)) {
3873
+ for (const entry of readdirSync10(dir)) {
3823
3874
  const abs = join30(dir, entry);
3824
3875
  const lst = lstatSync8(abs);
3825
3876
  if (lst.isSymbolicLink()) continue;
@@ -4580,7 +4631,7 @@ function reportOptionalDeps(section2, run = execFileSync12) {
4580
4631
  } else {
4581
4632
  addItem(
4582
4633
  section2,
4583
- `${yellow(warnGlyph)} gh: not installed (optional; needed for nomad init Actions auto-disable + the Actions-drift check)`
4634
+ `${yellow(warnGlyph)} gh: not installed (optional; nomad init needs it to create the sync repo and auto-disable Actions, and the Actions-drift check uses it)`
4584
4635
  );
4585
4636
  }
4586
4637
  reportFetcherRow(section2, run);
@@ -4736,6 +4787,7 @@ function compactSections(sections) {
4736
4787
  function gatherDoctorSections(opts) {
4737
4788
  const host = section("Environment");
4738
4789
  reportHostAndPaths(host);
4790
+ reportHostKeyAlignment(host);
4739
4791
  reportRepoState(host);
4740
4792
  const links = section("Shared links");
4741
4793
  const mapPath = join36(repoHome(), "path-map.json");
@@ -4836,7 +4888,7 @@ function parseDoctorArgs(args) {
4836
4888
  // src/commands.drop-session.ts
4837
4889
  init_config();
4838
4890
  import { execFileSync as execFileSync16 } from "node:child_process";
4839
- import { existsSync as existsSync32, readdirSync as readdirSync10, statSync as statSync8 } from "node:fs";
4891
+ import { existsSync as existsSync32, readdirSync as readdirSync11, statSync as statSync8 } from "node:fs";
4840
4892
  import { join as join38, relative as relative4 } from "node:path";
4841
4893
 
4842
4894
  // src/commands.drop-session.git.ts
@@ -4949,7 +5001,7 @@ function cmdDropSession(id) {
4949
5001
  }
4950
5002
  function collectMatches(repoProjects, id, repo) {
4951
5003
  const matches = [];
4952
- for (const logical of readdirSync10(repoProjects)) {
5004
+ for (const logical of readdirSync11(repoProjects)) {
4953
5005
  const candidate = join38(repoProjects, logical, `${id}.jsonl`);
4954
5006
  if (existsSync32(candidate)) {
4955
5007
  matches.push(relative4(repo, candidate));
@@ -5086,12 +5138,12 @@ init_config();
5086
5138
 
5087
5139
  // src/extras-sync.ts
5088
5140
  init_config();
5089
- import { existsSync as existsSync35 } from "node:fs";
5141
+ import { existsSync as existsSync35, statSync as statSync9 } from "node:fs";
5090
5142
  import { join as join42 } from "node:path";
5091
5143
 
5092
5144
  // src/extras-sync.core.ts
5093
5145
  init_config();
5094
- import { cpSync as cpSync6, existsSync as existsSync33, lstatSync as lstatSync9, readdirSync as readdirSync11, rmSync as rmSync11 } from "node:fs";
5146
+ import { cpSync as cpSync6, existsSync as existsSync33, lstatSync as lstatSync9, readdirSync as readdirSync12, rmSync as rmSync11 } from "node:fs";
5095
5147
  import { basename, join as join39 } from "node:path";
5096
5148
  init_utils();
5097
5149
  init_utils_json();
@@ -5132,7 +5184,7 @@ function* eachExtrasTarget(v, counts) {
5132
5184
  }
5133
5185
  function stripCollidingDstSymlinks(src, dst, isExcluded) {
5134
5186
  if (!existsSync33(dst)) return;
5135
- for (const name of readdirSync11(src)) {
5187
+ for (const name of readdirSync12(src)) {
5136
5188
  if (isExcluded(name)) continue;
5137
5189
  const dstPath = join39(dst, name);
5138
5190
  const dstStat = lstatSync9(dstPath, { throwIfNoEntry: false });
@@ -5180,7 +5232,7 @@ function copyExtrasFiltered(src, dst, blockSet) {
5180
5232
  });
5181
5233
  }
5182
5234
  function prunePreservingDenied(src, dst, blockSet) {
5183
- for (const name of readdirSync11(dst)) {
5235
+ for (const name of readdirSync12(dst)) {
5184
5236
  if (isDeniedName(blockSet, name)) continue;
5185
5237
  const dstPath = join39(dst, name);
5186
5238
  const srcStat = lstatSync9(join39(src, name), { throwIfNoEntry: false });
@@ -5211,7 +5263,7 @@ function copyExtrasFilteredPreserving(src, dst, blockSet) {
5211
5263
  });
5212
5264
  }
5213
5265
  function prunePreservingBy(src, dst, isPreserved) {
5214
- for (const name of readdirSync11(dst)) {
5266
+ for (const name of readdirSync12(dst)) {
5215
5267
  if (isPreserved(name)) continue;
5216
5268
  const dstPath = join39(dst, name);
5217
5269
  const srcStat = lstatSync9(join39(src, name), { throwIfNoEntry: false });
@@ -5248,7 +5300,7 @@ init_utils_json();
5248
5300
 
5249
5301
  // src/extras-sync.remap.ts
5250
5302
  init_config();
5251
- import { existsSync as existsSync34, mkdirSync as mkdirSync7, readdirSync as readdirSync12, realpathSync as realpathSync4, rmSync as rmSync12 } from "node:fs";
5303
+ import { existsSync as existsSync34, mkdirSync as mkdirSync7, readdirSync as readdirSync13, realpathSync as realpathSync4, rmSync as rmSync12 } from "node:fs";
5252
5304
  import { dirname as dirname7, join as join41, sep as sep7 } from "node:path";
5253
5305
 
5254
5306
  // src/extras-sync.planning-diff.ts
@@ -5349,7 +5401,7 @@ function pruneEmptyAncestors(target, planningRoot) {
5349
5401
  let dir = dirname7(target);
5350
5402
  while (dir !== planningRoot && dir.startsWith(planningRoot + sep7)) {
5351
5403
  try {
5352
- if (readdirSync12(dir).length > 0) break;
5404
+ if (readdirSync13(dir).length > 0) break;
5353
5405
  rmSync12(dir, { recursive: true, force: true });
5354
5406
  } catch {
5355
5407
  break;
@@ -5482,6 +5534,15 @@ function remapExtrasPull(ts, opts = {}) {
5482
5534
  }
5483
5535
 
5484
5536
  // src/extras-sync.ts
5537
+ function divergenceWarnLine(o) {
5538
+ const kind = o.isDir ? "folder" : "file";
5539
+ const name = o.isDir ? `${o.dirname}/` : o.dirname;
5540
+ const one = o.count === 1;
5541
+ const fileCount = one ? "1 file" : `${o.count} files`;
5542
+ const them = one ? "it" : "them";
5543
+ const yours = one ? "your current file is" : "your current files are";
5544
+ return `local ${kind} ${name} in repo ${o.logical} differs from the synced copy in ${fileCount}; the next pull step will overwrite ${them} with the synced version (${yours} backed up to ${o.projectBackupRoot}/)`;
5545
+ }
5485
5546
  function divergenceCheckExtras(ts) {
5486
5547
  const v = loadValidatedExtras({});
5487
5548
  if (v === null) return;
@@ -5496,7 +5557,13 @@ function divergenceCheckExtras(ts) {
5496
5557
  if (diff.length === 0) continue;
5497
5558
  const projectBackupRoot = join42(backupRoot, encodePath(localRoot));
5498
5559
  warn(
5499
- `local ${dirname8} for ${logical} diverges from origin in ${diff.length} file(s); next remapExtrasPull will merge changes (.planning overlays, .claude/.CLAUDE.md mirror; backups at ${projectBackupRoot}/)`
5560
+ divergenceWarnLine({
5561
+ dirname: dirname8,
5562
+ logical,
5563
+ isDir: statSync9(local).isDirectory(),
5564
+ count: diff.length,
5565
+ projectBackupRoot
5566
+ })
5500
5567
  );
5501
5568
  for (const f of diff) warn(` ${f}`);
5502
5569
  }
@@ -5504,7 +5571,7 @@ function divergenceCheckExtras(ts) {
5504
5571
 
5505
5572
  // src/skills-sync.ts
5506
5573
  init_config();
5507
- import { existsSync as existsSync36, lstatSync as lstatSync10, mkdirSync as mkdirSync8, readdirSync as readdirSync13, rmSync as rmSync13 } from "node:fs";
5574
+ import { existsSync as existsSync36, lstatSync as lstatSync10, mkdirSync as mkdirSync8, readdirSync as readdirSync14, rmSync as rmSync13 } from "node:fs";
5508
5575
  import { join as join43 } from "node:path";
5509
5576
  init_utils_fs();
5510
5577
  function isGsdOwned(name) {
@@ -5514,7 +5581,7 @@ function isSkillExcluded(name) {
5514
5581
  return isGsdOwned(name) || isDeniedName(ALWAYS_NEVER_SYNC, name);
5515
5582
  }
5516
5583
  function copySkillsPush(src, dst) {
5517
- const srcNames = readdirSync13(src, { encoding: "utf8" });
5584
+ const srcNames = readdirSync14(src, { encoding: "utf8" });
5518
5585
  const blockSet = /* @__PURE__ */ new Set([
5519
5586
  ...srcNames.filter((n) => isGsdOwned(n)),
5520
5587
  ...ALWAYS_NEVER_SYNC
@@ -6316,7 +6383,7 @@ init_color();
6316
6383
  init_config();
6317
6384
  init_config_sharedDirs_guard();
6318
6385
  import { randomBytes as randomBytes2 } from "node:crypto";
6319
- import { copyFileSync, existsSync as existsSync39, mkdirSync as mkdirSync10, readdirSync as readdirSync14, rmSync as rmSync14 } from "node:fs";
6386
+ import { copyFileSync, existsSync as existsSync39, mkdirSync as mkdirSync10, readdirSync as readdirSync15, rmSync as rmSync14 } from "node:fs";
6320
6387
  import { homedir as homedir5 } from "node:os";
6321
6388
  import { join as join46 } from "node:path";
6322
6389
  init_push_leak_verdict();
@@ -6336,7 +6403,7 @@ function stageSessions(tmpRoot, map) {
6336
6403
  const localProjects = join46(claudeHome(), "projects");
6337
6404
  if (!existsSync39(localProjects)) return 0;
6338
6405
  let staged = 0;
6339
- for (const dir of readdirSync14(localProjects)) {
6406
+ for (const dir of readdirSync15(localProjects)) {
6340
6407
  const logical = reverse.get(dir);
6341
6408
  if (!logical) continue;
6342
6409
  copyDirJsonlOnly(join46(localProjects, dir), join46(tmpRoot, "shared", "projects", logical));
@@ -6427,9 +6494,9 @@ function reportSettingsAheadDrift(repo) {
6427
6494
  const { ahead } = classifySettingsDrift(merged, settings);
6428
6495
  const { promotable } = partitionByCaptureExclusion(ahead);
6429
6496
  if (promotable.length === 0) return;
6430
- const keys = JSON.stringify(promotable);
6497
+ const { phrase, pronoun, verb } = describeSettings(promotable);
6431
6498
  warn(
6432
- `settings.json has local-only keys ${keys} not in the repo. Run 'nomad capture-settings' to promote them (or 'nomad capture-settings --host' for host-specific values).`
6499
+ `your settings.json has ${phrase} that ${verb} not yet in the repo; run 'nomad capture-settings' to save ${pronoun} (or 'nomad capture-settings --host' for host-specific values).`
6433
6500
  );
6434
6501
  } catch {
6435
6502
  }
@@ -6697,7 +6764,7 @@ init_config();
6697
6764
  init_utils();
6698
6765
  init_utils_fs();
6699
6766
  init_utils_json();
6700
- import { copyFileSync as copyFileSync2, cpSync as cpSync7, existsSync as existsSync42, rmSync as rmSync15, statSync as statSync9 } from "node:fs";
6767
+ import { copyFileSync as copyFileSync2, cpSync as cpSync7, existsSync as existsSync42, rmSync as rmSync15, statSync as statSync10 } from "node:fs";
6701
6768
  import { join as join49 } from "node:path";
6702
6769
  function snapshotIntoShared(map) {
6703
6770
  const repo = repoHome();
@@ -6706,7 +6773,7 @@ function snapshotIntoShared(map) {
6706
6773
  const src = join49(claude, name);
6707
6774
  if (!existsSync42(src)) continue;
6708
6775
  const dst = join49(repo, "shared", name);
6709
- if (statSync9(src).isDirectory()) {
6776
+ if (statSync10(src).isDirectory()) {
6710
6777
  const gk = join49(dst, ".gitkeep");
6711
6778
  if (existsSync42(gk)) rmSync15(gk);
6712
6779
  cpSync7(src, dst, { recursive: true, force: false, errorOnExist: true });
@@ -6747,6 +6814,9 @@ function preflightConflict(repoHome2) {
6747
6814
  }
6748
6815
  return null;
6749
6816
  }
6817
+ function isAlreadyInitialized(repoHome2) {
6818
+ return preflightConflict(repoHome2) !== null;
6819
+ }
6750
6820
  function cmdInit(opts = {}) {
6751
6821
  const snapshot = opts.snapshot === true;
6752
6822
  const keepActions = opts.keepActions === true;
@@ -6839,6 +6909,47 @@ function maybeDisableRepoActions(repoHome2, run) {
6839
6909
  }
6840
6910
  }
6841
6911
 
6912
+ // src/init.prompt.ts
6913
+ init_config();
6914
+ init_utils();
6915
+ import { existsSync as existsSync44, readdirSync as readdirSync16, statSync as statSync11 } from "node:fs";
6916
+ import { join as join51 } from "node:path";
6917
+ import { createInterface as createInterface3 } from "node:readline/promises";
6918
+ function nonEmptyExists(path) {
6919
+ if (!existsSync44(path)) return false;
6920
+ try {
6921
+ if (statSync11(path).isDirectory()) return readdirSync16(path).length > 0;
6922
+ return true;
6923
+ } catch {
6924
+ return false;
6925
+ }
6926
+ }
6927
+ function hasExistingClaudeConfig(claudeHome2) {
6928
+ if (existsSync44(join51(claudeHome2, "settings.json"))) return true;
6929
+ return SHARED_LINKS.some((name) => nonEmptyExists(join51(claudeHome2, name)));
6930
+ }
6931
+ async function confirmSnapshotDefault(claudeHome2) {
6932
+ if (process.stdin.isTTY !== true || process.stdout.isTTY !== true) {
6933
+ log(
6934
+ `tip: ${claudeHome2} already has config; re-run 'nomad init --snapshot' to seed the repo from it.`
6935
+ );
6936
+ return false;
6937
+ }
6938
+ log(`Found existing config in ${claudeHome2}.`);
6939
+ const rl = createInterface3({ input: process.stdin, output: process.stdout });
6940
+ try {
6941
+ const answer = await rl.question("Seed the new repo from it? [Y/n] ");
6942
+ return !/^n(o)?$/i.test(answer.trim());
6943
+ } finally {
6944
+ rl.close();
6945
+ }
6946
+ }
6947
+ async function resolveSnapshotChoice(flagSnapshot, claudeHome2, confirm = confirmSnapshotDefault) {
6948
+ if (flagSnapshot) return true;
6949
+ if (!hasExistingClaudeConfig(claudeHome2)) return false;
6950
+ return confirm(claudeHome2);
6951
+ }
6952
+
6842
6953
  // src/nomad.dispatch.helpers.ts
6843
6954
  var REJECT = { ok: false, advance: 0 };
6844
6955
  function applyBool(seen, set) {
@@ -7098,7 +7209,7 @@ function parsePushArgs(argv) {
7098
7209
  // package.json
7099
7210
  var package_default = {
7100
7211
  name: "claude-nomad",
7101
- version: "0.54.0",
7212
+ version: "0.55.0",
7102
7213
  type: "module",
7103
7214
  description: "Sync Claude Code config (~/.claude/) across machines via a private Git repo, with path remapping and per-host settings overrides.",
7104
7215
  keywords: [
@@ -7230,7 +7341,8 @@ var DEFAULT_HELP = [
7230
7341
  " init",
7231
7342
  "Create a private GitHub repo via gh (if none exists), scaffold shared/, hosts/, path-map."
7232
7343
  ),
7233
- row(" --snapshot", "Overlay the current ~/.claude/ into shared/ as the initial seed."),
7344
+ row(" --snapshot", "Seed shared/ from the current ~/.claude/. Without this flag, init"),
7345
+ cont("offers the same seed interactively when it detects existing config."),
7234
7346
  row(" --keep-actions", "Skip auto-disabling GitHub Actions on the private repo."),
7235
7347
  row(
7236
7348
  " --repo <name>",
@@ -7308,7 +7420,8 @@ var DEFAULT_HELP = [
7308
7420
  "",
7309
7421
  row(" --version", "Print the installed CLI version as bare semver to stdout; exits 0."),
7310
7422
  "",
7311
- "Run `nomad doctor` to validate your setup. Edit shared/ or hosts/<HOST>.json",
7423
+ "You edit the sync repo (~/claude-nomad/); nomad regenerates ~/.claude/ on every",
7424
+ "pull. Run `nomad doctor` to validate your setup. Edit shared/ or hosts/<HOST>.json",
7312
7425
  "in the repo, never ~/.claude/settings.json directly (it is regenerated on",
7313
7426
  "every pull)."
7314
7427
  ].join("\n");
@@ -7317,15 +7430,15 @@ var DEFAULT_HELP = [
7317
7430
  init_config();
7318
7431
  init_utils();
7319
7432
  init_utils_json();
7320
- import { existsSync as existsSync44, readFileSync as readFileSync14, readdirSync as readdirSync15 } from "node:fs";
7321
- import { join as join51 } from "node:path";
7433
+ import { existsSync as existsSync45, readFileSync as readFileSync14, readdirSync as readdirSync17 } from "node:fs";
7434
+ import { join as join52 } from "node:path";
7322
7435
  function resumeCmd(sessionId) {
7323
7436
  if (!/^[A-Za-z0-9_-]+$/.test(sessionId) || sessionId.length > 128) {
7324
7437
  fail(`invalid session id: ${sessionId}`);
7325
7438
  process.exit(1);
7326
7439
  }
7327
- const projectsRoot = join51(claudeHome(), "projects");
7328
- if (!existsSync44(projectsRoot)) {
7440
+ const projectsRoot = join52(claudeHome(), "projects");
7441
+ if (!existsSync45(projectsRoot)) {
7329
7442
  fail(`${projectsRoot} does not exist`);
7330
7443
  process.exit(1);
7331
7444
  }
@@ -7339,8 +7452,8 @@ function resumeCmd(sessionId) {
7339
7452
  fail(`no cwd field found in ${jsonlPath}`);
7340
7453
  process.exit(1);
7341
7454
  }
7342
- const mapPath = join51(repoHome(), "path-map.json");
7343
- if (!existsSync44(mapPath)) {
7455
+ const mapPath = join52(repoHome(), "path-map.json");
7456
+ if (!existsSync45(mapPath)) {
7344
7457
  fail("path-map.json missing");
7345
7458
  process.exit(1);
7346
7459
  }
@@ -7362,9 +7475,9 @@ function resumeCmd(sessionId) {
7362
7475
  console.log(`cd ${shQuote(hit.localPath)} && claude --resume ${shQuote(sessionId)}`);
7363
7476
  }
7364
7477
  function findTranscriptPath(projectsRoot, sessionId) {
7365
- for (const dir of readdirSync15(projectsRoot)) {
7366
- const candidate = join51(projectsRoot, dir, `${sessionId}.jsonl`);
7367
- if (existsSync44(candidate)) return candidate;
7478
+ for (const dir of readdirSync17(projectsRoot)) {
7479
+ const candidate = join52(projectsRoot, dir, `${sessionId}.jsonl`);
7480
+ if (existsSync45(candidate)) return candidate;
7368
7481
  }
7369
7482
  return null;
7370
7483
  }
@@ -7447,8 +7560,9 @@ try {
7447
7560
  console.error("usage: nomad init [--snapshot] [--keep-actions] [--repo <name>]");
7448
7561
  process.exit(1);
7449
7562
  }
7563
+ const snapshot = isAlreadyInitialized(repoHome()) ? initArgs.snapshot : await resolveSnapshotChoice(initArgs.snapshot, claudeHome());
7450
7564
  cmdInit({
7451
- snapshot: initArgs.snapshot,
7565
+ snapshot,
7452
7566
  keepActions: initArgs.keepActions,
7453
7567
  repoName: initArgs.repoName
7454
7568
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-nomad",
3
- "version": "0.54.0",
3
+ "version": "0.55.0",
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": [