claude-nomad 0.59.0 → 0.60.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,27 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.60.0](https://github.com/funkadelic/claude-nomad/compare/v0.59.0...v0.60.0) (2026-07-15)
4
+
5
+
6
+ ### Added
7
+
8
+ * **links:** preserve live gsd hook entries across regenerateSettings ([#421](https://github.com/funkadelic/claude-nomad/issues/421)) ([10a18ca](https://github.com/funkadelic/claude-nomad/commit/10a18ca7ed6c1851cdc43e46ad616527d41a1f92))
9
+ * **sync:** compact output with merged tree and no-op collapse ([#415](https://github.com/funkadelic/claude-nomad/issues/415)) ([9bb9cdd](https://github.com/funkadelic/claude-nomad/commit/9bb9cdd7e901ea463d5c1fb5b66ffc39b2864f8a))
10
+ * **sync:** compact summary-only output by default, add --verbose ([#420](https://github.com/funkadelic/claude-nomad/issues/420)) ([b5b95b3](https://github.com/funkadelic/claude-nomad/commit/b5b95b372634a0b372023b399e7f16bb0063ff74))
11
+
12
+
13
+ ### Changed
14
+
15
+ * **lint:** enable sonarjs recommended ruleset with triaged exclusions ([#422](https://github.com/funkadelic/claude-nomad/issues/422)) ([f92fce8](https://github.com/funkadelic/claude-nomad/commit/f92fce8533e06814d5e3f58c1f120adf3e00ded7))
16
+ * **tests:** make Defender exclusion step non-fatal ([#419](https://github.com/funkadelic/claude-nomad/issues/419)) ([413a906](https://github.com/funkadelic/claude-nomad/commit/413a9065ef152dfaedd76b325de749070e2165ab))
17
+
18
+
19
+ ### Testing
20
+
21
+ * **integration:** cover extras deny-set, host overrides, and skills sync in round-trip ([#418](https://github.com/funkadelic/claude-nomad/issues/418)) ([70074b5](https://github.com/funkadelic/claude-nomad/commit/70074b5c5a8831a93726576e346e7e89ac511ffa))
22
+ * **pull:** fix flaky incomingChanges tests via shared mock instance ([#417](https://github.com/funkadelic/claude-nomad/issues/417)) ([6589887](https://github.com/funkadelic/claude-nomad/commit/6589887d0ac1c77f7696b89a1a518e469e913dae))
23
+ * **vitest:** raise subprocess project maxWorkers to 2 ([#423](https://github.com/funkadelic/claude-nomad/issues/423)) ([8fa28b5](https://github.com/funkadelic/claude-nomad/commit/8fa28b5a2ef4cae4816f5b6ad23bf90189a611d1))
24
+
3
25
  ## [0.59.0](https://github.com/funkadelic/claude-nomad/compare/v0.58.1...v0.59.0) (2026-07-14)
4
26
 
5
27
 
package/README.md CHANGED
@@ -174,12 +174,13 @@ $ nomad sync # pull config, then publish local changes, in one step
174
174
 
175
175
  `nomad sync` is the command to reach for day to day: it always pulls first (so changes from your
176
176
  other machines land before anything is pushed, and work that exists only on this machine is kept,
177
- not deleted) and then pushes, under one lock, so there is no ordering to remember. `nomad pull` and
178
- `nomad push` are still available as lower-level commands for cases `sync` does not cover: recovering
179
- a wedged repo with `nomad pull --force-remote`, or resolving a detected secret without the
180
- interactive menu via `nomad push --redact-all` / `--allow` / `--allow-all` (see
181
- [Changing settings](#changing-settings) and
182
- [Recovery flows](https://funkadelic.github.io/claude-nomad/recovery/)). The
177
+ not deleted) and then pushes, under one lock, so there is no ordering to remember. Output is compact
178
+ by default: a run prints only a short Sync summary, not the full status tree; pass
179
+ `nomad sync --verbose` (or `--all` / `-v`) to see the full tree. `nomad pull` and `nomad push` are
180
+ still available as lower-level commands for cases `sync` does not cover: recovering a wedged repo
181
+ with `nomad pull --force-remote`, or resolving a detected secret without the interactive menu via
182
+ `nomad push --redact-all` / `--allow` / `--allow-all` (see [Changing settings](#changing-settings)
183
+ and [Recovery flows](https://funkadelic.github.io/claude-nomad/recovery/)). The
183
184
  [FAQ](https://funkadelic.github.io/claude-nomad/faq/) covers what `sync` does under the hood and the
184
185
  push/pull order it enforces.
185
186
 
package/dist/nomad.mjs CHANGED
@@ -1353,6 +1353,75 @@ function stripGsdHookEntries(settings) {
1353
1353
  }
1354
1354
  return out;
1355
1355
  }
1356
+ function keepMatcherEntry(entry) {
1357
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)) return null;
1358
+ const entryObj = entry;
1359
+ if (!Array.isArray(entryObj.hooks)) return null;
1360
+ const innerHooks = entryObj.hooks;
1361
+ const kept = innerHooks.filter((h2) => {
1362
+ if (h2 === null || typeof h2 !== "object" || Array.isArray(h2)) return false;
1363
+ const hookObj = h2;
1364
+ const cmd = hookObj.command;
1365
+ return isGsdHookEntry(typeof cmd === "string" ? cmd : "");
1366
+ });
1367
+ if (kept.length === 0) return null;
1368
+ return { ...entryObj, hooks: kept };
1369
+ }
1370
+ function keepEventMatchers(matchers) {
1371
+ if (!Array.isArray(matchers)) return null;
1372
+ const kept = [];
1373
+ for (const entry of matchers) {
1374
+ const result = keepMatcherEntry(entry);
1375
+ if (result !== null) kept.push(result);
1376
+ }
1377
+ return kept.length === 0 ? null : kept;
1378
+ }
1379
+ function keepGsdHookEntries(settings) {
1380
+ const hooksVal = settings.hooks;
1381
+ if (hooksVal === null || typeof hooksVal !== "object" || Array.isArray(hooksVal)) return {};
1382
+ const hooksObj = hooksVal;
1383
+ const keptHooks = {};
1384
+ for (const [event, matchers] of Object.entries(hooksObj)) {
1385
+ const kept = keepEventMatchers(matchers);
1386
+ if (kept !== null) keptHooks[event] = kept;
1387
+ }
1388
+ return Object.keys(keptHooks).length === 0 ? {} : { hooks: keptHooks };
1389
+ }
1390
+ function asPlainObject(value) {
1391
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return null;
1392
+ return value;
1393
+ }
1394
+ function canonicalMatcherKey(m) {
1395
+ const obj = asPlainObject(m);
1396
+ if (obj === null) return JSON.stringify(m);
1397
+ return JSON.stringify(
1398
+ Object.keys(obj).sort((a, b) => a.localeCompare(b)).map((k) => [k, obj[k]])
1399
+ );
1400
+ }
1401
+ function unionMatcherArrays(baseMatchers, gsdMatchers) {
1402
+ const seen = new Set(baseMatchers.map(canonicalMatcherKey));
1403
+ const merged = [...baseMatchers];
1404
+ for (const m of gsdMatchers) {
1405
+ const key = canonicalMatcherKey(m);
1406
+ if (!seen.has(key)) {
1407
+ merged.push(m);
1408
+ seen.add(key);
1409
+ }
1410
+ }
1411
+ return merged;
1412
+ }
1413
+ function graftGsdHookEntries(base, gsdOnly) {
1414
+ const gsdHooks = asPlainObject(gsdOnly.hooks);
1415
+ if (gsdHooks === null || Object.keys(gsdHooks).length === 0) return base;
1416
+ const baseHooks = asPlainObject(base.hooks);
1417
+ const mergedHooks = baseHooks ? { ...baseHooks } : {};
1418
+ for (const [event, gsdMatchers] of Object.entries(gsdHooks)) {
1419
+ if (!Array.isArray(gsdMatchers)) continue;
1420
+ const baseMatchers = mergedHooks[event];
1421
+ mergedHooks[event] = Array.isArray(baseMatchers) ? unionMatcherArrays(baseMatchers, gsdMatchers) : gsdMatchers;
1422
+ }
1423
+ return { ...base, hooks: mergedHooks };
1424
+ }
1356
1425
  function matcherHasGsdEntry(entry) {
1357
1426
  if (entry === null || typeof entry !== "object" || Array.isArray(entry)) return false;
1358
1427
  const entryObj = entry;
@@ -1804,6 +1873,34 @@ function syncSharedLinksPush(map) {
1804
1873
  copyExtrasFiltered(localPath, join5(repo, "shared", name), ALWAYS_NEVER_SYNC);
1805
1874
  }
1806
1875
  }
1876
+ function readExistingSettings(settingsPath) {
1877
+ if (!existsSync4(settingsPath)) return { existing: {}, present: false, malformed: false };
1878
+ try {
1879
+ const parsed = readJson(settingsPath);
1880
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
1881
+ return { existing: {}, present: true, malformed: true };
1882
+ }
1883
+ return { existing: parsed, present: true, malformed: false };
1884
+ } catch {
1885
+ return { existing: {}, present: true, malformed: true };
1886
+ }
1887
+ }
1888
+ function emitDriftWarnings(merged, existing) {
1889
+ const drift = classifySettingsDrift(merged, existing);
1890
+ if (drift.behind.length > 0) {
1891
+ const { phrase, pronoun } = describeSettings(drift.behind);
1892
+ warn(
1893
+ `your settings.json is missing ${phrase} that the synced copy has; run 'nomad pull' to restore ${pronoun}.`
1894
+ );
1895
+ }
1896
+ const { promotable } = partitionByCaptureExclusion(drift.ahead);
1897
+ if (promotable.length > 0) {
1898
+ const { phrase, pronoun, verb } = describeSettings(promotable);
1899
+ warn(
1900
+ `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}.`
1901
+ );
1902
+ }
1903
+ }
1807
1904
  function regenerateSettings(ts, opts = {}) {
1808
1905
  const dryRun = opts.dryRun === true;
1809
1906
  const suppressDriftWarn = opts.suppressDriftWarn === true;
@@ -1819,25 +1916,12 @@ function regenerateSettings(ts, opts = {}) {
1819
1916
  const overrides = hasOverrides ? readJson(hostPath) : {};
1820
1917
  const merged = deepMerge(base, overrides);
1821
1918
  const settingsPath = join5(claude, "settings.json");
1822
- if (!suppressDriftWarn && existsSync4(settingsPath)) {
1823
- try {
1824
- const existing = readJson(settingsPath);
1825
- const drift = classifySettingsDrift(merged, existing);
1826
- if (drift.behind.length > 0) {
1827
- const { phrase, pronoun } = describeSettings(drift.behind);
1828
- warn(
1829
- `your settings.json is missing ${phrase} that the synced copy has; run 'nomad pull' to restore ${pronoun}.`
1830
- );
1831
- }
1832
- const { promotable } = partitionByCaptureExclusion(drift.ahead);
1833
- if (promotable.length > 0) {
1834
- const { phrase, pronoun, verb } = describeSettings(promotable);
1835
- warn(
1836
- `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}.`
1837
- );
1838
- }
1839
- } catch {
1919
+ const { existing, present, malformed } = readExistingSettings(settingsPath);
1920
+ if (!suppressDriftWarn && present) {
1921
+ if (malformed) {
1840
1922
  warn("existing settings.json is malformed; skipping drift-check and regenerating.");
1923
+ } else {
1924
+ emitDriftWarnings(merged, existing);
1841
1925
  }
1842
1926
  }
1843
1927
  const overrideLabel = hasOverrides ? `${HOST}.json` : "no host overrides";
@@ -1846,7 +1930,10 @@ function regenerateSettings(ts, opts = {}) {
1846
1930
  return { label: overrideLabel };
1847
1931
  }
1848
1932
  backupBeforeWrite(settingsPath, ts);
1849
- writeJsonAtomic(settingsPath, stripGsdHookEntries(merged));
1933
+ writeJsonAtomic(
1934
+ settingsPath,
1935
+ graftGsdHookEntries(stripGsdHookEntries(merged), keepGsdHookEntries(existing))
1936
+ );
1850
1937
  return { label: overrideLabel };
1851
1938
  }
1852
1939
 
@@ -5818,19 +5905,25 @@ function summarySection(st) {
5818
5905
  addItem(s, summaryRow("push", unmapped, st.remap.collisions, st.extras.skipped));
5819
5906
  return s;
5820
5907
  }
5821
- function renderPushTree(st, verdict) {
5908
+ function buildPushTreeSections(st, verdict) {
5822
5909
  const leakScan = section("Leak scan");
5823
5910
  addItem(leakScan, verdict.verdictRow);
5824
- renderTree([...syncedSections(st), leakScan, summarySection(st)]);
5911
+ return [...syncedSections(st), leakScan, summarySection(st)];
5825
5912
  }
5826
- function renderNoScanTree(st, opts = {}) {
5913
+ function renderPushTree(st, verdict) {
5914
+ renderTree(buildPushTreeSections(st, verdict));
5915
+ }
5916
+ function buildNoScanSections(st, opts = {}) {
5827
5917
  const sections = [];
5828
5918
  if (opts.noMapHint === true) {
5829
5919
  const pathMap = section("Path map");
5830
5920
  addItem(pathMap, `${dim(infoGlyph)} no path-map.json (nothing to preview)`);
5831
5921
  sections.push(pathMap);
5832
5922
  }
5833
- renderTree([...sections, ...syncedSections(st), summarySection(st)]);
5923
+ return [...sections, ...syncedSections(st), summarySection(st)];
5924
+ }
5925
+ function renderNoScanTree(st, opts = {}) {
5926
+ renderTree(buildNoScanSections(st, opts));
5834
5927
  }
5835
5928
 
5836
5929
  // src/commands.pull.ts
@@ -6746,17 +6839,9 @@ function buildWetPullSections(ts, map, prePostHeads) {
6746
6839
  const remapResult = withSpinner("Syncing sessions", () => remapPull(ts));
6747
6840
  const extrasResult = remapExtrasPull(ts, { prePostHeads });
6748
6841
  const localOnly = scanLocalOnly();
6842
+ const unmapped = remapResult.unmapped + extrasResult.unmapped;
6749
6843
  const summary = section(PULL_SUMMARY_HEADER);
6750
- addItem(
6751
- summary,
6752
- summaryRow(
6753
- "pull",
6754
- remapResult.unmapped + extrasResult.unmapped,
6755
- 0,
6756
- extrasResult.skipped,
6757
- localOnly
6758
- )
6759
- );
6844
+ addItem(summary, summaryRow("pull", unmapped, 0, extrasResult.skipped, localOnly));
6760
6845
  return {
6761
6846
  sections: [
6762
6847
  buildSettingsSection(label),
@@ -6764,7 +6849,10 @@ function buildWetPullSections(ts, map, prePostHeads) {
6764
6849
  buildExtrasSection(extrasResult.pulled, extrasResult.skipped),
6765
6850
  summary
6766
6851
  ],
6767
- localOnly
6852
+ localOnly,
6853
+ settingsLabel: label,
6854
+ unmapped,
6855
+ extrasSkipped: extrasResult.skipped
6768
6856
  };
6769
6857
  }
6770
6858
  function handleWedge(repo, forceRemote) {
@@ -6788,6 +6876,7 @@ function handleWedge(repo, forceRemote) {
6788
6876
  function runPullCore(opts = {}) {
6789
6877
  const dryRun = opts.dryRun === true;
6790
6878
  const forceRemote = opts.forceRemote === true;
6879
+ const compose = opts.compose === true;
6791
6880
  const repo = repoHome();
6792
6881
  const backup = backupBase();
6793
6882
  const ts = freshBackupTs(backup);
@@ -6800,9 +6889,11 @@ function runPullCore(opts = {}) {
6800
6889
  die(`could not create backup dir: ${err.message}`);
6801
6890
  }
6802
6891
  }
6803
- log(
6804
- dryRun ? `pulling on host=${HOST} (backup=${ts}; dry-run)` : `pull on host=${HOST} (backup=${ts})`
6805
- );
6892
+ if (!compose) {
6893
+ log(
6894
+ dryRun ? `pulling on host=${HOST} (backup=${ts}; dry-run)` : `pull on host=${HOST} (backup=${ts})`
6895
+ );
6896
+ }
6806
6897
  const prePostHeads = capturePrePostHeads(repo, () => {
6807
6898
  gitOrFatal(["pull", "--rebase", "--autostash"], "git pull --rebase", repo);
6808
6899
  });
@@ -6814,8 +6905,22 @@ function runPullCore(opts = {}) {
6814
6905
  log("dry-run complete; no mutation");
6815
6906
  return { tag: "dry" };
6816
6907
  }
6817
- const { sections, localOnly } = buildWetPullSections(ts, map, prePostHeads);
6818
- return { tag: "wet", sections, localOnly, divergedKeptLocal };
6908
+ const { sections, localOnly, settingsLabel, unmapped, extrasSkipped } = buildWetPullSections(
6909
+ ts,
6910
+ map,
6911
+ prePostHeads
6912
+ );
6913
+ const incomingChanges = prePostHeads === void 0 ? true : prePostHeads.pre !== prePostHeads.post;
6914
+ return {
6915
+ tag: "wet",
6916
+ sections,
6917
+ localOnly,
6918
+ divergedKeptLocal,
6919
+ incomingChanges,
6920
+ settingsLabel,
6921
+ unmapped,
6922
+ extrasSkipped
6923
+ };
6819
6924
  }
6820
6925
  function cmdPull(opts = {}) {
6821
6926
  const repo = repoHome();
@@ -7242,7 +7347,7 @@ function previewPushLeaks(map, opts = {}) {
7242
7347
 
7243
7348
  // src/commands.push.steps.ts
7244
7349
  init_utils();
7245
- async function commitAndPush(st, ts, map, resolution, repo, newManifest) {
7350
+ async function commitAndPush(st, ts, map, resolution, repo, newManifest, render) {
7246
7351
  gitOrFatal(["add", "-A"], "git add", repo);
7247
7352
  const staged = parsePorcelainZ2(gitStatusPorcelainZ(repo));
7248
7353
  const toDrop = staged.filter((p) => isGsdDropped(p));
@@ -7250,13 +7355,18 @@ async function commitAndPush(st, ts, map, resolution, repo, newManifest) {
7250
7355
  gitOrFatal(["restore", "--staged", "--", ...toDrop], "git restore --staged", repo);
7251
7356
  }
7252
7357
  if (staged.length === toDrop.length) {
7253
- log("nothing to commit");
7254
- renderNoScanTree(st);
7255
- return "nothing";
7358
+ const sections2 = buildNoScanSections(st);
7359
+ if (render) {
7360
+ log("nothing to commit");
7361
+ renderTree(sections2);
7362
+ }
7363
+ return { outcome: "nothing", sections: sections2 };
7256
7364
  }
7257
7365
  st.globalConfig = collectGlobalConfigChanges(repo, HOST, { staged: true });
7258
7366
  let verdict = withSpinner("Scanning for secrets", () => scanPushVerdict(repo));
7367
+ const hadLeak = verdict.leak;
7259
7368
  if (verdict.leak) {
7369
+ if (!render) log("push (leak recovery)");
7260
7370
  renderPushTree(st, verdict);
7261
7371
  verdict = await resolveLeakFindings(verdict, ts, map, resolution);
7262
7372
  }
@@ -7267,8 +7377,13 @@ async function commitAndPush(st, ts, map, resolution, repo, newManifest) {
7267
7377
  } catch (err) {
7268
7378
  warn(`could not write push manifest (next push will full-rescan): ${String(err)}`);
7269
7379
  }
7270
- renderPushTree(st, verdict);
7271
- return "pushed";
7380
+ if (hadLeak) {
7381
+ renderPushTree(st, verdict);
7382
+ return { outcome: "pushed", sections: [] };
7383
+ }
7384
+ const sections = buildPushTreeSections(st, verdict);
7385
+ if (render) renderTree(sections);
7386
+ return { outcome: "pushed", sections };
7272
7387
  }
7273
7388
  function runDryRunPreview(st, map, repo, selection) {
7274
7389
  st.globalConfig = collectGlobalConfigChanges(repo, HOST, { staged: false });
@@ -7285,15 +7400,56 @@ function runDryRunPreview(st, map, repo, selection) {
7285
7400
  init_push_checks();
7286
7401
  init_utils();
7287
7402
  init_utils_fs();
7403
+ function aheadOfUpstream(repo) {
7404
+ try {
7405
+ const raw = gitCaptureRaw(["rev-list", "--count", "@{u}..HEAD"], repo);
7406
+ return Number.parseInt(raw.trim(), 10) > 0;
7407
+ } catch {
7408
+ return false;
7409
+ }
7410
+ }
7411
+ function emptyStatusResult(st, compose, repo) {
7412
+ if (compose) {
7413
+ return {
7414
+ tag: "nothing",
7415
+ sections: buildNoScanSections(st),
7416
+ aheadOfOrigin: aheadOfUpstream(repo),
7417
+ globalConfigCount: st.globalConfig.length,
7418
+ collisions: st.remap.collisions
7419
+ };
7420
+ }
7421
+ log("nothing to commit");
7422
+ renderNoScanTree(st);
7423
+ return { tag: "nothing" };
7424
+ }
7425
+ function toPushCoreResult(outcome, sections, compose, repo, st) {
7426
+ if (!compose) return { tag: outcome };
7427
+ const globalConfigCount = st.globalConfig.length;
7428
+ const collisions = st.remap.collisions;
7429
+ if (outcome === "nothing") {
7430
+ return {
7431
+ tag: "nothing",
7432
+ sections,
7433
+ aheadOfOrigin: aheadOfUpstream(repo),
7434
+ globalConfigCount,
7435
+ collisions
7436
+ };
7437
+ }
7438
+ return { tag: "pushed", sections, globalConfigCount, collisions };
7439
+ }
7288
7440
  async function runPushCore(opts = {}) {
7289
7441
  const dryRun = opts.dryRun === true;
7290
7442
  const redactAll = opts.redactAll === true;
7291
7443
  const allowAll = opts.allowAll === true;
7292
7444
  const allowRule = opts.allowRule;
7293
7445
  const fullScan = opts.fullScan === true;
7446
+ const compose = opts.compose === true;
7447
+ const render = !compose;
7294
7448
  const repo = repoHome();
7295
7449
  const backup = backupBase();
7296
- console.log(dryRun ? `push on host=${HOST} (dry-run)` : `push on host=${HOST}`);
7450
+ if (!compose) {
7451
+ console.log(dryRun ? `push on host=${HOST} (dry-run)` : `push on host=${HOST}`);
7452
+ }
7297
7453
  reportSettingsAheadDrift(repo);
7298
7454
  const scannerVersion = probeGitleaks();
7299
7455
  const configHash = computeConfigHash();
@@ -7318,11 +7474,7 @@ async function runPushCore(opts = {}) {
7318
7474
  const st = { dryRun, remap, extras, globalConfig: [] };
7319
7475
  guardGitlinks(repo);
7320
7476
  const status = gitStatusPorcelainZ(repo, { untrackedAll: true });
7321
- if (!dryRun && !status) {
7322
- log("nothing to commit");
7323
- renderNoScanTree(st);
7324
- return { tag: "nothing" };
7325
- }
7477
+ if (!dryRun && !status) return emptyStatusResult(st, compose, repo);
7326
7478
  if (map === null) {
7327
7479
  if (dryRun) {
7328
7480
  runDryRunPreview(st, null, repo, selection);
@@ -7335,15 +7487,16 @@ async function runPushCore(opts = {}) {
7335
7487
  runDryRunPreview(st, map, repo, selection);
7336
7488
  return { tag: "dry" };
7337
7489
  }
7338
- const outcome = await commitAndPush(
7490
+ const { outcome, sections } = await commitAndPush(
7339
7491
  st,
7340
7492
  ts,
7341
7493
  map,
7342
7494
  { redactAll, allowAll, allowRule },
7343
7495
  repo,
7344
- newManifest
7496
+ newManifest,
7497
+ render
7345
7498
  );
7346
- return outcome === "nothing" ? { tag: "nothing" } : { tag: "pushed" };
7499
+ return toPushCoreResult(outcome, sections, compose, repo, st);
7347
7500
  }
7348
7501
  async function cmdPush(opts = {}) {
7349
7502
  const dryRun = opts.dryRun === true;
@@ -7373,33 +7526,56 @@ async function cmdPush(opts = {}) {
7373
7526
  import { existsSync as existsSync46 } from "node:fs";
7374
7527
  import { join as join54 } from "node:path";
7375
7528
  init_config();
7529
+ init_color();
7376
7530
  init_utils();
7377
7531
  init_utils_fs();
7378
7532
  init_utils_json();
7379
- function pullHasNoSyncedItems(sections) {
7380
- return sections.every(
7381
- (s) => s.header === "Settings" || s.header === PULL_SUMMARY_HEADER || s.items.length === 0
7382
- );
7383
- }
7384
7533
  function isNoopSync(pull, pushOutcome) {
7385
7534
  if (!pushOutcome.ok) return false;
7386
7535
  if (pull.localOnly !== 0 || pull.divergedKeptLocal !== 0) return false;
7387
7536
  if (pushOutcome.result.tag !== "nothing") return false;
7388
- return pullHasNoSyncedItems(pull.sections);
7537
+ if (pushOutcome.result.aheadOfOrigin === true) return false;
7538
+ return !pull.incomingChanges;
7389
7539
  }
7390
- function pullPhrase(pull) {
7391
- const summary = pull.sections.find((s) => s.header === PULL_SUMMARY_HEADER);
7392
- return summary?.items[0] ?? "applied";
7540
+ function buildPullSummaryRow(pull) {
7541
+ const applied = pull.incomingChanges ? "upstream changes applied" : "no upstream changes";
7542
+ return `pull: ${applied}; settings regenerated (base + ${pull.settingsLabel})`;
7393
7543
  }
7394
- function reconciledNotes(pull) {
7395
- const notes = [];
7544
+ function countNoun(n, singular, plural) {
7545
+ return `${n} ${n === 1 ? singular : plural}`;
7546
+ }
7547
+ function pushedParenParts(pull, globalConfigCount) {
7548
+ const parts = [];
7549
+ if (pull.localOnly > 0) {
7550
+ parts.push(countNoun(pull.localOnly, "local-only session", "local-only sessions"));
7551
+ }
7396
7552
  if (pull.divergedKeptLocal > 0) {
7397
- notes.push(`${pull.divergedKeptLocal} diverged files kept local and pushed`);
7553
+ parts.push(countNoun(pull.divergedKeptLocal, "diverged extras file", "diverged extras files"));
7398
7554
  }
7399
- if (pull.localOnly > 0) {
7400
- notes.push(`${pull.localOnly} local-only sessions pushed`);
7555
+ if (globalConfigCount > 0) {
7556
+ parts.push(countNoun(globalConfigCount, "config file", "config files"));
7557
+ }
7558
+ return parts;
7559
+ }
7560
+ function buildPushSummaryRow(pull, result) {
7561
+ if (result.tag !== "pushed") return "push: nothing to push";
7562
+ const parts = pushedParenParts(pull, result.globalConfigCount ?? 0);
7563
+ return parts.length > 0 ? `push: pushed (${parts.join(", ")})` : "push: pushed";
7564
+ }
7565
+ function buildSkipAndCollisionRows(pull, result) {
7566
+ const rows = [];
7567
+ if (pull.unmapped > 0) {
7568
+ rows.push(`${dim(infoGlyph)} ${pull.unmapped} not in path-map (run nomad doctor to list)`);
7569
+ }
7570
+ if (pull.extrasSkipped > 0) {
7571
+ rows.push(`${dim(infoGlyph)} ${pull.extrasSkipped} extras skipped`);
7572
+ }
7573
+ const collisions = result.tag === "dry" ? void 0 : result.collisions;
7574
+ if (collisions !== void 0 && collisions > 0) {
7575
+ const phrase = countNoun(collisions, "collision", "collisions");
7576
+ rows.push(`${yellow(warnGlyph)} ${phrase} (run nomad doctor to list)`);
7401
7577
  }
7402
- return notes;
7578
+ return rows;
7403
7579
  }
7404
7580
  function buildSyncSummarySection(pull, pushOutcome) {
7405
7581
  const s = section("Sync summary");
@@ -7407,22 +7583,59 @@ function buildSyncSummarySection(pull, pushOutcome) {
7407
7583
  addItem(s, `pull: applied, push: failed (${pushOutcome.message})`);
7408
7584
  return s;
7409
7585
  }
7410
- addItem(s, `pull: ${pullPhrase(pull)}`);
7411
- addItem(s, `push: ${pushOutcome.result.tag === "pushed" ? "pushed" : "nothing to push"}`);
7412
- for (const note of reconciledNotes(pull)) addItem(s, note);
7586
+ const { result } = pushOutcome;
7587
+ addItem(s, buildPullSummaryRow(pull));
7588
+ addItem(s, buildPushSummaryRow(pull, result));
7589
+ if (result.tag === "nothing" && result.aheadOfOrigin === true) {
7590
+ addItem(s, "sync repo has unpushed commits");
7591
+ }
7592
+ for (const row2 of buildSkipAndCollisionRows(pull, result)) addItem(s, row2);
7413
7593
  return s;
7414
7594
  }
7415
- function renderWetSync(pull, pushOutcome) {
7595
+ var SYNC_SECTION_ORDER = ["Settings", "Global config", "Sessions", "Extras", "Leak scan"];
7596
+ function isDroppedSummaryHeader(header) {
7597
+ return header === PULL_SUMMARY_HEADER || header === "Push summary";
7598
+ }
7599
+ function mergeSyncSections(pullSections, pushSections) {
7600
+ const byHeader = new Map(
7601
+ SYNC_SECTION_ORDER.map((header) => [header, section(header)])
7602
+ );
7603
+ for (const s of [...pullSections, ...pushSections]) {
7604
+ if (isDroppedSummaryHeader(s.header)) continue;
7605
+ let target = byHeader.get(s.header);
7606
+ if (target === void 0) {
7607
+ target = section(s.header);
7608
+ byHeader.set(s.header, target);
7609
+ }
7610
+ for (const item2 of s.items) {
7611
+ if (!target.items.includes(item2)) addItem(target, item2);
7612
+ }
7613
+ }
7614
+ return [...byHeader.values()].filter((s) => s.items.length > 0);
7615
+ }
7616
+ function pushSectionsOf(pushOutcome) {
7617
+ if (!pushOutcome.ok) return [];
7618
+ const result = pushOutcome.result;
7619
+ if (result.tag === "dry") return [];
7620
+ return result.sections ?? [];
7621
+ }
7622
+ function renderWetSync(pull, pushOutcome, verbose) {
7416
7623
  if (isNoopSync(pull, pushOutcome)) {
7417
7624
  ok("already in sync");
7418
7625
  return;
7419
7626
  }
7420
- renderTree(pull.sections);
7421
- renderTree([buildSyncSummarySection(pull, pushOutcome)]);
7627
+ log(`sync on host=${HOST}`);
7628
+ const summary = buildSyncSummarySection(pull, pushOutcome);
7629
+ if (!verbose) {
7630
+ renderTree([summary]);
7631
+ return;
7632
+ }
7633
+ const merged = mergeSyncSections(pull.sections, pushSectionsOf(pushOutcome));
7634
+ renderTree([...merged, summary]);
7422
7635
  }
7423
7636
  async function runSyncPushHalf() {
7424
7637
  try {
7425
- const result = await runPushCore();
7638
+ const result = await runPushCore({ compose: true });
7426
7639
  return { ok: true, result };
7427
7640
  } catch (err) {
7428
7641
  if (err instanceof NomadFatal) {
@@ -7432,10 +7645,10 @@ async function runSyncPushHalf() {
7432
7645
  throw err;
7433
7646
  }
7434
7647
  }
7435
- async function runSyncWet() {
7436
- const pull = runPullCore();
7648
+ async function runSyncWet(verbose) {
7649
+ const pull = runPullCore({ compose: true });
7437
7650
  const pushOutcome = await runSyncPushHalf();
7438
- renderWetSync(pull, pushOutcome);
7651
+ renderWetSync(pull, pushOutcome, verbose);
7439
7652
  }
7440
7653
  async function runSyncDryRun(repo, backup) {
7441
7654
  const ts = freshBackupTs(backup);
@@ -7447,6 +7660,7 @@ async function runSyncDryRun(repo, backup) {
7447
7660
  }
7448
7661
  async function cmdSync(opts = {}) {
7449
7662
  const dryRun = opts.dryRun === true;
7663
+ const verbose = opts.verbose === true;
7450
7664
  const repo = repoHome();
7451
7665
  const backup = backupBase();
7452
7666
  if (!existsSync46(repo)) die(`repo not cloned at ${repo}`);
@@ -7459,7 +7673,7 @@ async function cmdSync(opts = {}) {
7459
7673
  if (dryRun) {
7460
7674
  await runSyncDryRun(repo, backup);
7461
7675
  } else {
7462
- await runSyncWet();
7676
+ await runSyncWet(verbose);
7463
7677
  }
7464
7678
  } catch (err) {
7465
7679
  if (err instanceof NomadFatal) {
@@ -8084,24 +8298,27 @@ function parsePushArgs(argv) {
8084
8298
  // src/nomad.dispatch.sync.ts
8085
8299
  function parseSyncArgs(argv) {
8086
8300
  let dryRun = false;
8301
+ let verbose = false;
8087
8302
  let i = 3;
8088
8303
  while (i < argv.length) {
8089
8304
  const token = argv[i];
8090
8305
  if (token === "--dry-run") {
8091
8306
  if (dryRun) return null;
8092
8307
  dryRun = true;
8308
+ } else if (token === "--verbose" || token === "--all" || token === "-v") {
8309
+ verbose = true;
8093
8310
  } else {
8094
8311
  return null;
8095
8312
  }
8096
8313
  i++;
8097
8314
  }
8098
- return { dryRun };
8315
+ return { dryRun, verbose };
8099
8316
  }
8100
8317
 
8101
8318
  // package.json
8102
8319
  var package_default = {
8103
8320
  name: "claude-nomad",
8104
- version: "0.59.0",
8321
+ version: "0.60.0",
8105
8322
  type: "module",
8106
8323
  description: "Sync Claude Code config (~/.claude/) across machines via a private Git repo, with path remapping and per-host settings overrides.",
8107
8324
  keywords: [
@@ -8236,10 +8453,15 @@ var DEFAULT_HELP = [
8236
8453
  cont("Mutually exclusive with --redact-all/--allow. Cannot combine with --dry-run."),
8237
8454
  "",
8238
8455
  row(" sync", "Pull (retain-merge), then push, under one lock. One-shot; hides ordering."),
8456
+ cont("Compact by default: prints only the Sync summary."),
8239
8457
  row(" --dry-run", "Stack the pull preview then the push preview; mutates nothing."),
8240
8458
  cont("Fails fast on a pull-half error; a wedged repo hints at nomad pull --force-remote"),
8241
8459
  cont("(sync itself takes no --force-remote). Mid-push leak recovery behaves exactly"),
8242
8460
  cont("like nomad push."),
8461
+ row(
8462
+ " --verbose, --all, -v",
8463
+ "Show the full merged tree; default output is the Sync summary only."
8464
+ ),
8243
8465
  "",
8244
8466
  row(" diff", "Offline preview of what `pull` would change against local repo state."),
8245
8467
  cont("No git pull, no lock acquired."),
@@ -8465,10 +8687,10 @@ try {
8465
8687
  case "sync": {
8466
8688
  const syncArgs = parseSyncArgs(process.argv);
8467
8689
  if (syncArgs === null) {
8468
- console.error("usage: nomad sync [--dry-run]");
8690
+ console.error("usage: nomad sync [--dry-run] [--verbose|--all|-v]");
8469
8691
  process.exit(1);
8470
8692
  }
8471
- await cmdSync({ dryRun: syncArgs.dryRun });
8693
+ await cmdSync({ dryRun: syncArgs.dryRun, verbose: syncArgs.verbose });
8472
8694
  break;
8473
8695
  }
8474
8696
  case "init": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-nomad",
3
- "version": "0.59.0",
3
+ "version": "0.60.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": [