claude-nomad 0.51.0 → 0.52.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,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.52.0](https://github.com/funkadelic/claude-nomad/compare/v0.51.1...v0.52.0) (2026-06-19)
4
+
5
+
6
+ ### Added
7
+
8
+ * stop syncing gsd-owned hook entries from settings.base.json ([#317](https://github.com/funkadelic/claude-nomad/issues/317)) ([31f5f79](https://github.com/funkadelic/claude-nomad/commit/31f5f79b16245dd792a12a7fc315661c34bae610))
9
+
10
+ ## [0.51.1](https://github.com/funkadelic/claude-nomad/compare/v0.51.0...v0.51.1) (2026-06-18)
11
+
12
+
13
+ ### Fixed
14
+
15
+ * **doctor:** handle diverged settings keys honestly instead of advising blind pull ([#315](https://github.com/funkadelic/claude-nomad/issues/315)) ([39f8148](https://github.com/funkadelic/claude-nomad/commit/39f81480285693e08575f5ab4ecbb8b182eeb233))
16
+
3
17
  ## [0.51.0](https://github.com/funkadelic/claude-nomad/compare/v0.50.3...v0.51.0) (2026-06-18)
4
18
 
5
19
 
package/README.md CHANGED
@@ -33,6 +33,10 @@ survives different file paths and your secrets never ride along.
33
33
  churn). Skills sync as a filtered copy: your own skills travel, `gsd-*` skills are excluded (see
34
34
  `SHARED_LINKS` and `src/skills-sync.ts` in `src/config.ts`). Settings merge a shared base with a
35
35
  per-host override, so one machine can run a different model or MCP URL without forking the rest.
36
+ GSD-owned hook entries (scripts whose basename starts with `gsd-`) are filtered out of the
37
+ generated `~/.claude/settings.json` during pull and stripped from `shared/settings.base.json` on
38
+ the next push; GSD reinstalls the correct per-host hook set itself. A non-gsd hook you add to your
39
+ live settings syncs normally via `nomad capture-settings`.
36
40
  - **Every push is secret-scanned.** Only an explicit allow-list of paths ever leaves the machine,
37
41
  credentials never sync, and gitleaks scans the exact files about to be published. The push aborts
38
42
  on any hit, with an interactive menu to redact, allow, or drop the finding.
package/dist/nomad.mjs CHANGED
@@ -1270,6 +1270,102 @@ import { existsSync as existsSync5 } from "node:fs";
1270
1270
  import { join as join7 } from "node:path";
1271
1271
  import { createInterface } from "node:readline/promises";
1272
1272
 
1273
+ // src/hooks-filter.ts
1274
+ init_config();
1275
+ var KNOWN_LAUNCHER_BASENAMES = /* @__PURE__ */ new Set(["node", "bash", "sh"]);
1276
+ function scriptBasename(token) {
1277
+ const lastSlash = Math.max(token.lastIndexOf("/"), token.lastIndexOf("\\"));
1278
+ return lastSlash >= 0 ? token.slice(lastSlash + 1) : token;
1279
+ }
1280
+ function isGsdHookEntry(command) {
1281
+ const tokens = command.trim().split(/\s+/);
1282
+ if (tokens.length === 0 || tokens[0] === "") return false;
1283
+ const envAssign = /^[A-Za-z_]\w*=/;
1284
+ let i = 0;
1285
+ while (i < tokens.length && envAssign.test(tokens[i])) {
1286
+ i++;
1287
+ }
1288
+ const first = tokens[i] ?? "";
1289
+ const firstBase = scriptBasename(first);
1290
+ const firstHasPath = first.includes("/") || first.includes("\\");
1291
+ if (firstHasPath && !KNOWN_LAUNCHER_BASENAMES.has(firstBase) || first.startsWith(GSD_PREFIX)) {
1292
+ return firstBase.startsWith(GSD_PREFIX);
1293
+ }
1294
+ for (let j = i + 1; j < tokens.length; j++) {
1295
+ if (tokens[j].startsWith("-")) continue;
1296
+ return scriptBasename(tokens[j]).startsWith(GSD_PREFIX);
1297
+ }
1298
+ return false;
1299
+ }
1300
+ function filterMatcherEntry(entry) {
1301
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)) return entry;
1302
+ const entryObj = entry;
1303
+ if (!Array.isArray(entryObj.hooks)) return entryObj;
1304
+ const innerHooks = entryObj.hooks;
1305
+ const kept = innerHooks.filter((h2) => {
1306
+ if (h2 === null || typeof h2 !== "object" || Array.isArray(h2)) return true;
1307
+ const hookObj = h2;
1308
+ const cmd = hookObj.command;
1309
+ return !isGsdHookEntry(typeof cmd === "string" ? cmd : "");
1310
+ });
1311
+ if (kept.length === 0) return null;
1312
+ return { ...entryObj, hooks: kept };
1313
+ }
1314
+ function filterEventMatchers(matchers) {
1315
+ if (!Array.isArray(matchers)) return matchers;
1316
+ const kept = [];
1317
+ for (const entry of matchers) {
1318
+ const result = filterMatcherEntry(entry);
1319
+ if (result != null) kept.push(result);
1320
+ }
1321
+ return kept.length === 0 ? null : kept;
1322
+ }
1323
+ function stripGsdHookEntries(settings) {
1324
+ const out = {};
1325
+ for (const [key, value] of Object.entries(settings)) {
1326
+ if (key !== "hooks") {
1327
+ out[key] = value;
1328
+ continue;
1329
+ }
1330
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
1331
+ out[key] = value;
1332
+ continue;
1333
+ }
1334
+ const hooksObj = value;
1335
+ const filteredHooks = {};
1336
+ for (const [event, matchers] of Object.entries(hooksObj)) {
1337
+ const filtered = filterEventMatchers(matchers);
1338
+ if (filtered !== null) filteredHooks[event] = filtered;
1339
+ }
1340
+ if (Object.keys(filteredHooks).length > 0) out[key] = filteredHooks;
1341
+ }
1342
+ return out;
1343
+ }
1344
+ function matcherHasGsdEntry(entry) {
1345
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)) return false;
1346
+ const entryObj = entry;
1347
+ if (!Array.isArray(entryObj.hooks)) return false;
1348
+ for (const h2 of entryObj.hooks) {
1349
+ if (h2 === null || typeof h2 !== "object" || Array.isArray(h2)) continue;
1350
+ const hookObj = h2;
1351
+ const cmd = hookObj.command;
1352
+ if (isGsdHookEntry(typeof cmd === "string" ? cmd : "")) return true;
1353
+ }
1354
+ return false;
1355
+ }
1356
+ function baseHasGsdHookEntries(settings) {
1357
+ const hooksVal = settings.hooks;
1358
+ if (hooksVal === null || typeof hooksVal !== "object" || Array.isArray(hooksVal)) return false;
1359
+ const hooksObj = hooksVal;
1360
+ for (const matchers of Object.values(hooksObj)) {
1361
+ if (!Array.isArray(matchers)) continue;
1362
+ for (const entry of matchers) {
1363
+ if (matcherHasGsdEntry(entry)) return true;
1364
+ }
1365
+ }
1366
+ return false;
1367
+ }
1368
+
1273
1369
  // src/commands.capture-settings.core.ts
1274
1370
  function arraysEqual(a, b) {
1275
1371
  if (a.length !== b.length) return false;
@@ -1299,19 +1395,24 @@ function deepEqual(a, b) {
1299
1395
  return false;
1300
1396
  }
1301
1397
  function classifySettingsDrift(merged, settings) {
1398
+ const filteredMerged = stripGsdHookEntries(merged);
1399
+ const filteredSettings = stripGsdHookEntries(settings);
1302
1400
  const behind = [];
1303
1401
  const ahead = [];
1304
1402
  const changed = [];
1305
- const settingsKeys = new Set(Object.keys(settings));
1306
- for (const key of Object.keys(merged)) {
1403
+ const settingsKeys = new Set(Object.keys(filteredSettings));
1404
+ for (const key of Object.keys(filteredMerged)) {
1307
1405
  if (!settingsKeys.has(key)) {
1308
1406
  behind.push(key);
1309
- } else if (!deepEqual(merged[key], settings[key])) {
1407
+ } else if (!deepEqual(
1408
+ normalizeNodePathsDeep(filteredMerged[key]),
1409
+ normalizeNodePathsDeep(filteredSettings[key])
1410
+ )) {
1310
1411
  changed.push(key);
1311
1412
  }
1312
1413
  }
1313
- const mergedKeys = new Set(Object.keys(merged));
1314
- for (const key of Object.keys(settings)) {
1414
+ const mergedKeys = new Set(Object.keys(filteredMerged));
1415
+ for (const key of Object.keys(filteredSettings)) {
1315
1416
  if (!mergedKeys.has(key)) ahead.push(key);
1316
1417
  }
1317
1418
  const collator = (a, b) => a.localeCompare(b, "en");
@@ -1337,10 +1438,13 @@ function partitionByCaptureExclusion(keys) {
1337
1438
  }
1338
1439
  return { promotable, excluded };
1339
1440
  }
1340
- var BIN_NODE_RE = /^(?:[A-Za-z]:)?[\\/](?:.*[\\/])?bin[\\/]node$/;
1441
+ var BIN_NODE_RE = /^"?(?:[A-Za-z]:)?[\\/](?:.*[\\/])?bin[\\/]node"?$/;
1442
+ var LEADING_BIN_NODE_RE = /^"?(?:[A-Za-z]:)?[\\/](?:[^"\s]*[\\/])?bin[\\/]node"?(?=\s)/;
1341
1443
  function normalizeNodePathsDeep(value) {
1342
1444
  if (typeof value === "string") {
1343
- return BIN_NODE_RE.test(value) ? "node" : value;
1445
+ if (BIN_NODE_RE.test(value)) return "node";
1446
+ const lead = LEADING_BIN_NODE_RE.exec(value);
1447
+ return lead ? "node" + value.slice(lead[0].length) : value;
1344
1448
  }
1345
1449
  if (Array.isArray(value)) {
1346
1450
  return value.map(normalizeNodePathsDeep);
@@ -1355,11 +1459,12 @@ function normalizeNodePathsDeep(value) {
1355
1459
  return value;
1356
1460
  }
1357
1461
  function buildCaptureSubset(merged, settings, opts) {
1462
+ const filtered = stripGsdHookEntries(settings);
1358
1463
  const { ahead } = classifySettingsDrift(merged, settings);
1359
1464
  const out = {};
1360
1465
  for (const key of ahead) {
1361
1466
  if (CAPTURE_EXCLUDED_KEYS.has(key)) continue;
1362
- const raw = settings[key];
1467
+ const raw = filtered[key];
1363
1468
  out[key] = opts.normalizeNodePath ? normalizeNodePathsDeep(raw) : raw;
1364
1469
  }
1365
1470
  return out;
@@ -1462,7 +1567,7 @@ function regenerateSettings(ts, opts = {}) {
1462
1567
  return { label: overrideLabel };
1463
1568
  }
1464
1569
  backupBeforeWrite(settingsPath, ts);
1465
- writeJsonAtomic(settingsPath, merged);
1570
+ writeJsonAtomic(settingsPath, stripGsdHookEntries(merged));
1466
1571
  return { label: overrideLabel };
1467
1572
  }
1468
1573
 
@@ -3204,6 +3309,16 @@ function tryReadJson(filePath) {
3204
3309
  return null;
3205
3310
  }
3206
3311
  }
3312
+ function reportHooksBaseSelfCleanNote(section2) {
3313
+ const basePath = join28(repoHome(), "shared", "settings.base.json");
3314
+ const base = tryReadJson(basePath);
3315
+ if (base === null) return;
3316
+ if (!baseHasGsdHookEntries(base)) return;
3317
+ addItem(
3318
+ section2,
3319
+ `${dim(infoGlyph)} gsd now owns hook entries per-host; shared/settings.base.json self-cleans on your next 'nomad push'`
3320
+ );
3321
+ }
3207
3322
  function reportSettingsDriftCheck(section2) {
3208
3323
  const claude = claudeHome();
3209
3324
  const repo = repoHome();
@@ -3258,7 +3373,7 @@ function emitDriftRows(section2, missing, changed, promotable, excluded, hostFil
3258
3373
  if (changed.length > 0) {
3259
3374
  addItem(
3260
3375
  section2,
3261
- `${yellow(warnGlyph)} settings.json drift: merged keys with changed values: ${changed.join(", ")} (run 'nomad pull')`
3376
+ `${yellow(warnGlyph)} settings.json drift: ${changed.join(", ")} diverged from the base+host merge (run 'nomad diff' to inspect; 'nomad pull' overwrites local with the repo, or edit the base/host file to keep local)`
3262
3377
  );
3263
3378
  }
3264
3379
  if (promotable.length > 0 && hostFileExists) {
@@ -4335,6 +4450,7 @@ function gatherDoctorSections(opts) {
4335
4450
  const parsedSettings = loadAndReportSettings(settings);
4336
4451
  reportHostOverrides(settings, base, parsedSettings);
4337
4452
  reportSettingsDriftCheck(settings);
4453
+ reportHooksBaseSelfCleanNote(settings);
4338
4454
  const pathMap = section("Path map");
4339
4455
  reportPathMap(pathMap);
4340
4456
  const neverSync = section("Never-sync");
@@ -5988,6 +6104,21 @@ function previewPushLeaks(map) {
5988
6104
  init_utils();
5989
6105
  init_utils_fs();
5990
6106
  init_utils_json();
6107
+ function stripGsdHooksFromBase(repo, backup) {
6108
+ const basePath = join46(repo, "shared", "settings.base.json");
6109
+ if (!existsSync39(basePath)) return;
6110
+ let base;
6111
+ try {
6112
+ base = readJson(basePath);
6113
+ } catch {
6114
+ return;
6115
+ }
6116
+ if (!baseHasGsdHookEntries(base)) return;
6117
+ const stripped = stripGsdHookEntries(base);
6118
+ const ts = freshBackupTs(backup);
6119
+ backupRepoWrite(basePath, ts, repo);
6120
+ writeJsonAtomic(basePath, stripped);
6121
+ }
5991
6122
  function reportSettingsAheadDrift(repo) {
5992
6123
  const basePath = join46(repo, "shared", "settings.base.json");
5993
6124
  if (!existsSync39(basePath)) return;
@@ -6087,7 +6218,10 @@ async function cmdPush(opts = {}) {
6087
6218
  const ts = freshBackupTs(backup);
6088
6219
  const remap = withSpinner("Syncing sessions", () => remapPush(ts, { dryRun }));
6089
6220
  const extras = withSpinner("Syncing extras", () => remapExtrasPush(ts, { dryRun }));
6090
- if (!dryRun) syncSkillsPush();
6221
+ if (!dryRun) {
6222
+ syncSkillsPush();
6223
+ stripGsdHooksFromBase(repo, backup);
6224
+ }
6091
6225
  const st = { dryRun, remap, extras, globalConfig: [] };
6092
6226
  guardGitlinks(repo);
6093
6227
  const status = gitStatusPorcelainZ(repo, { untrackedAll: true });
@@ -6660,7 +6794,7 @@ function parsePushArgs(argv) {
6660
6794
  // package.json
6661
6795
  var package_default = {
6662
6796
  name: "claude-nomad",
6663
- version: "0.51.0",
6797
+ version: "0.52.0",
6664
6798
  type: "module",
6665
6799
  description: "Sync Claude Code config (~/.claude/) across machines via a private Git repo, with path remapping and per-host settings overrides.",
6666
6800
  keywords: [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-nomad",
3
- "version": "0.51.0",
3
+ "version": "0.52.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": [