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/.gitleaks.toml +16 -0
- package/CHANGELOG.md +293 -0
- package/LICENSE +21 -0
- package/README.md +464 -0
- package/package.json +79 -0
- package/shared/.gitignore +8 -0
- package/src/color.ts +81 -0
- package/src/commands.doctor.checks.ts +343 -0
- package/src/commands.doctor.format.ts +68 -0
- package/src/commands.doctor.ts +56 -0
- package/src/commands.doctor.version.ts +190 -0
- package/src/commands.drop-session.ts +173 -0
- package/src/commands.pull.ts +88 -0
- package/src/commands.push.ts +215 -0
- package/src/commands.update.ts +279 -0
- package/src/config.ts +149 -0
- package/src/diff.ts +49 -0
- package/src/init.snapshot.ts +53 -0
- package/src/init.ts +190 -0
- package/src/links.ts +123 -0
- package/src/nomad.ts +227 -0
- package/src/preview.ts +141 -0
- package/src/push-checks.ts +170 -0
- package/src/push-gitleaks.ts +217 -0
- package/src/remap.ts +158 -0
- package/src/resume.ts +159 -0
- package/src/summary.ts +43 -0
- package/src/update.topology.ts +118 -0
- package/src/utils.ts +368 -0
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
|
+
}
|