claude-nomad 0.17.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/src/init.ts ADDED
@@ -0,0 +1,190 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ import { CLAUDE_HOME, type PathMap, REPO_HOME } from './config.ts';
5
+ import { snapshotIntoShared } from './init.snapshot.ts';
6
+ import { die, log, readJson, writeJsonAtomic } from './utils.ts';
7
+
8
+ /**
9
+ * The HTML comment line that anchors `shared/CLAUDE.md` on a fresh scaffold.
10
+ * Empty file would be silently misleading after symlinking into `~/.claude/`;
11
+ * the comment makes the file self-describing when grep'd or `cat`-ed later.
12
+ */
13
+ const SHARED_CLAUDE_MD =
14
+ '<!-- claude-nomad shared CLAUDE.md; symlinked into ~/.claude/CLAUDE.md by nomad pull -->\n';
15
+
16
+ /**
17
+ * Subdirectories under `shared/` that get a `.gitkeep` placeholder on a fresh
18
+ * scaffold so the empty dirs survive git and materialize on every host. Pairs
19
+ * with the SHARED_LINKS contract in `src/config.ts` (those same names are
20
+ * symlinked into `~/.claude/` on every pull).
21
+ */
22
+ const SHARED_KEEP_DIRS = ['agents', 'skills', 'commands', 'rules'] as const;
23
+
24
+ /**
25
+ * Pre-flight refuse-to-clobber list. If ANY of these absolute paths
26
+ * already exists at the target REPO_HOME, `cmdInit` aborts with a NomadFatal
27
+ * naming the offender. Partial state is unsafe to merge with; init writes
28
+ * only into a clean target. Note that REPO_HOME itself is allowed to exist
29
+ * (e.g. it might be an empty dir created by `git clone` of an empty repo);
30
+ * the guard fires only on artifacts cmdInit is about to write.
31
+ */
32
+ function preflightConflict(repoHome: string): string | null {
33
+ const candidates = [
34
+ join(repoHome, 'shared', 'settings.base.json'),
35
+ join(repoHome, 'shared', 'CLAUDE.md'),
36
+ join(repoHome, 'path-map.json'),
37
+ join(repoHome, 'hosts'),
38
+ join(repoHome, 'shared'),
39
+ ];
40
+ for (const c of candidates) {
41
+ if (existsSync(c)) return c;
42
+ }
43
+ return null;
44
+ }
45
+
46
+ /**
47
+ * Scaffold REPO_HOME with the minimal layout `cmdDoctor` expects: `shared/`
48
+ * with `CLAUDE.md`, four `<name>/.gitkeep` markers, and an empty
49
+ * `settings.base.json`; `hosts/.gitkeep`; root `path-map.json` =
50
+ * `{"projects":{}}`. No auto-commit; no lock (no concurrent-mutator surface
51
+ * on a fresh target).
52
+ *
53
+ * When `opts.snapshot` is true, the user's current `~/.claude/` SHARED_LINKS
54
+ * are overlaid onto `shared/` and `~/.claude/settings.json` (if present) is
55
+ * translated into `hosts/<HOST>.json`. The placeholder `shared/CLAUDE.md`
56
+ * write is skipped when `~/.claude/CLAUDE.md` exists so the snapshot captures
57
+ * verbatim content; originals are NOT removed. Aborts with NomadFatal
58
+ * (containing `already initialized`) when any scaffold path already exists,
59
+ * identical to plain init; a bare `shared/` dir is enough to refuse since
60
+ * partial state is unsafe to merge with.
61
+ */
62
+ export function cmdInit(opts: { snapshot?: boolean } = {}): void {
63
+ const snapshot = opts.snapshot === true;
64
+
65
+ const conflict = preflightConflict(REPO_HOME);
66
+ if (conflict !== null) {
67
+ die(`already initialized; refusing to clobber ${conflict}`);
68
+ }
69
+
70
+ // Create the directory structure first so the subsequent file writes have
71
+ // a parent. `recursive: true` is a no-op when the dir already exists, but
72
+ // the preflight guarantees it does not.
73
+ mkdirSync(join(REPO_HOME, 'shared'), { recursive: true });
74
+ mkdirSync(join(REPO_HOME, 'hosts'), { recursive: true });
75
+ for (const name of SHARED_KEEP_DIRS) {
76
+ mkdirSync(join(REPO_HOME, 'shared', name), { recursive: true });
77
+ }
78
+
79
+ // Per-artifact writes. Each emits a log line so the user sees the
80
+ // structure being built. Atomic writes for JSON so a power loss mid-init
81
+ // leaves either a clean fresh-clone state or the fully-written scaffold,
82
+ // never a half-written JSON the next pull would die on. In snapshot mode,
83
+ // skip the CLAUDE.md placeholder when a real source exists so the overlay
84
+ // copies the user content verbatim instead of an overwrite-from-placeholder.
85
+ const userClaudeMd = join(CLAUDE_HOME, 'CLAUDE.md');
86
+ if (!snapshot || !existsSync(userClaudeMd)) {
87
+ writeFileSync(join(REPO_HOME, 'shared', 'CLAUDE.md'), SHARED_CLAUDE_MD);
88
+ log('created shared/CLAUDE.md');
89
+ }
90
+ for (const name of SHARED_KEEP_DIRS) {
91
+ writeFileSync(join(REPO_HOME, 'shared', name, '.gitkeep'), '');
92
+ log(`created shared/${name}/.gitkeep`);
93
+ }
94
+ writeFileSync(join(REPO_HOME, 'hosts', '.gitkeep'), '');
95
+ log('created hosts/.gitkeep');
96
+ writeJsonAtomic(join(REPO_HOME, 'shared', 'settings.base.json'), {});
97
+ log('created shared/settings.base.json');
98
+ writeJsonAtomic(join(REPO_HOME, 'path-map.json'), { projects: {} } satisfies PathMap);
99
+ log('created path-map.json');
100
+
101
+ if (snapshot) {
102
+ snapshotIntoShared();
103
+ log(`snapshot staged in shared/; review, then 'nomad push' to share with other hosts.`);
104
+ log('~/.claude/ originals were NOT removed.');
105
+ }
106
+
107
+ log('init complete');
108
+ }
109
+
110
+ /**
111
+ * Read-only health classifier for `cmdDoctor`'s `repo state:` header.
112
+ * Inspects three signals at the given `repoHome`: `shared/settings.base.json`
113
+ * presence, `path-map.json.projects` having at least one entry, and
114
+ * `hosts/<host>.json` presence.
115
+ *
116
+ * Returns `'empty'` when the base is missing AND the path-map has no entries
117
+ * (either missing or `projects` is empty); `'populated'` when all three
118
+ * signals are positive; `'partial'` for anything in between. Malformed
119
+ * `path-map.json` is treated as zero entries rather than thrown, so a doctor
120
+ * run against a corrupted scaffold still produces a classification line.
121
+ *
122
+ * The `host` parameter is passed explicitly (rather than read from the
123
+ * imported `HOST` constant) so the test fixture can drive multiple host
124
+ * scenarios without mutating module-level state via `vi.resetModules()`.
125
+ */
126
+ export function classifyRepoState(
127
+ repoHome: string,
128
+ host: string,
129
+ ): 'empty' | 'partial' | 'populated' {
130
+ const basePath = join(repoHome, 'shared', 'settings.base.json');
131
+ const mapPath = join(repoHome, 'path-map.json');
132
+ const hostPath = join(repoHome, 'hosts', `${host}.json`);
133
+
134
+ const hasBase = existsSync(basePath);
135
+ const hasMap = existsSync(mapPath);
136
+ const hasHost = existsSync(hostPath);
137
+
138
+ let mapEntryCount = 0;
139
+ if (hasMap) {
140
+ try {
141
+ const map = readJson<PathMap>(mapPath);
142
+ mapEntryCount = Object.keys(map.projects).length;
143
+ } catch {
144
+ // Malformed JSON: treat as zero entries, do NOT throw. The doctor's
145
+ // own JSON-parse FAIL line will surface the malformed file separately.
146
+ mapEntryCount = 0;
147
+ }
148
+ }
149
+
150
+ if (!hasBase && mapEntryCount === 0) return 'empty';
151
+ if (hasBase && mapEntryCount > 0 && hasHost) return 'populated';
152
+ return 'partial';
153
+ }
154
+
155
+ /**
156
+ * Suffix that follows `repo state: WARN partial` per the fixed priority
157
+ * order. First matching condition wins, exactly one suffix per line.
158
+ * Inspects the same on-disk signals `classifyRepoState` reads (base file,
159
+ * `path-map.json` + its `.projects` entry count, `hosts/<host>.json`), but
160
+ * explicitly distinguishes "path-map missing" from "path-map present but
161
+ * empty" because users debug differently for each.
162
+ *
163
+ * Lives alongside `classifyRepoState` so the suffix rules and the classifier
164
+ * stay co-located: changes to one almost always require updating the other.
165
+ * Returns the string with a leading `- ` separator so the caller can
166
+ * concatenate directly without re-deciding the separator.
167
+ */
168
+ export function reasonForPartial(repoHome: string, host: string): string {
169
+ const basePath = join(repoHome, 'shared', 'settings.base.json');
170
+ const mapPath = join(repoHome, 'path-map.json');
171
+ const hostPath = join(repoHome, 'hosts', `${host}.json`);
172
+ if (!existsSync(basePath)) return '- shared/settings.base.json missing';
173
+ if (!existsSync(mapPath)) return '- path-map.json missing';
174
+ let mapEntryCount: number;
175
+ try {
176
+ const map = readJson<PathMap>(mapPath);
177
+ mapEntryCount = Object.keys(map.projects).length;
178
+ } catch {
179
+ // Malformed JSON: treat as zero entries. Doctor's own JSON-parse FAIL
180
+ // line surfaces the malformed file separately.
181
+ mapEntryCount = 0;
182
+ }
183
+ if (mapEntryCount === 0) return '- path-map.json.projects has no entries';
184
+ if (!existsSync(hostPath)) return `- hosts/${host}.json missing`;
185
+ // Defensive fallback: classifyRepoState returned 'partial' for a reason
186
+ // not captured by the four signals above. Should be unreachable in
187
+ // practice because the priority order is exhaustive against the
188
+ // classifier's definition of populated.
189
+ return '- partial state (unknown gap)';
190
+ }
package/src/links.ts ADDED
@@ -0,0 +1,123 @@
1
+ import { existsSync, lstatSync, rmSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ import { CLAUDE_HOME, HOST, REPO_HOME, SHARED_LINKS } from './config.ts';
5
+ import {
6
+ backupBeforeWrite,
7
+ deepMerge,
8
+ die,
9
+ ensureSymlink,
10
+ log,
11
+ readJson,
12
+ warn,
13
+ writeJsonAtomic,
14
+ } from './utils.ts';
15
+
16
+ /**
17
+ * Symlink the `SHARED_LINKS` names from the repo's `shared/` dir into
18
+ * `~/.claude/`. Two-pass: first back up and remove any pre-existing
19
+ * non-symlink at each link path (auto-move using `ts` as the backup
20
+ * timestamp), then create the symlinks. Skips a link entirely when the repo
21
+ * has no counterpart, so a host where `shared/commands/` does not exist
22
+ * keeps its local `~/.claude/commands/` instead of having it silently
23
+ * deleted.
24
+ *
25
+ * `opts.dryRun` (default `false`): when `true`, no disk mutation occurs. The
26
+ * function logs `would auto-move non-symlink:` and `would create symlink:`
27
+ * lines describing the would-be effect and returns. Backwards-compatible: a
28
+ * call with no opts arg or with `dryRun: false` keeps the prior mutating
29
+ * behavior.
30
+ */
31
+ export function applySharedLinks(ts: string, opts: { dryRun?: boolean } = {}): void {
32
+ const dryRun = opts.dryRun === true;
33
+ for (const name of SHARED_LINKS) {
34
+ const linkPath = join(CLAUDE_HOME, name);
35
+ const target = join(REPO_HOME, 'shared', name);
36
+ if (!existsSync(linkPath)) continue;
37
+ if (lstatSync(linkPath).isSymbolicLink()) continue;
38
+ if (!existsSync(target)) continue;
39
+ if (dryRun) {
40
+ log(`would auto-move non-symlink: ${linkPath} -> backup/${ts}/${name}`);
41
+ continue;
42
+ }
43
+ backupBeforeWrite(linkPath, ts);
44
+ rmSync(linkPath, { recursive: true, force: true });
45
+ }
46
+ for (const name of SHARED_LINKS) {
47
+ const target = join(REPO_HOME, 'shared', name);
48
+ if (!existsSync(target)) continue;
49
+ if (dryRun) {
50
+ log(`would create symlink: ${join(CLAUDE_HOME, name)} -> ${target}`);
51
+ continue;
52
+ }
53
+ ensureSymlink(join(CLAUDE_HOME, name), target);
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Deep-merge `shared/settings.base.json` with `hosts/<HOST>.json` (when
59
+ * present) and atomically rewrite `~/.claude/settings.json`. Composes
60
+ * `writeJsonAtomic` (temp + fsync + rename + parent fsync) on top of
61
+ * `backupBeforeWrite`, so an interrupted pull leaves either the pre-pull
62
+ * file or the fully-merged file, never a half-written one. Surfaces a
63
+ * stderr WARN when no host override exists AND prior settings has top-level
64
+ * keys not in base; the matching doctor-side FAIL with non-zero exit lives
65
+ * in `cmdDoctor`.
66
+ *
67
+ * `opts.dryRun` (default `false`): when `true`, skip the
68
+ * `backupBeforeWrite` + `writeJsonAtomic` pair and instead log a single
69
+ * `would write settings.json ...` line. The drift-detection WARN above
70
+ * still fires (informational), so users see the same warning a real pull
71
+ * would produce. The unified textual diff of the would-be-written content
72
+ * is produced by `computePreview` in `src/preview.ts`, not here, to keep
73
+ * this function's contract simple (mutation or log-only).
74
+ */
75
+ export function regenerateSettings(ts: string, opts: { dryRun?: boolean } = {}): void {
76
+ const dryRun = opts.dryRun === true;
77
+ const basePath = join(REPO_HOME, 'shared', 'settings.base.json');
78
+ const hostPath = join(REPO_HOME, 'hosts', `${HOST}.json`);
79
+ if (!existsSync(basePath)) {
80
+ die("repo not initialized; run 'nomad init' to scaffold");
81
+ }
82
+
83
+ const base = readJson<Record<string, unknown>>(basePath);
84
+ const hasOverrides = existsSync(hostPath);
85
+ const overrides = hasOverrides ? readJson<Record<string, unknown>>(hostPath) : {};
86
+ const merged = deepMerge(base, overrides);
87
+
88
+ const settingsPath = join(CLAUDE_HOME, 'settings.json');
89
+
90
+ // Pull-side surface: warn-then-proceed when no host file matches AND
91
+ // existing settings has top-level keys not in base. Informational only;
92
+ // pull does NOT abort. The matching doctor-side FAIL with non-zero exit
93
+ // lives in `cmdDoctor`. The WARN runs in dry-run mode too: the user sees
94
+ // the same drift signal they would see on a real pull.
95
+ if (!hasOverrides && existsSync(settingsPath)) {
96
+ // Best-effort drift report. Malformed prior settings.json must not block
97
+ // regeneration: the whole point here is to overwrite it from base+overrides.
98
+ try {
99
+ const existing = readJson<Record<string, unknown>>(settingsPath);
100
+ const baseKeys = new Set(Object.keys(base));
101
+ const drift = Object.keys(existing).filter((k) => !baseKeys.has(k));
102
+ if (drift.length > 0) {
103
+ warn(
104
+ `no hosts/${HOST}.json found; existing settings has unbased keys ${JSON.stringify(drift)}. ` +
105
+ `Set NOMAD_HOST to match a hosts/*.json or rerun 'nomad doctor' for candidates.`,
106
+ );
107
+ }
108
+ } catch {
109
+ warn('existing settings.json is malformed; skipping drift-check and regenerating.');
110
+ }
111
+ }
112
+
113
+ const overrideLabel = hasOverrides ? `${HOST}.json` : 'no host overrides';
114
+
115
+ if (dryRun) {
116
+ log(`would write settings.json (base + ${overrideLabel})`);
117
+ return;
118
+ }
119
+
120
+ backupBeforeWrite(settingsPath, ts);
121
+ writeJsonAtomic(settingsPath, merged);
122
+ log(`wrote settings.json (base + ${overrideLabel})`);
123
+ }
package/src/nomad.ts ADDED
@@ -0,0 +1,227 @@
1
+ #!/usr/bin/env -S npx tsx
2
+ /**
3
+ * claude-nomad: Claude Code config sync wrapper over a private Git repo.
4
+ *
5
+ * Adds two features the existing community tools lack:
6
+ * 1. Path remapping so session history follows you across machines even
7
+ * when the same repo lives at /Users/norm/code/foo vs /home/norm/foo.
8
+ * 2. Per-host overrides for settings.json via deep merge.
9
+ *
10
+ * Layout (~/claude-nomad/):
11
+ * shared/ symlinked into ~/.claude/ on every host
12
+ * shared/settings.base.json merged with hosts/<hostname>.json -> settings.json
13
+ * shared/projects/ session transcripts keyed by logical name
14
+ * hosts/<hostname>.json per-host settings.json overrides
15
+ * path-map.json logical project name -> { host: localPath }
16
+ */
17
+
18
+ import { cmdDoctor } from './commands.doctor.ts';
19
+ import { cmdDropSession } from './commands.drop-session.ts';
20
+ import { cmdPull } from './commands.pull.ts';
21
+ import { cmdPush } from './commands.push.ts';
22
+ import { cmdUpdate } from './commands.update.ts';
23
+ import { HOME } from './config.ts';
24
+ import { cmdDiff } from './diff.ts';
25
+ import { cmdInit } from './init.ts';
26
+ import { resumeCmd } from './resume.ts';
27
+ import { fail, NomadFatal } from './utils.ts';
28
+
29
+ /**
30
+ * Static JSON import for the `--version` arm. Uses `with { type: 'json' }`
31
+ * per Node 22+ import-attribute syntax (the older `assert { type: 'json' }`
32
+ * was removed). Reading synchronously at module load avoids a runtime fs
33
+ * walk on every `nomad --version` invocation and keeps the smoke-test
34
+ * contract (in npm-publish.yml) deterministic.
35
+ */
36
+ import pkg from '../package.json' with { type: 'json' };
37
+
38
+ /**
39
+ * Multi-line help block printed on the `default:` arm of the dispatcher
40
+ * (bare `nomad` and any unknown subcommand). Per-subcommand `usage:` lines
41
+ * stay terse and live inside their own `case` arms; this block exists so a
42
+ * cold invocation of `nomad` is self-describing without forcing the user
43
+ * into the README. Channel is stderr, exit code is 1.
44
+ */
45
+ const DEFAULT_HELP = [
46
+ 'usage: nomad <command> [flags]',
47
+ '',
48
+ 'Commands:',
49
+ ' pull Sync ~/.claude/ from the shared repo (settings, symlinks, sessions).',
50
+ ' --dry-run Run lock + git pull, then preview every mutation without writing.',
51
+ '',
52
+ ' push Rebase, run safety checks (gitleaks, gitlinks, allow-list), commit, push.',
53
+ ' --dry-run Run pre-checks (rebase, gitleaks probe, gitlink scan) and preview',
54
+ ' remap, without staging or pushing.',
55
+ '',
56
+ ' diff Offline preview of what `pull` would change against local repo state.',
57
+ ' No git pull, no lock acquired.',
58
+ '',
59
+ ' init Scaffold an empty ~/claude-nomad/ repo (shared/, hosts/, path-map).',
60
+ ' --snapshot Overlay the current ~/.claude/ into shared/ as the initial seed.',
61
+ '',
62
+ ' doctor Read-only health check (symlinks, host file, path-map,',
63
+ ' gitleaks, gitlinks).',
64
+ ' --resume-cmd <id> Print `cd <abspath> && claude --resume <id>` for a session id',
65
+ ' from ~/.claude/projects/.',
66
+ '',
67
+ ' drop-session <id> Unstage shared/projects/<logical>/<id>.jsonl from the staged tree (local ~/.claude/projects is never touched).',
68
+ '',
69
+ ' update Topology-aware upgrade of ~/claude-nomad/ to the latest upstream.',
70
+ ' --dry-run Detect topology + pre-flight, print would-be git commands only.',
71
+ ' --force Proceed even when the working tree is not clean.',
72
+ ' --push-origin Fork topology only: push the merge to origin/main without prompting.',
73
+ '',
74
+ 'Run `nomad doctor` to validate your setup. Edit shared/ or hosts/<HOST>.json',
75
+ 'in the repo, never ~/.claude/settings.json directly (it is regenerated on',
76
+ 'every pull).',
77
+ ].join('\n');
78
+
79
+ if (!HOME) {
80
+ fail(
81
+ 'could not determine home directory (HOME env unset and no uid mapping). Set HOME and retry.',
82
+ );
83
+ process.exit(1);
84
+ }
85
+
86
+ try {
87
+ const cmd = process.argv[2];
88
+ switch (cmd) {
89
+ case '--version':
90
+ // Early-arm placement so a broken --version invocation never falls
91
+ // through to full command dispatch. Bare semver output (no prefix)
92
+ // matches the contract asserted by the smoke-test step in
93
+ // .github/workflows/npm-publish.yml (`nomad --version` strict-equal
94
+ // to the published tag minus the leading `v`).
95
+ if (process.argv.length !== 3) {
96
+ console.error('usage: nomad --version (no extra arguments)');
97
+ process.exit(1);
98
+ }
99
+ console.log(pkg.version);
100
+ break;
101
+ case 'pull': {
102
+ // Sub-flag: `pull --dry-run` runs the full pull flow (lock + git pull)
103
+ // in preview mode without mutating ~/.claude/. Any other argv after
104
+ // `pull` is rejected so a typo does not silently degrade to a real pull.
105
+ const sub = process.argv[3];
106
+ if (sub === undefined) {
107
+ cmdPull();
108
+ } else if (sub === '--dry-run' && process.argv.length === 4) {
109
+ cmdPull({ dryRun: true });
110
+ } else {
111
+ console.error('usage: nomad pull [--dry-run]');
112
+ process.exit(1);
113
+ }
114
+ break;
115
+ }
116
+ case 'push': {
117
+ // Sub-flag: `push --dry-run` runs the pre-checks and remap preview
118
+ // without staging, scanning, committing, or pushing. Any other argv
119
+ // after `push` is rejected so a typo does not silently degrade.
120
+ const sub = process.argv[3];
121
+ if (sub === undefined) {
122
+ cmdPush();
123
+ } else if (sub === '--dry-run' && process.argv.length === 4) {
124
+ cmdPush({ dryRun: true });
125
+ } else {
126
+ console.error('usage: nomad push [--dry-run]');
127
+ process.exit(1);
128
+ }
129
+ break;
130
+ }
131
+ case 'init':
132
+ // Two valid forms: `nomad init` (empty scaffold) and
133
+ // `nomad init --snapshot` (overlay user's current ~/.claude/ into
134
+ // shared/). Anything else (unknown flag, extra positional arg, two
135
+ // flags) hits the same usage-error pattern as `doctor --resume-cmd`.
136
+ if (process.argv[3] === undefined) {
137
+ cmdInit();
138
+ } else if (process.argv[3] === '--snapshot' && process.argv[4] === undefined) {
139
+ cmdInit({ snapshot: true });
140
+ } else {
141
+ console.error('usage: nomad init [--snapshot]');
142
+ process.exit(1);
143
+ }
144
+ break;
145
+ case 'diff':
146
+ // Offline, lockless preview against local repo state. No git pull, no
147
+ // lock acquisition. Reject any argv after `diff` since this slice
148
+ // accepts no flags.
149
+ if (process.argv.length > 3) {
150
+ console.error('usage: nomad diff');
151
+ process.exit(1);
152
+ }
153
+ cmdDiff();
154
+ break;
155
+ case 'update': {
156
+ // Set-based parse so flag order does not matter and duplicates are
157
+ // rejected (`--dry-run --dry-run` is a typo, not a no-op). Unknown
158
+ // flags hit the same usage-error pattern as other subcommands.
159
+ const known = new Set(['--dry-run', '--force', '--push-origin']);
160
+ const seen = new Set<string>();
161
+ let argvOk = true;
162
+ for (let i = 3; i < process.argv.length; i++) {
163
+ const flag = process.argv[i];
164
+ if (!known.has(flag) || seen.has(flag)) {
165
+ argvOk = false;
166
+ break;
167
+ }
168
+ seen.add(flag);
169
+ }
170
+ if (!argvOk) {
171
+ console.error('usage: nomad update [--dry-run] [--force] [--push-origin]');
172
+ process.exit(1);
173
+ }
174
+ cmdUpdate({
175
+ dryRun: seen.has('--dry-run'),
176
+ force: seen.has('--force'),
177
+ pushOrigin: seen.has('--push-origin'),
178
+ });
179
+ break;
180
+ }
181
+ case 'doctor':
182
+ // Sub-flag: `doctor --resume-cmd <session-id>` dispatches to the
183
+ // read-only sidecar that prints `cd <abspath> && claude --resume <id>`.
184
+ if (process.argv[3] === '--resume-cmd') {
185
+ const id = process.argv[4];
186
+ if (typeof id !== 'string' || id.length === 0) {
187
+ console.error('usage: nomad doctor --resume-cmd <session-id>');
188
+ process.exit(1);
189
+ }
190
+ resumeCmd(id);
191
+ } else {
192
+ cmdDoctor();
193
+ }
194
+ break;
195
+ case 'drop-session': {
196
+ // Single positional argv; cmdDropSession revalidates id at entry as
197
+ // defense-in-depth (the function may be called from non-argv paths
198
+ // in tests). The argv regex mirrors the function-entry allowlist
199
+ // (`[A-Za-z0-9_-]`) but additionally rejects ids starting with `-`
200
+ // so a typo like `nomad drop-session --bogus` shows the usage line,
201
+ // not a FATAL. The length bound matches cmdDropSession.
202
+ const id = process.argv[3];
203
+ if (
204
+ process.argv.length !== 4 ||
205
+ typeof id !== 'string' ||
206
+ !/^[A-Za-z0-9_][A-Za-z0-9_-]{0,127}$/.test(id)
207
+ ) {
208
+ console.error('usage: nomad drop-session <id>');
209
+ process.exit(1);
210
+ }
211
+ cmdDropSession(id);
212
+ break;
213
+ }
214
+ default:
215
+ console.error(DEFAULT_HELP);
216
+ process.exit(1);
217
+ }
218
+ } catch (err) {
219
+ // Top-level safety net for NomadFatal thrown from contexts that don't have
220
+ // their own try/catch (e.g., cmdDoctor's readJson path). cmdPull / cmdPush
221
+ // have their own catches so their finally blocks release the lock first.
222
+ if (err instanceof NomadFatal) {
223
+ fail(err.message);
224
+ process.exit(1);
225
+ }
226
+ throw err;
227
+ }
package/src/preview.ts ADDED
@@ -0,0 +1,141 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ import { green, red } from './color.ts';
5
+ import { CLAUDE_HOME, HOST, REPO_HOME } from './config.ts';
6
+ import { applySharedLinks } from './links.ts';
7
+ import { remapPull } from './remap.ts';
8
+ import { deepMerge, log, readJson } from './utils.ts';
9
+
10
+ /**
11
+ * Minimal in-tree unified-diff helper for two pre-stringified JSON
12
+ * documents. Walks the line arrays in parallel and emits a unified-diff
13
+ * style output: unchanged lines prefixed with a space, removed lines with
14
+ * `-` (red), added lines with `+` (green), plus at most three lines of
15
+ * surrounding context per changed block. The implementation is intentionally
16
+ * naive (no LCS); for two ~50-line settings JSON inputs the result is
17
+ * acceptable even if not optimal.
18
+ *
19
+ * Returns the empty string when the inputs are byte-identical so the caller
20
+ * can suppress the section. Picocolors handles `NO_COLOR` / `FORCE_COLOR`
21
+ * detection, so the `red`/`green` wrappers degrade to identity in non-TTY
22
+ * environments and the output stays literal `-` / `+` prefixed.
23
+ *
24
+ * The header line `--- ~/.claude/settings.json` / `+++ would write` is
25
+ * literal; callers that want a different header can prepend their own.
26
+ */
27
+ export function diffJsonStrings(currentJsonText: string, newJsonText: string): string {
28
+ if (currentJsonText === newJsonText) return '';
29
+ const a = currentJsonText.split('\n');
30
+ const b = newJsonText.split('\n');
31
+ const lines: string[] = [];
32
+ lines.push('--- ~/.claude/settings.json');
33
+ lines.push('+++ would write');
34
+
35
+ // Walk both arrays in parallel. Lines that match index-wise are context;
36
+ // others are emitted as -a / +b. A real unified-diff would compute the
37
+ // longest common subsequence; this naive walk is good enough for two JSON
38
+ // documents pretty-printed at the same indentation level.
39
+ const maxLen = Math.max(a.length, b.length);
40
+ for (let i = 0; i < maxLen; i++) {
41
+ const av = a[i];
42
+ const bv = b[i];
43
+ if (av === bv) {
44
+ if (av !== undefined) lines.push(` ${av}`);
45
+ continue;
46
+ }
47
+ if (av !== undefined) lines.push(red(`-${av}`));
48
+ if (bv !== undefined) lines.push(green(`+${bv}`));
49
+ }
50
+ return lines.join('\n');
51
+ }
52
+
53
+ /**
54
+ * Read JSON from `path` returning the parsed object, or `null` on any
55
+ * filesystem or parse failure. Used by computePreview's tolerant settings
56
+ * read so a malformed settings.json on a fresh-clone host does not abort
57
+ * the preview surface.
58
+ */
59
+ function readJsonOrNull(path: string): Record<string, unknown> | null {
60
+ if (!existsSync(path)) return null;
61
+ try {
62
+ return readJson<Record<string, unknown>>(path);
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Orchestrate the dry-run preview across all three sync modalities:
70
+ * symlinks (via applySharedLinks dry-run), settings.json (via deepMerge +
71
+ * diffJsonStrings; we do NOT call regenerateSettings dry-run because that
72
+ * emits a "would write" intent line that duplicates the unified diff
73
+ * produced here), and projects (via remapPull dry-run).
74
+ *
75
+ * Returns `{ unmapped, collisions }` aggregated from remapPull. Collisions
76
+ * is always 0 in this slice; a future slice wires path-encoding collision
77
+ * detection through.
78
+ *
79
+ * Tolerant by design: missing `shared/settings.base.json` and malformed
80
+ * `~/.claude/settings.json` both emit a single log line and continue rather
81
+ * than throw. This supports `cmdDiff`'s offline-safe contract, where the
82
+ * preview may run against a partially-scaffolded repo (e.g. right after a
83
+ * fresh clone before `nomad init`).
84
+ *
85
+ * Settings diff output goes through `log()` so each line gets the ℹ︎-prefixed
86
+ * prefix, keeping output channels consistent across the three sections.
87
+ */
88
+ export function computePreview(ts: string): { unmapped: number; collisions: number } {
89
+ log(`would pull on host=${HOST} (dry-run; no mutation)`);
90
+
91
+ // Symlinks: applySharedLinks emits its own would-create / would-auto-move
92
+ // lines. dryRun:true is mandatory; a real call here would mutate disk.
93
+ applySharedLinks(ts, { dryRun: true });
94
+
95
+ // Settings section: skip-with-log when base or current is missing. Per the
96
+ // locked phrasing decision, the message text is fixed so cmdDiff users see
97
+ // the same line regardless of which side is missing. Calling
98
+ // regenerateSettings(ts, { dryRun: true }) would only emit a generic
99
+ // "would write" intent line; we want the unified diff here, so we compute
100
+ // it directly from base + host-override + current.
101
+ const basePath = join(REPO_HOME, 'shared', 'settings.base.json');
102
+ const hostPath = join(REPO_HOME, 'hosts', `${HOST}.json`);
103
+ const settingsPath = join(CLAUDE_HOME, 'settings.json');
104
+ const base = readJsonOrNull(basePath);
105
+ if (base === null) {
106
+ // Base is the load-bearing input here. Per the locked phrasing decision,
107
+ // emit one canonical message and skip the diff. The current-side missing
108
+ // case (no ~/.claude/settings.json) is handled below by treating current
109
+ // as `{}` and producing a normal diff; only base-missing is fatal-ish.
110
+ log('settings.json: section skipped (base or current missing)');
111
+ } else {
112
+ // Tolerate a malformed hosts/<HOST>.json the same way base and current
113
+ // are tolerated: log once and fall back to no overrides so the preview
114
+ // keeps rendering instead of crashing the dry-run.
115
+ const hostOverrides = readJsonOrNull(hostPath);
116
+ if (hostOverrides === null && existsSync(hostPath)) {
117
+ log(`settings.json: malformed hosts/${HOST}.json; ignoring overrides`);
118
+ }
119
+ const overrides = hostOverrides ?? {};
120
+ const merged = deepMerge(base, overrides);
121
+ const current = readJsonOrNull(settingsPath);
122
+ if (current === null && existsSync(settingsPath)) {
123
+ log('settings.json: malformed; skipping diff');
124
+ } else {
125
+ const currentText = JSON.stringify(current ?? {}, null, 2);
126
+ const mergedText = JSON.stringify(merged, null, 2);
127
+ const diff = diffJsonStrings(currentText, mergedText);
128
+ if (diff === '') {
129
+ log('settings.json: no changes');
130
+ } else {
131
+ log('settings.json:');
132
+ for (const line of diff.split('\n')) log(line);
133
+ }
134
+ }
135
+ }
136
+
137
+ // Projects: remapPull emits its own would-overwrite lines and returns the
138
+ // skipped count.
139
+ const remapResult = remapPull(ts, { dryRun: true });
140
+ return { unmapped: remapResult.unmapped, collisions: 0 };
141
+ }