claude-nomad 0.56.2 → 0.57.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,24 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.57.0](https://github.com/funkadelic/claude-nomad/compare/v0.56.2...v0.57.0) (2026-07-03)
4
+
5
+
6
+ ### Added
7
+
8
+ * **docs-site:** add starlight-llms-txt plugin ([#378](https://github.com/funkadelic/claude-nomad/issues/378)) ([0604327](https://github.com/funkadelic/claude-nomad/commit/0604327ffa69abbee0b70183a49416ec14a3306c))
9
+ * **sync:** add unified nomad sync command ([#380](https://github.com/funkadelic/claude-nomad/issues/380)) ([e60a05e](https://github.com/funkadelic/claude-nomad/commit/e60a05e62179751652d70e54ba3deb8b6b7c4859))
10
+
11
+
12
+ ### Fixed
13
+
14
+ * **plugin:** tolerate a user-supplied --dry-run in push and sync wrappers ([#381](https://github.com/funkadelic/claude-nomad/issues/381)) ([7ce0a99](https://github.com/funkadelic/claude-nomad/commit/7ce0a99ae53cd8737b5e03bb0f5f730447b1108d))
15
+
16
+
17
+ ### Changed
18
+
19
+ * remove vestigial private-repo guards from workflows ([#383](https://github.com/funkadelic/claude-nomad/issues/383)) ([31e52b8](https://github.com/funkadelic/claude-nomad/commit/31e52b834ca55204dac29fcec6ceee35605f818b))
20
+ * scrub internal planning IDs from source and test comments ([#382](https://github.com/funkadelic/claude-nomad/issues/382)) ([b024b7b](https://github.com/funkadelic/claude-nomad/commit/b024b7b1b877af347db7ca1754751ede73b5879f))
21
+
3
22
  ## [0.56.2](https://github.com/funkadelic/claude-nomad/compare/v0.56.1...v0.56.2) (2026-07-01)
4
23
 
5
24
 
package/README.md CHANGED
@@ -13,8 +13,9 @@
13
13
 
14
14
  Open Claude Code on a second machine and it is a blank slate: none of your custom skills, slash
15
15
  commands, tuned settings, or past conversations. **claude-nomad** keeps all of it in sync through a
16
- private Git repo you control. `nomad push` on one machine, `nomad pull` on the next, and everything
17
- is there, conversations included.
16
+ private Git repo you control. Run `nomad sync` on any machine and everything is there, conversations
17
+ included; it pulls in your latest config first, then publishes your local changes, so you never have
18
+ to remember which one to run first.
18
19
 
19
20
  Not dotfiles, not rsync. **claude-nomad** understands Claude Code's state, so your session history
20
21
  survives different file paths and your secrets never ride along.
@@ -117,14 +118,19 @@ Everyday loop on any host:
117
118
 
118
119
  ```bash
119
120
  $ nomad doctor # confirm setup
120
- $ nomad pull # apply config to ~/.claude/
121
- $ nomad push # publish local changes (sessions, settings)
121
+ $ nomad sync # pull config, then publish local changes, in one step
122
122
  ```
123
123
 
124
- Pull before you push whenever both machines may have changed. Sync is last-write-wins, so pushing
125
- stale local state over newer remote state silently overwrites it. The
126
- [FAQ](https://funkadelic.github.io/claude-nomad/faq/) covers the full push/pull order when both
127
- sides have changed.
124
+ `nomad sync` is the command to reach for day to day: it always pulls first (so changes from your
125
+ other machines land before anything is pushed, and work that exists only on this machine is kept,
126
+ not deleted) and then pushes, under one lock, so there is no ordering to remember. `nomad pull` and
127
+ `nomad push` are still available as lower-level commands for cases `sync` does not cover: recovering
128
+ a wedged repo with `nomad pull --force-remote`, or resolving a detected secret without the
129
+ interactive menu via `nomad push --redact-all` / `--allow` / `--allow-all` (see
130
+ [Changing settings](#changing-settings) and
131
+ [Recovery flows](https://funkadelic.github.io/claude-nomad/recovery/)). The
132
+ [FAQ](https://funkadelic.github.io/claude-nomad/faq/) covers what `sync` does under the hood and the
133
+ push/pull order it enforces.
128
134
 
129
135
  ### Make your sessions follow you
130
136
 
@@ -216,10 +222,10 @@ first (minimum version `>= 0.35.0`), then add the plugin.
216
222
  /plugin install nomad@claude-nomad
217
223
  ```
218
224
 
219
- It adds `/nomad:pull`, `/nomad:diff`, `/nomad:push` (preview only), `/nomad:doctor`, and
220
- `/nomad:clean`, plus a session-start drift check. The plugin versions independently from the CLI,
221
- but requires nomad `>= 0.35.0` because it calls recent subcommands (`nomad diff`,
222
- `nomad clean --backups`) and reads the doctor command's status output. See the
225
+ It adds `/nomad:sync` (preview only), `/nomad:pull`, `/nomad:diff`, `/nomad:push` (preview only),
226
+ `/nomad:doctor`, and `/nomad:clean`, plus a session-start drift check. The plugin versions
227
+ independently from the CLI, but requires nomad `>= 0.35.0` because it calls recent subcommands
228
+ (`nomad diff`, `nomad clean --backups`) and reads the doctor command's status output. See the
223
229
  [plugin guide](https://funkadelic.github.io/claude-nomad/plugin/) for details.
224
230
 
225
231
  ## Requirements
package/dist/nomad.mjs CHANGED
@@ -214,12 +214,13 @@ function gitOrFatal(args, context, cwd) {
214
214
  throw new NomadFatal(`${context} failed`);
215
215
  }
216
216
  }
217
- var log, warn, fail, item, NomadFatal, die, gitStatusPorcelainZ;
217
+ var log, ok, warn, fail, item, NomadFatal, die, gitStatusPorcelainZ;
218
218
  var init_utils = __esm({
219
219
  "src/utils.ts"() {
220
220
  "use strict";
221
221
  init_color();
222
222
  log = (msg) => console.log(`${dim(infoGlyph)} ${msg}`);
223
+ ok = (msg) => console.log(`${green(okGlyph)} ${msg}`);
223
224
  warn = (msg) => {
224
225
  console.error(`${yellow(warnGlyph)} ${msg}`);
225
226
  };
@@ -468,7 +469,7 @@ var init_config = __esm({
468
469
  "hosts/",
469
470
  "path-map.json",
470
471
  ".gitleaksignore",
471
- // written by nomad push Allow action (D-04)
472
+ // written by nomad push Allow action
472
473
  ".gitleaks.overlay.toml"
473
474
  // user-owned gitleaks allowlist overlay layered on the bundled base
474
475
  ];
@@ -5804,7 +5805,7 @@ function remapExtrasPush(ts, opts = {}) {
5804
5805
  // Repo-only files survive; local edits propagate (overlay overwrites).
5805
5806
  // The filter prevents ALWAYS_NEVER_SYNC files from landing in the repo
5806
5807
  // working tree before the allow-list gate fires, eliminating the
5807
- // "residue wedges repeat push" regression (WR-02). The allow-list gate
5808
+ // "residue wedges repeat push" regression. The allow-list gate
5808
5809
  // (enforceAllowList / blockSetFor in commands.push.allowlist.ts)
5809
5810
  // remains the hard security boundary.
5810
5811
  // All others: copyExtrasFiltered with per-extra denylist.
@@ -5871,16 +5872,18 @@ function divergenceWarnLine(o) {
5871
5872
  }
5872
5873
  function divergenceCheckExtras(ts, prePostHeads) {
5873
5874
  const v = loadValidatedExtras({});
5874
- if (v === null) return;
5875
+ if (v === null) return 0;
5875
5876
  const counts = { unmapped: 0, skipped: 0 };
5876
5877
  const backupRoot = join43(backupBase(), ts, "extras");
5877
5878
  const repo = repoHome();
5879
+ let divergedCount = 0;
5878
5880
  for (const { logical, localRoot, dirname: dirname10 } of eachExtrasTarget(v, counts)) {
5879
5881
  const local = join43(localRoot, dirname10);
5880
5882
  const repoEntry = join43(repo, "shared", "extras", logical, dirname10);
5881
5883
  if (!existsSync36(local) || !existsSync36(repoEntry)) continue;
5882
5884
  const modified = listDivergingModified(local, repoEntry);
5883
5885
  if (modified.length === 0) continue;
5886
+ divergedCount += modified.length;
5884
5887
  const projectBackupRoot = join43(backupRoot, encodePath(localRoot));
5885
5888
  warn(
5886
5889
  divergenceWarnLine({
@@ -5898,6 +5901,7 @@ function divergenceCheckExtras(ts, prePostHeads) {
5898
5901
  warn(keptDeleteWarnLine(logical, relToLocal));
5899
5902
  }
5900
5903
  }
5904
+ return divergedCount;
5901
5905
  }
5902
5906
 
5903
5907
  // src/skills-sync.ts
@@ -6458,7 +6462,7 @@ function capturePrePostHeads(repo, rebase) {
6458
6462
  if (pre === void 0 || post === void 0) return void 0;
6459
6463
  return { pre, post };
6460
6464
  }
6461
- function applyWetPull(ts, map, prePostHeads) {
6465
+ function buildWetPullSections(ts, map, prePostHeads) {
6462
6466
  applySharedLinks(ts, map);
6463
6467
  const { label } = regenerateSettings(ts);
6464
6468
  syncSkillsPull(ts);
@@ -6476,12 +6480,15 @@ function applyWetPull(ts, map, prePostHeads) {
6476
6480
  localOnly
6477
6481
  )
6478
6482
  );
6479
- renderTree([
6480
- buildSettingsSection(label),
6481
- buildSessionsSection(remapResult.pulled, remapResult.unmapped, localOnly),
6482
- buildExtrasSection(extrasResult.pulled, extrasResult.skipped),
6483
- summary
6484
- ]);
6483
+ return {
6484
+ sections: [
6485
+ buildSettingsSection(label),
6486
+ buildSessionsSection(remapResult.pulled, remapResult.unmapped, localOnly),
6487
+ buildExtrasSection(extrasResult.pulled, extrasResult.skipped),
6488
+ summary
6489
+ ],
6490
+ localOnly
6491
+ };
6485
6492
  }
6486
6493
  function handleWedge(repo, forceRemote) {
6487
6494
  const wedge = classifyWedge(repo);
@@ -6501,11 +6508,40 @@ function handleWedge(repo, forceRemote) {
6501
6508
  const state = wedge === "rebase" ? "mid-rebase" : "mid-merge";
6502
6509
  die(wedgeMarkerRunbookText(state));
6503
6510
  }
6504
- function cmdPull(opts = {}) {
6511
+ function runPullCore(opts = {}) {
6505
6512
  const dryRun = opts.dryRun === true;
6506
6513
  const forceRemote = opts.forceRemote === true;
6507
6514
  const repo = repoHome();
6508
6515
  const backup = backupBase();
6516
+ const ts = freshBackupTs(backup);
6517
+ handleWedge(repo, forceRemote);
6518
+ if (!dryRun) {
6519
+ const backupRoot = join46(backup, ts);
6520
+ try {
6521
+ mkdirSync10(backupRoot, { recursive: true });
6522
+ } catch (err) {
6523
+ die(`could not create backup dir: ${err.message}`);
6524
+ }
6525
+ }
6526
+ log(
6527
+ dryRun ? `pulling on host=${HOST} (backup=${ts}; dry-run)` : `pull on host=${HOST} (backup=${ts})`
6528
+ );
6529
+ const prePostHeads = capturePrePostHeads(repo, () => {
6530
+ gitOrFatal(["pull", "--rebase", "--autostash"], "git pull --rebase", repo);
6531
+ });
6532
+ const mapPath = join46(repo, "path-map.json");
6533
+ const map = existsSync39(mapPath) ? readPathMap(mapPath) : { projects: {} };
6534
+ const divergedKeptLocal = divergenceCheckExtras(ts, dryRun ? prePostHeads : void 0);
6535
+ if (dryRun) {
6536
+ computePreview(ts, map, "pull");
6537
+ log("dry-run complete; no mutation");
6538
+ return { tag: "dry" };
6539
+ }
6540
+ const { sections, localOnly } = buildWetPullSections(ts, map, prePostHeads);
6541
+ return { tag: "wet", sections, localOnly, divergedKeptLocal };
6542
+ }
6543
+ function cmdPull(opts = {}) {
6544
+ const repo = repoHome();
6509
6545
  if (!existsSync39(repo)) die(`repo not cloned at ${repo}`);
6510
6546
  if (!existsSync39(join46(repo, "shared", "settings.base.json"))) {
6511
6547
  die("repo not initialized; run 'nomad init' to scaffold");
@@ -6513,31 +6549,8 @@ function cmdPull(opts = {}) {
6513
6549
  const handle = acquireLock("pull");
6514
6550
  if (handle === null) process.exit(0);
6515
6551
  try {
6516
- const ts = freshBackupTs(backup);
6517
- handleWedge(repo, forceRemote);
6518
- if (!dryRun) {
6519
- const backupRoot = join46(backup, ts);
6520
- try {
6521
- mkdirSync10(backupRoot, { recursive: true });
6522
- } catch (err) {
6523
- die(`could not create backup dir: ${err.message}`);
6524
- }
6525
- }
6526
- log(
6527
- dryRun ? `pulling on host=${HOST} (backup=${ts}; dry-run)` : `pull on host=${HOST} (backup=${ts})`
6528
- );
6529
- const prePostHeads = capturePrePostHeads(repo, () => {
6530
- gitOrFatal(["pull", "--rebase", "--autostash"], "git pull --rebase", repo);
6531
- });
6532
- const mapPath = join46(repo, "path-map.json");
6533
- const map = existsSync39(mapPath) ? readPathMap(mapPath) : { projects: {} };
6534
- divergenceCheckExtras(ts, dryRun ? prePostHeads : void 0);
6535
- if (dryRun) {
6536
- computePreview(ts, map, "pull");
6537
- log("dry-run complete; no mutation");
6538
- } else {
6539
- applyWetPull(ts, map, prePostHeads);
6540
- }
6552
+ const result = runPullCore(opts);
6553
+ if (result.tag === "wet") renderTree(result.sections);
6541
6554
  } catch (err) {
6542
6555
  if (err instanceof NomadFatal) {
6543
6556
  fail(err.message);
@@ -6962,7 +6975,7 @@ async function commitAndPush(st, ts, map, resolution, repo, newManifest) {
6962
6975
  if (staged.length === toDrop.length) {
6963
6976
  log("nothing to commit");
6964
6977
  renderNoScanTree(st);
6965
- return;
6978
+ return "nothing";
6966
6979
  }
6967
6980
  st.globalConfig = collectGlobalConfigChanges(repo, HOST, { staged: true });
6968
6981
  let verdict = withSpinner("Scanning for secrets", () => scanPushVerdict(repo));
@@ -6978,6 +6991,7 @@ async function commitAndPush(st, ts, map, resolution, repo, newManifest) {
6978
6991
  warn(`could not write push manifest (next push will full-rescan): ${String(err)}`);
6979
6992
  }
6980
6993
  renderPushTree(st, verdict);
6994
+ return "pushed";
6981
6995
  }
6982
6996
  function runDryRunPreview(st, map, repo, selection) {
6983
6997
  st.globalConfig = collectGlobalConfigChanges(repo, HOST, { staged: false });
@@ -6994,55 +7008,181 @@ function runDryRunPreview(st, map, repo, selection) {
6994
7008
  init_push_checks();
6995
7009
  init_utils();
6996
7010
  init_utils_fs();
6997
- async function cmdPush(opts = {}) {
7011
+ async function runPushCore(opts = {}) {
6998
7012
  const dryRun = opts.dryRun === true;
6999
7013
  const redactAll = opts.redactAll === true;
7000
7014
  const allowAll = opts.allowAll === true;
7001
7015
  const allowRule = opts.allowRule;
7002
7016
  const fullScan = opts.fullScan === true;
7003
- guardResolutionModeConflicts(dryRun, redactAll, allowAll, allowRule);
7004
7017
  const repo = repoHome();
7005
7018
  const backup = backupBase();
7019
+ console.log(dryRun ? `push on host=${HOST} (dry-run)` : `push on host=${HOST}`);
7020
+ reportSettingsAheadDrift(repo);
7021
+ const scannerVersion = probeGitleaks();
7022
+ const configHash = computeConfigHash();
7023
+ const old = readManifest(manifestPath());
7024
+ const mapPath = join51(repo, "path-map.json");
7025
+ const { map, selection, newManifest } = loadSelectionForPush(
7026
+ mapPath,
7027
+ old,
7028
+ scannerVersion,
7029
+ configHash,
7030
+ fullScan
7031
+ );
7032
+ withSpinner("Rebasing onto origin", () => rebaseBeforePush(repo));
7033
+ const ts = freshBackupTs(backup);
7034
+ const remap = withSpinner("Syncing sessions", () => remapPush(ts, { dryRun, selection }));
7035
+ const extras = withSpinner("Syncing extras", () => remapExtrasPush(ts, { dryRun }));
7036
+ if (!dryRun) {
7037
+ syncSkillsPush();
7038
+ stripGsdHooksFromBase(repo, backup);
7039
+ }
7040
+ const st = { dryRun, remap, extras, globalConfig: [] };
7041
+ guardGitlinks(repo);
7042
+ const status = gitStatusPorcelainZ(repo, { untrackedAll: true });
7043
+ if (!dryRun && !status) {
7044
+ log("nothing to commit");
7045
+ renderNoScanTree(st);
7046
+ return { tag: "nothing" };
7047
+ }
7048
+ if (map === null) {
7049
+ if (dryRun) {
7050
+ runDryRunPreview(st, null, repo, selection);
7051
+ return { tag: "dry" };
7052
+ }
7053
+ return die("path-map.json missing, cannot enforce push allow-list");
7054
+ }
7055
+ if (status) enforceAllowList(status, map);
7056
+ if (dryRun) {
7057
+ runDryRunPreview(st, map, repo, selection);
7058
+ return { tag: "dry" };
7059
+ }
7060
+ const outcome = await commitAndPush(
7061
+ st,
7062
+ ts,
7063
+ map,
7064
+ { redactAll, allowAll, allowRule },
7065
+ repo,
7066
+ newManifest
7067
+ );
7068
+ return outcome === "nothing" ? { tag: "nothing" } : { tag: "pushed" };
7069
+ }
7070
+ async function cmdPush(opts = {}) {
7071
+ const dryRun = opts.dryRun === true;
7072
+ const redactAll = opts.redactAll === true;
7073
+ const allowAll = opts.allowAll === true;
7074
+ const allowRule = opts.allowRule;
7075
+ guardResolutionModeConflicts(dryRun, redactAll, allowAll, allowRule);
7076
+ const repo = repoHome();
7006
7077
  if (!existsSync43(repo)) die(`repo not cloned at ${repo}`);
7007
7078
  const handle = acquireLock("push");
7008
7079
  if (handle === null) process.exit(0);
7009
7080
  try {
7010
- console.log(dryRun ? `push on host=${HOST} (dry-run)` : `push on host=${HOST}`);
7011
- reportSettingsAheadDrift(repo);
7012
- const scannerVersion = probeGitleaks();
7013
- const configHash = computeConfigHash();
7014
- const old = readManifest(manifestPath());
7015
- const mapPath = join51(repo, "path-map.json");
7016
- const { map, selection, newManifest } = loadSelectionForPush(
7017
- mapPath,
7018
- old,
7019
- scannerVersion,
7020
- configHash,
7021
- fullScan
7022
- );
7023
- withSpinner("Rebasing onto origin", () => rebaseBeforePush(repo));
7024
- const ts = freshBackupTs(backup);
7025
- const remap = withSpinner("Syncing sessions", () => remapPush(ts, { dryRun, selection }));
7026
- const extras = withSpinner("Syncing extras", () => remapExtrasPush(ts, { dryRun }));
7027
- if (!dryRun) {
7028
- syncSkillsPush();
7029
- stripGsdHooksFromBase(repo, backup);
7030
- }
7031
- const st = { dryRun, remap, extras, globalConfig: [] };
7032
- guardGitlinks(repo);
7033
- const status = gitStatusPorcelainZ(repo, { untrackedAll: true });
7034
- if (!dryRun && !status) {
7035
- log("nothing to commit");
7036
- renderNoScanTree(st);
7037
- return;
7081
+ await runPushCore(opts);
7082
+ } catch (err) {
7083
+ if (err instanceof NomadFatal) {
7084
+ fail(err.message);
7085
+ process.exitCode = 1;
7086
+ } else {
7087
+ throw err;
7038
7088
  }
7039
- if (map === null) {
7040
- if (dryRun) return runDryRunPreview(st, null, repo, selection);
7041
- return die("path-map.json missing, cannot enforce push allow-list");
7089
+ } finally {
7090
+ releaseLock(handle);
7091
+ }
7092
+ }
7093
+
7094
+ // src/commands.sync.ts
7095
+ import { existsSync as existsSync44 } from "node:fs";
7096
+ import { join as join52 } from "node:path";
7097
+ init_config();
7098
+ init_utils();
7099
+ init_utils_fs();
7100
+ init_utils_json();
7101
+ function pullHasNoSyncedItems(sections) {
7102
+ return sections.every(
7103
+ (s) => s.header === "Settings" || s.header === "Summary" || s.items.length === 0
7104
+ );
7105
+ }
7106
+ function isNoopSync(pull, pushOutcome) {
7107
+ if (!pushOutcome.ok) return false;
7108
+ if (pull.localOnly !== 0 || pull.divergedKeptLocal !== 0) return false;
7109
+ if (pushOutcome.result.tag !== "nothing") return false;
7110
+ return pullHasNoSyncedItems(pull.sections);
7111
+ }
7112
+ function pullPhrase(pull) {
7113
+ const summary = pull.sections.find((s) => s.header === "Summary");
7114
+ return summary?.items[0] ?? "applied";
7115
+ }
7116
+ function reconciledNotes(pull) {
7117
+ const notes = [];
7118
+ if (pull.divergedKeptLocal > 0) {
7119
+ notes.push(`${pull.divergedKeptLocal} diverged files kept local and pushed`);
7120
+ }
7121
+ if (pull.localOnly > 0) {
7122
+ notes.push(`${pull.localOnly} local-only sessions pushed`);
7123
+ }
7124
+ return notes;
7125
+ }
7126
+ function buildSyncSummarySection(pull, pushOutcome) {
7127
+ const s = section("Summary");
7128
+ if (!pushOutcome.ok) {
7129
+ addItem(s, `pull: applied, push: failed (${pushOutcome.message})`);
7130
+ return s;
7131
+ }
7132
+ addItem(s, `pull: ${pullPhrase(pull)}`);
7133
+ addItem(s, `push: ${pushOutcome.result.tag === "pushed" ? "pushed" : "nothing to push"}`);
7134
+ for (const note of reconciledNotes(pull)) addItem(s, note);
7135
+ return s;
7136
+ }
7137
+ function renderWetSync(pull, pushOutcome) {
7138
+ if (isNoopSync(pull, pushOutcome)) {
7139
+ ok("already in sync");
7140
+ return;
7141
+ }
7142
+ renderTree(pull.sections);
7143
+ renderTree([buildSyncSummarySection(pull, pushOutcome)]);
7144
+ }
7145
+ async function runSyncPushHalf() {
7146
+ try {
7147
+ const result = await runPushCore();
7148
+ return { ok: true, result };
7149
+ } catch (err) {
7150
+ if (err instanceof NomadFatal) {
7151
+ process.exitCode = 1;
7152
+ return { ok: false, message: err.message };
7153
+ }
7154
+ throw err;
7155
+ }
7156
+ }
7157
+ async function runSyncWet() {
7158
+ const pull = runPullCore();
7159
+ const pushOutcome = await runSyncPushHalf();
7160
+ renderWetSync(pull, pushOutcome);
7161
+ }
7162
+ async function runSyncDryRun(repo, backup) {
7163
+ const ts = freshBackupTs(backup);
7164
+ const mapPath = join52(repo, "path-map.json");
7165
+ const map = existsSync44(mapPath) ? readPathMap(mapPath) : { projects: {} };
7166
+ computePreview(ts, map, "pull");
7167
+ log("push preview below is computed against pre-pull state (a real sync pushes after pull)");
7168
+ await runPushCore({ dryRun: true });
7169
+ }
7170
+ async function cmdSync(opts = {}) {
7171
+ const dryRun = opts.dryRun === true;
7172
+ const repo = repoHome();
7173
+ const backup = backupBase();
7174
+ if (!existsSync44(repo)) die(`repo not cloned at ${repo}`);
7175
+ if (!existsSync44(join52(repo, "shared", "settings.base.json"))) {
7176
+ die("repo not initialized; run 'nomad init' to scaffold");
7177
+ }
7178
+ const handle = acquireLock("sync");
7179
+ if (handle === null) process.exit(0);
7180
+ try {
7181
+ if (dryRun) {
7182
+ await runSyncDryRun(repo, backup);
7183
+ } else {
7184
+ await runSyncWet();
7042
7185
  }
7043
- if (status) enforceAllowList(status, map);
7044
- if (dryRun) return runDryRunPreview(st, map, repo, selection);
7045
- await commitAndPush(st, ts, map, { redactAll, allowAll, allowRule }, repo, newManifest);
7046
7186
  } catch (err) {
7047
7187
  if (err instanceof NomadFatal) {
7048
7188
  fail(err.message);
@@ -7099,18 +7239,18 @@ init_config();
7099
7239
 
7100
7240
  // src/diff.ts
7101
7241
  init_config();
7102
- import { existsSync as existsSync44 } from "node:fs";
7103
- import { join as join52 } from "node:path";
7242
+ import { existsSync as existsSync45 } from "node:fs";
7243
+ import { join as join53 } from "node:path";
7104
7244
  init_utils();
7105
7245
  init_utils_fs();
7106
7246
  init_utils_json();
7107
7247
  function cmdDiff() {
7108
7248
  try {
7109
7249
  const repo = repoHome();
7110
- if (!existsSync44(repo)) die(`repo not cloned at ${repo}`);
7250
+ if (!existsSync45(repo)) die(`repo not cloned at ${repo}`);
7111
7251
  const ts = freshBackupTs(backupBase());
7112
- const mapPath = join52(repo, "path-map.json");
7113
- const map = existsSync44(mapPath) ? readPathMap(mapPath) : { projects: {} };
7252
+ const mapPath = join53(repo, "path-map.json");
7253
+ const map = existsSync45(mapPath) ? readPathMap(mapPath) : { projects: {} };
7114
7254
  divergenceCheckExtras(ts);
7115
7255
  computePreview(ts, map, "diff");
7116
7256
  } catch (err) {
@@ -7125,8 +7265,8 @@ function cmdDiff() {
7125
7265
 
7126
7266
  // src/init.ts
7127
7267
  init_config();
7128
- import { existsSync as existsSync46, mkdirSync as mkdirSync12, writeFileSync as writeFileSync6 } from "node:fs";
7129
- import { join as join54 } from "node:path";
7268
+ import { existsSync as existsSync47, mkdirSync as mkdirSync12, writeFileSync as writeFileSync6 } from "node:fs";
7269
+ import { join as join55 } from "node:path";
7130
7270
 
7131
7271
  // src/init.gh-onboard.ts
7132
7272
  init_config();
@@ -7208,33 +7348,33 @@ init_config();
7208
7348
  init_utils();
7209
7349
  init_utils_fs();
7210
7350
  init_utils_json();
7211
- import { copyFileSync as copyFileSync2, cpSync as cpSync7, existsSync as existsSync45, rmSync as rmSync15, statSync as statSync12 } from "node:fs";
7212
- import { join as join53 } from "node:path";
7351
+ import { copyFileSync as copyFileSync2, cpSync as cpSync7, existsSync as existsSync46, rmSync as rmSync15, statSync as statSync12 } from "node:fs";
7352
+ import { join as join54 } from "node:path";
7213
7353
  function snapshotIntoShared(map) {
7214
7354
  const repo = repoHome();
7215
7355
  const claude = claudeHome();
7216
7356
  for (const name of allSharedLinks(map)) {
7217
- const src = join53(claude, name);
7218
- if (!existsSync45(src)) continue;
7219
- const dst = join53(repo, "shared", name);
7357
+ const src = join54(claude, name);
7358
+ if (!existsSync46(src)) continue;
7359
+ const dst = join54(repo, "shared", name);
7220
7360
  if (statSync12(src).isDirectory()) {
7221
- const gk = join53(dst, ".gitkeep");
7222
- if (existsSync45(gk)) rmSync15(gk);
7361
+ const gk = join54(dst, ".gitkeep");
7362
+ if (existsSync46(gk)) rmSync15(gk);
7223
7363
  cpSync7(src, dst, { recursive: true, force: false, errorOnExist: true });
7224
7364
  } else {
7225
7365
  copyFileSync2(src, dst);
7226
7366
  }
7227
7367
  log(`snapshotted shared/${name} from ${src}`);
7228
7368
  }
7229
- const userSettings = join53(claude, "settings.json");
7230
- if (existsSync45(userSettings)) {
7369
+ const userSettings = join54(claude, "settings.json");
7370
+ if (existsSync46(userSettings)) {
7231
7371
  let parsed;
7232
7372
  try {
7233
7373
  parsed = readJson(userSettings);
7234
7374
  } catch (err) {
7235
7375
  return die(`malformed ${userSettings}: ${err.message}`);
7236
7376
  }
7237
- const hostFile = join53(repo, "hosts", `${HOST}.json`);
7377
+ const hostFile = join54(repo, "hosts", `${HOST}.json`);
7238
7378
  writeJsonAtomic(hostFile, parsed);
7239
7379
  log(`snapshotted hosts/${HOST}.json from ${userSettings}`);
7240
7380
  }
@@ -7247,14 +7387,14 @@ var SHARED_CLAUDE_MD = "<!-- claude-nomad shared CLAUDE.md; symlinked into ~/.cl
7247
7387
  var SHARED_KEEP_DIRS = ["agents", "skills", "commands", "rules", "hooks"];
7248
7388
  function preflightConflict(repoHome2) {
7249
7389
  const candidates = [
7250
- join54(repoHome2, "shared", "settings.base.json"),
7251
- join54(repoHome2, "shared", "CLAUDE.md"),
7252
- join54(repoHome2, "path-map.json"),
7253
- join54(repoHome2, "hosts"),
7254
- join54(repoHome2, "shared")
7390
+ join55(repoHome2, "shared", "settings.base.json"),
7391
+ join55(repoHome2, "shared", "CLAUDE.md"),
7392
+ join55(repoHome2, "path-map.json"),
7393
+ join55(repoHome2, "hosts"),
7394
+ join55(repoHome2, "shared")
7255
7395
  ];
7256
7396
  for (const c of candidates) {
7257
- if (existsSync46(c)) return c;
7397
+ if (existsSync47(c)) return c;
7258
7398
  }
7259
7399
  return null;
7260
7400
  }
@@ -7272,25 +7412,25 @@ function cmdInit(opts = {}) {
7272
7412
  die(`already initialized; refusing to clobber ${conflict}`);
7273
7413
  }
7274
7414
  ensureOriginRepo(opts.repoName ?? DEFAULT_REPO_NAME, opts.run);
7275
- mkdirSync12(join54(repo, "shared"), { recursive: true });
7276
- mkdirSync12(join54(repo, "hosts"), { recursive: true });
7415
+ mkdirSync12(join55(repo, "shared"), { recursive: true });
7416
+ mkdirSync12(join55(repo, "hosts"), { recursive: true });
7277
7417
  for (const name of SHARED_KEEP_DIRS) {
7278
- mkdirSync12(join54(repo, "shared", name), { recursive: true });
7418
+ mkdirSync12(join55(repo, "shared", name), { recursive: true });
7279
7419
  }
7280
- const userClaudeMd = join54(claude, "CLAUDE.md");
7281
- if (!snapshot || !existsSync46(userClaudeMd)) {
7282
- writeFileSync6(join54(repo, "shared", "CLAUDE.md"), SHARED_CLAUDE_MD);
7420
+ const userClaudeMd = join55(claude, "CLAUDE.md");
7421
+ if (!snapshot || !existsSync47(userClaudeMd)) {
7422
+ writeFileSync6(join55(repo, "shared", "CLAUDE.md"), SHARED_CLAUDE_MD);
7283
7423
  item("created shared/CLAUDE.md");
7284
7424
  }
7285
7425
  for (const name of SHARED_KEEP_DIRS) {
7286
- writeFileSync6(join54(repo, "shared", name, ".gitkeep"), "");
7426
+ writeFileSync6(join55(repo, "shared", name, ".gitkeep"), "");
7287
7427
  item(`created shared/${name}/.gitkeep`);
7288
7428
  }
7289
- writeFileSync6(join54(repo, "hosts", ".gitkeep"), "");
7429
+ writeFileSync6(join55(repo, "hosts", ".gitkeep"), "");
7290
7430
  item("created hosts/.gitkeep");
7291
- writeJsonAtomic(join54(repo, "shared", "settings.base.json"), {});
7431
+ writeJsonAtomic(join55(repo, "shared", "settings.base.json"), {});
7292
7432
  item("created shared/settings.base.json");
7293
- writeJsonAtomic(join54(repo, "path-map.json"), { projects: {} });
7433
+ writeJsonAtomic(join55(repo, "path-map.json"), { projects: {} });
7294
7434
  item("created path-map.json");
7295
7435
  if (snapshot) {
7296
7436
  snapshotIntoShared({ projects: {} });
@@ -7356,11 +7496,11 @@ function maybeDisableRepoActions(repoHome2, run) {
7356
7496
  // src/init.prompt.ts
7357
7497
  init_config();
7358
7498
  init_utils();
7359
- import { existsSync as existsSync47, readdirSync as readdirSync17, statSync as statSync13 } from "node:fs";
7360
- import { join as join55 } from "node:path";
7499
+ import { existsSync as existsSync48, readdirSync as readdirSync17, statSync as statSync13 } from "node:fs";
7500
+ import { join as join56 } from "node:path";
7361
7501
  import { createInterface as createInterface3 } from "node:readline/promises";
7362
7502
  function nonEmptyExists(path) {
7363
- if (!existsSync47(path)) return false;
7503
+ if (!existsSync48(path)) return false;
7364
7504
  try {
7365
7505
  if (statSync13(path).isDirectory()) return readdirSync17(path).length > 0;
7366
7506
  return true;
@@ -7369,8 +7509,8 @@ function nonEmptyExists(path) {
7369
7509
  }
7370
7510
  }
7371
7511
  function hasExistingClaudeConfig(claudeHome2) {
7372
- if (existsSync47(join55(claudeHome2, "settings.json"))) return true;
7373
- return SHARED_LINKS.some((name) => nonEmptyExists(join55(claudeHome2, name)));
7512
+ if (existsSync48(join56(claudeHome2, "settings.json"))) return true;
7513
+ return SHARED_LINKS.some((name) => nonEmptyExists(join56(claudeHome2, name)));
7374
7514
  }
7375
7515
  async function confirmSnapshotDefault(claudeHome2) {
7376
7516
  if (process.stdin.isTTY !== true || process.stdout.isTTY !== true) {
@@ -7654,10 +7794,27 @@ function parsePushArgs(argv) {
7654
7794
  };
7655
7795
  }
7656
7796
 
7797
+ // src/nomad.dispatch.sync.ts
7798
+ function parseSyncArgs(argv) {
7799
+ let dryRun = false;
7800
+ let i = 3;
7801
+ while (i < argv.length) {
7802
+ const token = argv[i];
7803
+ if (token === "--dry-run") {
7804
+ if (dryRun) return null;
7805
+ dryRun = true;
7806
+ } else {
7807
+ return null;
7808
+ }
7809
+ i++;
7810
+ }
7811
+ return { dryRun };
7812
+ }
7813
+
7657
7814
  // package.json
7658
7815
  var package_default = {
7659
7816
  name: "claude-nomad",
7660
- version: "0.56.2",
7817
+ version: "0.57.0",
7661
7818
  type: "module",
7662
7819
  description: "Sync Claude Code config (~/.claude/) across machines via a private Git repo, with path remapping and per-host settings overrides.",
7663
7820
  keywords: [
@@ -7786,6 +7943,12 @@ var DEFAULT_HELP = [
7786
7943
  cont("when no finding survives. No TTY required. Never skips the scan."),
7787
7944
  cont("Mutually exclusive with --redact-all/--allow. Cannot combine with --dry-run."),
7788
7945
  "",
7946
+ row(" sync", "Pull (retain-merge), then push, under one lock. One-shot; hides ordering."),
7947
+ row(" --dry-run", "Stack the pull preview then the push preview; mutates nothing."),
7948
+ cont("Fails fast on a pull-half error; a wedged repo hints at nomad pull --force-remote"),
7949
+ cont("(sync itself takes no --force-remote). Mid-push leak recovery behaves exactly"),
7950
+ cont("like nomad push."),
7951
+ "",
7789
7952
  row(" diff", "Offline preview of what `pull` would change against local repo state."),
7790
7953
  cont("No git pull, no lock acquired."),
7791
7954
  "",
@@ -7882,15 +8045,15 @@ var DEFAULT_HELP = [
7882
8045
  init_config();
7883
8046
  init_utils();
7884
8047
  init_utils_json();
7885
- import { existsSync as existsSync48, readFileSync as readFileSync17, readdirSync as readdirSync18 } from "node:fs";
7886
- import { join as join56 } from "node:path";
8048
+ import { existsSync as existsSync49, readFileSync as readFileSync17, readdirSync as readdirSync18 } from "node:fs";
8049
+ import { join as join57 } from "node:path";
7887
8050
  function resumeCmd(sessionId) {
7888
8051
  if (!/^[A-Za-z0-9_-]+$/.test(sessionId) || sessionId.length > 128) {
7889
8052
  fail(`invalid session id: ${sessionId}`);
7890
8053
  process.exit(1);
7891
8054
  }
7892
- const projectsRoot = join56(claudeHome(), "projects");
7893
- if (!existsSync48(projectsRoot)) {
8055
+ const projectsRoot = join57(claudeHome(), "projects");
8056
+ if (!existsSync49(projectsRoot)) {
7894
8057
  fail(`${projectsRoot} does not exist`);
7895
8058
  process.exit(1);
7896
8059
  }
@@ -7904,8 +8067,8 @@ function resumeCmd(sessionId) {
7904
8067
  fail(`no cwd field found in ${jsonlPath}`);
7905
8068
  process.exit(1);
7906
8069
  }
7907
- const mapPath = join56(repoHome(), "path-map.json");
7908
- if (!existsSync48(mapPath)) {
8070
+ const mapPath = join57(repoHome(), "path-map.json");
8071
+ if (!existsSync49(mapPath)) {
7909
8072
  fail("path-map.json missing");
7910
8073
  process.exit(1);
7911
8074
  }
@@ -7928,8 +8091,8 @@ function resumeCmd(sessionId) {
7928
8091
  }
7929
8092
  function findTranscriptPath(projectsRoot, sessionId) {
7930
8093
  for (const dir of readdirSync18(projectsRoot)) {
7931
- const candidate = join56(projectsRoot, dir, `${sessionId}.jsonl`);
7932
- if (existsSync48(candidate)) return candidate;
8094
+ const candidate = join57(projectsRoot, dir, `${sessionId}.jsonl`);
8095
+ if (existsSync49(candidate)) return candidate;
7933
8096
  }
7934
8097
  return null;
7935
8098
  }
@@ -8007,6 +8170,15 @@ try {
8007
8170
  });
8008
8171
  break;
8009
8172
  }
8173
+ case "sync": {
8174
+ const syncArgs = parseSyncArgs(process.argv);
8175
+ if (syncArgs === null) {
8176
+ console.error("usage: nomad sync [--dry-run]");
8177
+ process.exit(1);
8178
+ }
8179
+ await cmdSync({ dryRun: syncArgs.dryRun });
8180
+ break;
8181
+ }
8010
8182
  case "init": {
8011
8183
  const initArgs = parseInitArgs(process.argv);
8012
8184
  if (initArgs === null) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-nomad",
3
- "version": "0.56.2",
3
+ "version": "0.57.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": [