claude-nomad 0.31.0 → 0.32.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.32.1](https://github.com/funkadelic/claude-nomad/compare/v0.32.0...v0.32.1) (2026-05-30)
4
+
5
+
6
+ ### Fixed
7
+
8
+ * **redact:** make applyRedactions overlap-safe ([#186](https://github.com/funkadelic/claude-nomad/issues/186)) ([8da07d1](https://github.com/funkadelic/claude-nomad/commit/8da07d1ef58b7f6596ca479a3c57863fc75f35bb))
9
+
10
+ ## [0.32.0](https://github.com/funkadelic/claude-nomad/compare/v0.31.0...v0.32.0) (2026-05-30)
11
+
12
+
13
+ ### Added
14
+
15
+ * **adopt:** add nomad adopt for pre-existing local dirs ([#185](https://github.com/funkadelic/claude-nomad/issues/185)) ([251d5b7](https://github.com/funkadelic/claude-nomad/commit/251d5b71569315cbcf6ae073e29faecb4dbe5aa2))
16
+
17
+
18
+ ### Documentation
19
+
20
+ * **readme:** note synced skills carry shims, not the tool engine ([#183](https://github.com/funkadelic/claude-nomad/issues/183)) ([695ba02](https://github.com/funkadelic/claude-nomad/commit/695ba029a92f397a1469ca1cf5f782e0b5bcecac))
21
+
3
22
  ## [0.31.0](https://github.com/funkadelic/claude-nomad/compare/v0.30.0...v0.31.0) (2026-05-29)
4
23
 
5
24
 
package/README.md CHANGED
@@ -194,6 +194,17 @@ Pointers and specifics:
194
194
  > URLs) still need that side set up on each host. Put them in `hosts/<host>.json` or the plugin's
195
195
  > own per-host config.
196
196
 
197
+ <!-- prettier-ignore -->
198
+ > [!IMPORTANT]
199
+ > Syncing a tool's `skills/` or `commands/` files copies the command shims, not the engine behind
200
+ > them. If a tool keeps a binary or runtime outside `~/.claude/` (installed with `npm i -g`, a setup
201
+ > script, and so on), nomad does not carry that part, so the synced commands appear on a new host but
202
+ > fail until the tool itself is installed there. Install such tools once per host. For example, if you
203
+ > sync the GSD (`get-shit-done`) skills, run `npm i -g get-shit-done-cc` on each host, pinned to the
204
+ > version that matches your committed skills. Claude Code marketplace plugins (such as superpowers)
205
+ > are the exception: they are listed in `enabledPlugins`, synced via `settings.base.json`, and
206
+ > re-downloaded by Claude Code automatically, so they need no manual install.
207
+
197
208
  For the rationale behind these choices, see
198
209
  [What does NOT sync (deliberate trade-offs)](#what-does-not-sync-deliberate-trade-offs).
199
210
 
@@ -614,6 +625,8 @@ point under your npm prefix's `bin/`), then delete the alias line from your shel
614
625
  | `nomad push --dry-run` | Run pre-push safety checks (gitleaks probe, rebase, remap preview, gitlink scan, allow-list) and a read-only gitleaks leak preview over a throwaway temp copy of the sessions and extras this host would stage; skip stage, commit, and push. Exits 1 if a leak is found in the preview. Nothing is written to the sync repo. |
615
626
  | `nomad push --redact-all` | Redact all findings non-interactively (backup written first) without a TTY. Does not auto-Allow findings. After redaction re-stages and re-scans; aborts with the session-aware FATAL if any finding survives. Use this in scripts or when you are confident every finding is a real secret that should be scrubbed. See [Recovery flow: push-time interactive menu](#recovery-flow-push-time-interactive-menu). |
616
627
  | `nomad drop-session <id>` | Surgically unstage every `shared/projects/*/<id>.jsonl` and the sibling `shared/projects/*/<id>/` subagent directory from the staged tree of `~/claude-nomad/`. Idempotent; the local `~/.claude/projects/<encoded>/<id>.jsonl` and `<id>/` tree are preserved. See [Recovery flows](#recovery-flows). |
628
+ | `nomad adopt <name>` | Back up, then move a pre-existing `~/.claude/<name>` directory into `shared/<name>`, recreate the symlink so this host keeps working, and stage the result for push. `<name>` must already be listed in `SHARED_LINKS` or in the `sharedDirs` field of `path-map.json`; adopt is a mover, not a config editor, so it never writes `path-map.json` itself. |
629
+ | `nomad adopt <name> --dry-run` | Preview the planned backup, move, and `git add` without touching the filesystem or the git index. |
617
630
  | `nomad redact <session-id>` | Rewrite the secret span in the local source transcript for a session, backed up to `~/.cache/claude-nomad/backup/`. Refuses to touch a session that was modified recently (potential active session). Safe to re-run. See [`nomad redact <session-id>`](#nomad-redact-session-id). |
618
631
  | `nomad redact --rule <id>` | Limit redaction to findings of one gitleaks rule id only. |
619
632
  | `nomad redact --dry-run` | Show what `nomad redact` would change without writing anything. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-nomad",
3
- "version": "0.31.0",
3
+ "version": "0.32.1",
4
4
  "type": "module",
5
5
  "description": "Sync Claude Code config (~/.claude/) across machines via a private Git repo, with path remapping and per-host settings overrides.",
6
6
  "keywords": [
@@ -0,0 +1,183 @@
1
+ import { cpSync, existsSync, lstatSync, rmSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ import { CLAUDE_HOME, HOME, REPO_HOME, SHARED_LINKS, type PathMap } from './config.ts';
5
+ import { isValidSharedDir } from './config.sharedDirs.guard.ts';
6
+ import { fail, gitOrFatal, log, NomadFatal } from './utils.ts';
7
+ import { backupBeforeWrite, ensureSymlink, freshBackupTs } from './utils.fs.ts';
8
+ import { readPathMap } from './utils.json.ts';
9
+
10
+ /**
11
+ * Follow-up hint printed after a successful adopt. Exported so Plan 02's
12
+ * doctor hint can reuse the exact literal without duplicating the string.
13
+ */
14
+ export const ADOPT_PUSH_HINT = 'run `nomad push` to share with other hosts';
15
+
16
+ /**
17
+ * lstat-based existence check that, unlike `existsSync`, does NOT follow
18
+ * symlinks: a dangling symlink at `p` returns true. Used for the clobber
19
+ * guard so an existing (even broken) `shared/<name>` link is refused rather
20
+ * than fed to `cpSync`, which would otherwise throw an opaque non-NomadFatal
21
+ * error on a dangling-symlink destination.
22
+ *
23
+ * @param p Absolute path to probe.
24
+ * @returns True when any entry (file, dir, or symlink) exists at `p`.
25
+ */
26
+ function lexists(p: string): boolean {
27
+ try {
28
+ lstatSync(p);
29
+ return true;
30
+ } catch {
31
+ return false;
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Read `path-map.json` if present; fall back to an empty map when absent.
37
+ * Adopt reads sharedDirs for membership only; it never writes path-map.json.
38
+ *
39
+ * @param repoHome Absolute path to the nomad repo root.
40
+ * @returns The parsed PathMap, or `{ projects: {} }` when path-map.json is absent.
41
+ */
42
+ function readMapIfPresent(repoHome: string): PathMap {
43
+ const mapPath = join(repoHome, 'path-map.json');
44
+ return existsSync(mapPath) ? readPathMap(mapPath) : { projects: {} };
45
+ }
46
+
47
+ /**
48
+ * Return true when `name` is an already-configured shared target: either a
49
+ * static `SHARED_LINKS` member or a `sharedDirs` entry declared in
50
+ * `path-map.json`. This is a read-only membership check; adopt never writes
51
+ * `path-map.json` (D-03).
52
+ *
53
+ * @param name Candidate name.
54
+ * @param map Parsed path-map (sharedDirs membership source).
55
+ * @returns True when name is a configured shared target.
56
+ */
57
+ function isConfiguredTarget(name: string, map: PathMap): boolean {
58
+ return (
59
+ (SHARED_LINKS as readonly string[]).includes(name) || (map.sharedDirs?.includes(name) ?? false)
60
+ );
61
+ }
62
+
63
+ /**
64
+ * Return true when `name` is safe to adopt. Static `SHARED_LINKS` members
65
+ * are pre-approved and bypass `isValidSharedDir` (which rejects RESERVED_SHARED,
66
+ * overlapping with SHARED_LINKS). Candidate `sharedDirs` names must pass
67
+ * `isValidSharedDir` to prevent path injection (D-00a).
68
+ *
69
+ * @param name Candidate name from the CLI argument.
70
+ * @returns True when the name is safe for adopt processing.
71
+ */
72
+ function isValidAdoptName(name: string): boolean {
73
+ if ((SHARED_LINKS as readonly string[]).includes(name)) return true;
74
+ return isValidSharedDir(name);
75
+ }
76
+
77
+ /**
78
+ * Perform the actual backup -> copy -> remove -> relink -> stage sequence
79
+ * once all preconditions have passed. Extracts the mutation block so the
80
+ * top-level function stays under the cognitive-complexity threshold.
81
+ *
82
+ * @param name The validated, configured, real-directory name to adopt.
83
+ * @param linkPath Absolute path of the source directory (`CLAUDE_HOME/<name>`).
84
+ * @param sharedTarget Absolute path of the destination (`REPO_HOME/shared/<name>`).
85
+ */
86
+ function performAdoptMove(name: string, linkPath: string, sharedTarget: string): void {
87
+ const backupBase = join(HOME, '.cache', 'claude-nomad', 'backup');
88
+ const ts = freshBackupTs(backupBase);
89
+
90
+ // D-00c: backup before any mutation
91
+ backupBeforeWrite(linkPath, ts);
92
+
93
+ // D-00e, V-07: copy fully into shared/ BEFORE removing the source so a
94
+ // mid-move crash cannot lose user content
95
+ cpSync(linkPath, sharedTarget, { recursive: true, force: true, preserveTimestamps: true });
96
+ rmSync(linkPath, { recursive: true, force: true });
97
+
98
+ // D-01: recreate the symlink immediately on this host
99
+ ensureSymlink(linkPath, sharedTarget);
100
+
101
+ // D-02: targeted stage of shared/<name> only; never git add -A
102
+ const rel = join('shared', name);
103
+ gitOrFatal(['add', '--', rel], `git add shared/${name}`, REPO_HOME);
104
+
105
+ log(`adopted ${name}; ${ADOPT_PUSH_HINT}`);
106
+ }
107
+
108
+ /**
109
+ * Bring a pre-existing `~/.claude/<name>` directory into the nomad shared set.
110
+ *
111
+ * Validates `name`, enforces the precondition matrix, then performs:
112
+ * backup -> copy-into-shared -> remove-source -> recreate-symlink ->
113
+ * targeted `git add` -> print follow-up hint. Stops there: no auto-commit,
114
+ * no push pipeline (D-02).
115
+ *
116
+ * Accepts only already-configured names: a static SHARED_LINKS member or a
117
+ * `sharedDirs` entry already declared in `path-map.json`. adopt is a mover,
118
+ * not a config editor; it never writes `path-map.json` (D-03).
119
+ *
120
+ * `--dry-run` reports the planned actions and performs zero filesystem or
121
+ * git changes (D-00d, V-08).
122
+ *
123
+ * @param name The `~/.claude/<name>` directory to adopt.
124
+ * @param opts.dryRun When true, log planned actions and return without mutation.
125
+ */
126
+ export function cmdAdopt(name: string, opts: { dryRun?: boolean } = {}): void {
127
+ const dryRun = opts.dryRun === true;
128
+
129
+ // D-00a: validate name format (rejects path separators, NEVER_SYNC, and arbitrary
130
+ // names that are not in SHARED_LINKS; SHARED_LINKS statics bypass isValidSharedDir
131
+ // because RESERVED_SHARED overlaps with SHARED_LINKS by design)
132
+ if (!isValidAdoptName(name)) {
133
+ fail(`invalid name: ${JSON.stringify(name)}`);
134
+ process.exit(1);
135
+ }
136
+
137
+ // D-03: confirm name is an already-configured shared target
138
+ const map = readMapIfPresent(REPO_HOME);
139
+ if (!isConfiguredTarget(name, map)) {
140
+ fail(
141
+ `${name}: not a configured shared target. ` +
142
+ `Add it to sharedDirs in path-map.json first, then re-run adopt.`,
143
+ );
144
+ process.exit(1);
145
+ }
146
+
147
+ const linkPath = join(CLAUDE_HOME, name);
148
+ const sharedTarget = join(REPO_HOME, 'shared', name);
149
+
150
+ // D-00b precondition checks -- in order: absent, already symlink, would clobber
151
+ if (!existsSync(linkPath)) {
152
+ log(`${name}: nothing to adopt (not present in ~/.claude/)`);
153
+ return;
154
+ }
155
+ if (lstatSync(linkPath).isSymbolicLink()) {
156
+ log(`${name}: already adopted (already a symlink)`);
157
+ return;
158
+ }
159
+ if (lexists(sharedTarget)) {
160
+ fail(`${name}: shared/${name} already exists; would clobber. Remove it first.`);
161
+ process.exit(1);
162
+ }
163
+
164
+ // D-00d: dry-run preview -- branch before any mutation
165
+ if (dryRun) {
166
+ const backupBase = join(HOME, '.cache', 'claude-nomad', 'backup');
167
+ const ts = freshBackupTs(backupBase);
168
+ log(`would backup: ${linkPath} -> backup/${ts}/${name}`);
169
+ log(`would move: ${linkPath} -> shared/${name}`);
170
+ log(`would stage: shared/${name}`);
171
+ return;
172
+ }
173
+
174
+ /* c8 ignore start -- catch is defensive: performAdoptMove only throws on a git/fs fault */
175
+ try {
176
+ performAdoptMove(name, linkPath, sharedTarget);
177
+ } catch (err) {
178
+ if (!(err instanceof NomadFatal)) throw err;
179
+ fail(err.message);
180
+ process.exitCode = 1;
181
+ }
182
+ /* c8 ignore stop */
183
+ }
@@ -119,7 +119,10 @@ function classifySharedLink(name: string, p: string): { line: string; fail: bool
119
119
  return { line: `${red(failGlyph)} ${name}: could not stat (${String(code)})`, fail: true };
120
120
  }
121
121
  if (!stat.isSymbolicLink()) {
122
- return { line: `${red(failGlyph)} ${name}: NOT a symlink (blocks sync)`, fail: true };
122
+ return {
123
+ line: `${red(failGlyph)} ${name}: NOT a symlink (blocks sync); run \`nomad adopt ${name}\` to fix`,
124
+ fail: true,
125
+ };
123
126
  }
124
127
  return classifySymlinkTarget(name, p);
125
128
  }
@@ -3,25 +3,6 @@ import { join } from 'node:path';
3
3
 
4
4
  import { REPO_HOME } from './config.ts';
5
5
 
6
- /**
7
- * Replace every occurrence of a literal secret value in a raw line. Uses
8
- * split/join to avoid regex escaping and to replace all occurrences. Pure,
9
- * no I/O.
10
- *
11
- * The replacement token `[REDACTED:<ruleId>]` contains no JSON-special
12
- * characters, so the result remains valid JSON when the value sits inside a
13
- * JSON string token.
14
- *
15
- * @param line Raw JSONL line text.
16
- * @param match Literal secret value to replace (empty string is a no-op).
17
- * @param ruleId Gitleaks rule identifier included in the replacement token.
18
- * @returns Line with all occurrences of `match` replaced by `[REDACTED:<ruleId>]`.
19
- */
20
- export function redactValue(line: string, match: string, ruleId: string): string {
21
- if (match === '') return line;
22
- return line.split(match).join(`[REDACTED:${ruleId}]`);
23
- }
24
-
25
6
  /** Minimal finding shape consumed by `applyRedactions`. */
26
7
  export type RedactFinding = {
27
8
  StartLine: number;
@@ -29,25 +10,95 @@ export type RedactFinding = {
29
10
  RuleID: string;
30
11
  };
31
12
 
13
+ /** A half-open byte interval `[start, end)` derived from a `Match` value. */
14
+ type MatchInterval = {
15
+ start: number;
16
+ end: number;
17
+ ruleId: string;
18
+ };
19
+
32
20
  /**
33
- * Apply all findings for one file in memory. Replaces each finding's `Match`
34
- * value globally across the whole content string (split/join, no column
35
- * arithmetic). To avoid a shorter secret being a substring of a longer one
36
- * causing a partial match, findings are sorted by `Match.length` descending so
37
- * the longer secret is replaced first. Findings with an empty `Match` are
38
- * silently skipped (a defensive guard: an empty match would otherwise inject
39
- * the token between every character). Pure, no I/O.
21
+ * Locate every occurrence of each finding's `Match` value in `content` using
22
+ * `indexOf`. Findings with an empty `Match` are skipped. Multiple
23
+ * non-overlapping occurrences of the same value are each recorded as a
24
+ * separate interval. Offsets are value-derived, not column-derived, so they
25
+ * are always correct regardless of gitleaks column alignment.
26
+ *
27
+ * @param content Full file content as a single string.
28
+ * @param findings Array of finding descriptors.
29
+ * @returns Array of `{start, end, ruleId}` intervals (unmerged, unsorted).
30
+ */
31
+ export function collectMatchIntervals(
32
+ content: string,
33
+ findings: readonly RedactFinding[],
34
+ ): MatchInterval[] {
35
+ const intervals: MatchInterval[] = [];
36
+ for (const f of findings) {
37
+ const match = f.Match;
38
+ if (match === '') continue;
39
+ let from = 0;
40
+ let pos = content.indexOf(match, from);
41
+ while (pos !== -1) {
42
+ intervals.push({ start: pos, end: pos + match.length, ruleId: f.RuleID });
43
+ from = pos + match.length;
44
+ pos = content.indexOf(match, from);
45
+ }
46
+ }
47
+ return intervals;
48
+ }
49
+
50
+ /**
51
+ * Sort and merge a list of (possibly overlapping or adjacent) intervals into a
52
+ * minimal list of non-overlapping spans. Sort order is start ascending, then
53
+ * end descending so that a longer interval at the same start position wins its
54
+ * `ruleId`. Overlapping or adjacent intervals are folded into one span that
55
+ * extends to the maximum end seen, keeping the first span's `ruleId`.
56
+ *
57
+ * @param intervals Unmerged intervals from `collectMatchIntervals`.
58
+ * @returns Sorted, merged, non-overlapping intervals.
59
+ */
60
+ export function mergeIntervals(intervals: MatchInterval[]): MatchInterval[] {
61
+ if (intervals.length === 0) return [];
62
+ const sorted = [...intervals].sort((a, b) => a.start - b.start || b.end - a.end);
63
+ let last = { ...sorted[0] };
64
+ const merged: MatchInterval[] = [last];
65
+ for (let i = 1; i < sorted.length; i++) {
66
+ const cur = sorted[i];
67
+ if (cur.start <= last.end) {
68
+ if (cur.end > last.end) last.end = cur.end;
69
+ } else {
70
+ last = { ...cur };
71
+ merged.push(last);
72
+ }
73
+ }
74
+ return merged;
75
+ }
76
+
77
+ /**
78
+ * Apply all findings for one file in memory. Collects every occurrence of each
79
+ * finding's `Match` value via `indexOf`, merges overlapping and adjacent spans
80
+ * into non-overlapping intervals, then replaces each interval right-to-left so
81
+ * earlier offsets remain valid. Findings with an empty `Match` are silently
82
+ * skipped. Returns `content` unchanged when there are no findings or no
83
+ * occurrences are found.
84
+ *
85
+ * The replacement token `[REDACTED:<ruleId>]` is byte-identical to the previous
86
+ * format. Right-to-left replacement guarantees that overlapping matches (e.g.
87
+ * two findings whose `Match` values share a middle span) are replaced by a
88
+ * single token covering the full union, leaving no fragment. Pure, no I/O.
40
89
  *
41
90
  * @param content Full file content as a single string.
42
91
  * @param findings Array of finding descriptors.
43
92
  * @returns Redacted file content.
44
93
  */
45
94
  export function applyRedactions(content: string, findings: readonly RedactFinding[]): string {
46
- const sorted = [...findings].sort((a, b) => b.Match.length - a.Match.length);
95
+ const raw = collectMatchIntervals(content, findings);
96
+ if (raw.length === 0) return content;
97
+ const merged = mergeIntervals(raw);
47
98
  let result = content;
48
- for (const f of sorted) {
49
- if (f.Match === '') continue;
50
- result = result.split(f.Match).join(`[REDACTED:${f.RuleID}]`);
99
+ for (let i = merged.length - 1; i >= 0; i--) {
100
+ const { start, end, ruleId } = merged[i];
101
+ result = result.slice(0, start) + `[REDACTED:${ruleId}]` + result.slice(end);
51
102
  }
52
103
  return result;
53
104
  }
@@ -15,7 +15,6 @@ export {
15
15
  appendGitleaksIgnore,
16
16
  formatFingerprint,
17
17
  isRecentlyModified,
18
- redactValue,
19
18
  } from './commands.redact.core.ts';
20
19
 
21
20
  /**
package/src/nomad.help.ts CHANGED
@@ -61,6 +61,13 @@ export const DEFAULT_HELP = [
61
61
  'Unstage shared/projects/<logical>/<id>.jsonl from the staged tree (local ~/.claude/projects is never touched).',
62
62
  ),
63
63
  '',
64
+ row(
65
+ ' adopt <name>',
66
+ 'Move a pre-existing ~/.claude/<name> dir into shared/<name>, recreate the',
67
+ ),
68
+ cont('symlink, and stage for push. <name> must be in SHARED_LINKS or sharedDirs.'),
69
+ row(' --dry-run', 'Preview backup, move, and git-add without writing.'),
70
+ '',
64
71
  row(
65
72
  ' redact <session-id>',
66
73
  'Rewrite the secret span in the local source transcript for a session,',
package/src/nomad.ts CHANGED
@@ -15,6 +15,7 @@
15
15
  * path-map.json logical project name -> { host: localPath }
16
16
  */
17
17
 
18
+ import { cmdAdopt } from './commands.adopt.ts';
18
19
  import { cmdDoctor } from './commands.doctor.ts';
19
20
  import { cmdDropSession } from './commands.drop-session.ts';
20
21
  import { cmdRedact } from './commands.redact.ts';
@@ -125,6 +126,24 @@ try {
125
126
  });
126
127
  break;
127
128
  }
129
+ case 'adopt': {
130
+ // Required positional <name>; optional --dry-run. Any other shape
131
+ // (missing name, leading-dash name, two positionals, unknown flag)
132
+ // is a usage error. Single <name> per invocation (D-04).
133
+ const name = process.argv[3];
134
+ const sub = process.argv[4];
135
+ if (
136
+ typeof name !== 'string' ||
137
+ name.length === 0 ||
138
+ name.startsWith('-') ||
139
+ (sub !== undefined && (sub !== '--dry-run' || process.argv.length !== 5))
140
+ ) {
141
+ console.error('usage: nomad adopt <name> [--dry-run]');
142
+ process.exit(1);
143
+ }
144
+ cmdAdopt(name, { dryRun: sub === '--dry-run' });
145
+ break;
146
+ }
128
147
  case 'doctor':
129
148
  // Sub-flags: `doctor --resume-cmd <session-id>` dispatches to the
130
149
  // read-only sidecar that prints `cd <abspath> && claude --resume <id>`;