@vishalpunjabi/claude-profile 0.1.5
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/LICENSE +21 -0
- package/README.md +151 -0
- package/dist/bin/claude-profile.js +2190 -0
- package/package.json +67 -0
|
@@ -0,0 +1,2190 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli/index.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import { readFileSync } from "fs";
|
|
6
|
+
import { fileURLToPath } from "url";
|
|
7
|
+
|
|
8
|
+
// src/cli/adopt.ts
|
|
9
|
+
import * as fs7 from "fs/promises";
|
|
10
|
+
import * as path5 from "path";
|
|
11
|
+
import * as os from "os";
|
|
12
|
+
|
|
13
|
+
// src/io/machine-config.ts
|
|
14
|
+
import * as fs2 from "fs/promises";
|
|
15
|
+
|
|
16
|
+
// src/io/atomic.ts
|
|
17
|
+
import * as fs from "fs/promises";
|
|
18
|
+
import * as path from "path";
|
|
19
|
+
import * as crypto from "crypto";
|
|
20
|
+
async function atomicWrite(dest, content) {
|
|
21
|
+
await fs.mkdir(path.dirname(dest), { recursive: true });
|
|
22
|
+
const tmp = `${dest}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`;
|
|
23
|
+
await fs.writeFile(tmp, content);
|
|
24
|
+
await fs.rename(tmp, dest);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// src/io/machine-config.ts
|
|
28
|
+
async function loadMachineConfig(p) {
|
|
29
|
+
try {
|
|
30
|
+
const txt = await fs2.readFile(p, "utf8");
|
|
31
|
+
const obj = JSON.parse(txt);
|
|
32
|
+
return obj;
|
|
33
|
+
} catch (err) {
|
|
34
|
+
if (err.code === "ENOENT") return null;
|
|
35
|
+
throw err;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
async function saveMachineConfig(p, cfg) {
|
|
39
|
+
await atomicWrite(p, JSON.stringify(cfg, null, 2) + "\n");
|
|
40
|
+
}
|
|
41
|
+
function addProfile(cfg, name) {
|
|
42
|
+
if (cfg.profiles.includes(name)) return cfg;
|
|
43
|
+
return { ...cfg, profiles: [...cfg.profiles, name] };
|
|
44
|
+
}
|
|
45
|
+
function setActive(cfg, name) {
|
|
46
|
+
if (!cfg.profiles.includes(name)) {
|
|
47
|
+
throw new Error(`profile "${name}" is not registered on this machine`);
|
|
48
|
+
}
|
|
49
|
+
return { ...cfg, active: name };
|
|
50
|
+
}
|
|
51
|
+
function defaultMachineConfigPath(envHome) {
|
|
52
|
+
if (envHome !== void 0) return `${envHome}/config.json`;
|
|
53
|
+
const home = process.env.HOME;
|
|
54
|
+
if (!home) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
"Cannot resolve machine config path: HOME is not set and no explicit path was provided"
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
return `${home}/.claude-profile/config.json`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// src/core/utils.ts
|
|
63
|
+
function isPlainObject(v) {
|
|
64
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// src/core/config.ts
|
|
68
|
+
var DEFAULTS = {
|
|
69
|
+
// Catch-all: everything under ~/.claude/ is in scope unless excluded below.
|
|
70
|
+
// BUILTIN_DENYLIST is unioned in at resolve time and blocks credentials/secrets
|
|
71
|
+
// regardless of include/exclude.
|
|
72
|
+
include: ["**"],
|
|
73
|
+
exclude: [
|
|
74
|
+
// Per-machine cache + transient runtime state
|
|
75
|
+
"cache/**",
|
|
76
|
+
"daemon*",
|
|
77
|
+
"daemon/**",
|
|
78
|
+
"tasks/**",
|
|
79
|
+
"telemetry/**",
|
|
80
|
+
"jobs/**",
|
|
81
|
+
"backups/**",
|
|
82
|
+
"paste-cache/**",
|
|
83
|
+
"downloads/**",
|
|
84
|
+
"file-history/**",
|
|
85
|
+
"shell-snapshots/**",
|
|
86
|
+
"session-env/**",
|
|
87
|
+
".last-cleanup",
|
|
88
|
+
"stats-cache.json",
|
|
89
|
+
"mcp-needs-auth-cache.json",
|
|
90
|
+
"security/**",
|
|
91
|
+
"security_warnings_state_*.json",
|
|
92
|
+
"*.bak.*",
|
|
93
|
+
// Project + session history (per-machine, conflict-prone)
|
|
94
|
+
"projects/**",
|
|
95
|
+
"sessions/**",
|
|
96
|
+
"history.jsonl",
|
|
97
|
+
// Plugin caches (plugin code itself now syncs)
|
|
98
|
+
"plugins/cache/**",
|
|
99
|
+
"plugins/plugin-catalog-cache.json",
|
|
100
|
+
"plugins/data/**"
|
|
101
|
+
],
|
|
102
|
+
denylist: [],
|
|
103
|
+
claude_json: {
|
|
104
|
+
path: "~/.claude.json",
|
|
105
|
+
partial_filename: "claude.json.partial",
|
|
106
|
+
include_keys: [
|
|
107
|
+
"mcpServers",
|
|
108
|
+
"autoUpdates",
|
|
109
|
+
"autoUpdatesProtectedForNative",
|
|
110
|
+
"showExpandedTodos",
|
|
111
|
+
"showSpinnerTree"
|
|
112
|
+
]
|
|
113
|
+
},
|
|
114
|
+
autosync: {
|
|
115
|
+
enabled: true,
|
|
116
|
+
pull_on_session_start: true,
|
|
117
|
+
push_on_session_stop: true,
|
|
118
|
+
pull_timeout_seconds: 3,
|
|
119
|
+
push_debounce_seconds: 10,
|
|
120
|
+
pull_command: "claude-profile sync --pull --quiet",
|
|
121
|
+
push_command: "claude-profile sync --push --quiet --background",
|
|
122
|
+
hook_id_pull: "claude-profile-pull",
|
|
123
|
+
hook_id_push: "claude-profile-push"
|
|
124
|
+
},
|
|
125
|
+
git: {
|
|
126
|
+
pull_strategy: "ff-only",
|
|
127
|
+
push: true,
|
|
128
|
+
commit_message_template: "sync from {host} at {iso_ts}",
|
|
129
|
+
commit_author_from_git: true
|
|
130
|
+
},
|
|
131
|
+
state: { dir: ".claude-profile" },
|
|
132
|
+
backups: {
|
|
133
|
+
dir: "~/.claude-profile/backups",
|
|
134
|
+
keep_per_profile: 10,
|
|
135
|
+
use_hardlinks: true,
|
|
136
|
+
auto_prune_on_use: true
|
|
137
|
+
},
|
|
138
|
+
limits: { marketplaces_warn_mb: 50 },
|
|
139
|
+
log: { level: "info", color: "auto" }
|
|
140
|
+
};
|
|
141
|
+
var BUILTIN_DENYLIST = [
|
|
142
|
+
".credentials.json",
|
|
143
|
+
"*.credentials.json",
|
|
144
|
+
"*.key",
|
|
145
|
+
"*.pem",
|
|
146
|
+
"*.p12",
|
|
147
|
+
".env",
|
|
148
|
+
".env.*",
|
|
149
|
+
"*secret*",
|
|
150
|
+
"*token*"
|
|
151
|
+
];
|
|
152
|
+
function deepMerge(base, overlay) {
|
|
153
|
+
if (overlay === void 0 || overlay === null) return base;
|
|
154
|
+
if (!isPlainObject(base) || !isPlainObject(overlay)) return overlay;
|
|
155
|
+
const out = { ...base };
|
|
156
|
+
for (const k of Object.keys(overlay)) {
|
|
157
|
+
const ov = overlay[k];
|
|
158
|
+
const bv = base[k];
|
|
159
|
+
if (Array.isArray(ov)) {
|
|
160
|
+
out[k] = ov;
|
|
161
|
+
} else if (isPlainObject(ov) && isPlainObject(bv)) {
|
|
162
|
+
out[k] = deepMerge(bv, ov);
|
|
163
|
+
} else {
|
|
164
|
+
out[k] = ov;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return out;
|
|
168
|
+
}
|
|
169
|
+
function resolveConfig(input) {
|
|
170
|
+
let cfg = JSON.parse(JSON.stringify(DEFAULTS));
|
|
171
|
+
cfg = deepMerge(cfg, input.repo);
|
|
172
|
+
cfg = deepMerge(cfg, input.profile);
|
|
173
|
+
cfg = deepMerge(cfg, input.machine);
|
|
174
|
+
cfg = deepMerge(cfg, input.flags);
|
|
175
|
+
if (input.env?.CLAUDE_PROFILE_QUIET === "1") {
|
|
176
|
+
cfg = deepMerge(cfg, { log: { level: "warn" } });
|
|
177
|
+
}
|
|
178
|
+
const userDeny = new Set(cfg.denylist);
|
|
179
|
+
for (const d of BUILTIN_DENYLIST) userDeny.add(d);
|
|
180
|
+
cfg.denylist = [...userDeny];
|
|
181
|
+
return cfg;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// src/core/yaml-merge.ts
|
|
185
|
+
import yaml from "yaml";
|
|
186
|
+
function parseYamlConfig(text) {
|
|
187
|
+
const parsed = yaml.parse(text);
|
|
188
|
+
if (parsed === null || parsed === void 0) return {};
|
|
189
|
+
if (typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
190
|
+
throw new Error("claude-profile.yaml root must be an object");
|
|
191
|
+
}
|
|
192
|
+
return parsed;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// src/io/walk.ts
|
|
196
|
+
import * as fs3 from "fs/promises";
|
|
197
|
+
import * as path2 from "path";
|
|
198
|
+
async function walk(root) {
|
|
199
|
+
const out = [];
|
|
200
|
+
let rootReal;
|
|
201
|
+
try {
|
|
202
|
+
rootReal = await fs3.realpath(root);
|
|
203
|
+
} catch {
|
|
204
|
+
return [];
|
|
205
|
+
}
|
|
206
|
+
await walkInto(rootReal, "", out, rootReal);
|
|
207
|
+
return out;
|
|
208
|
+
}
|
|
209
|
+
async function walkInto(absDir, rel, out, rootReal) {
|
|
210
|
+
let entries;
|
|
211
|
+
try {
|
|
212
|
+
entries = await fs3.readdir(absDir, { withFileTypes: true });
|
|
213
|
+
} catch {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
for (const ent of entries) {
|
|
217
|
+
const childAbs = path2.join(absDir, ent.name);
|
|
218
|
+
const childRel = rel === "" ? ent.name : `${rel}/${ent.name}`;
|
|
219
|
+
if (ent.isSymbolicLink()) {
|
|
220
|
+
try {
|
|
221
|
+
const real = await fs3.realpath(childAbs);
|
|
222
|
+
if (!real.startsWith(rootReal + path2.sep) && real !== rootReal) continue;
|
|
223
|
+
const stat9 = await fs3.stat(real);
|
|
224
|
+
if (stat9.isDirectory()) {
|
|
225
|
+
await walkInto(real, childRel, out, rootReal);
|
|
226
|
+
} else {
|
|
227
|
+
out.push(childRel);
|
|
228
|
+
}
|
|
229
|
+
} catch {
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
} else if (ent.isDirectory()) {
|
|
233
|
+
await walkInto(childAbs, childRel, out, rootReal);
|
|
234
|
+
} else if (ent.isFile()) {
|
|
235
|
+
out.push(childRel);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
async function statMtime(absPath) {
|
|
240
|
+
try {
|
|
241
|
+
const st = await fs3.stat(absPath);
|
|
242
|
+
return Math.floor(st.mtimeMs);
|
|
243
|
+
} catch {
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// src/io/copy.ts
|
|
249
|
+
import * as fs5 from "fs/promises";
|
|
250
|
+
import * as path3 from "path";
|
|
251
|
+
|
|
252
|
+
// src/util/hash.ts
|
|
253
|
+
import * as crypto2 from "crypto";
|
|
254
|
+
import * as fs4 from "fs";
|
|
255
|
+
async function sha256(filePath) {
|
|
256
|
+
return await new Promise((resolve, reject) => {
|
|
257
|
+
const h = crypto2.createHash("sha256");
|
|
258
|
+
const s = fs4.createReadStream(filePath);
|
|
259
|
+
s.on("data", (chunk) => h.update(chunk));
|
|
260
|
+
s.on("end", () => resolve(h.digest("hex")));
|
|
261
|
+
s.on("error", reject);
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// src/io/copy.ts
|
|
266
|
+
async function copyFile2(src, dst, opts = {}) {
|
|
267
|
+
await fs5.mkdir(path3.dirname(dst), { recursive: true });
|
|
268
|
+
await fs5.rm(dst, { force: true });
|
|
269
|
+
if (opts.hardlink) {
|
|
270
|
+
try {
|
|
271
|
+
await fs5.link(src, dst);
|
|
272
|
+
return;
|
|
273
|
+
} catch (err) {
|
|
274
|
+
const code = err.code;
|
|
275
|
+
if (code !== "EXDEV" && code !== "EPERM") throw err;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
await fs5.copyFile(src, dst);
|
|
279
|
+
}
|
|
280
|
+
var sha256File = sha256;
|
|
281
|
+
|
|
282
|
+
// src/core/glob.ts
|
|
283
|
+
import picomatch from "picomatch";
|
|
284
|
+
function normalize(p) {
|
|
285
|
+
if (p.startsWith("/")) return null;
|
|
286
|
+
if (p.split("/").includes("..")) return null;
|
|
287
|
+
return p.replace(/^\.\//, "");
|
|
288
|
+
}
|
|
289
|
+
function anyMatch(patterns, path18, opts = {}) {
|
|
290
|
+
for (const pat of patterns) {
|
|
291
|
+
if (picomatch.isMatch(path18, pat, { dot: true, ...opts })) return true;
|
|
292
|
+
}
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
function isPathInScope(rawPath, rules) {
|
|
296
|
+
const p = normalize(rawPath);
|
|
297
|
+
if (p === null) return false;
|
|
298
|
+
if (anyMatch(rules.denylist, p, { matchBase: true })) return false;
|
|
299
|
+
if (anyMatch(rules.exclude, p)) return false;
|
|
300
|
+
return anyMatch(rules.include, p);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// src/io/backup.ts
|
|
304
|
+
import * as fs6 from "fs/promises";
|
|
305
|
+
import * as path4 from "path";
|
|
306
|
+
async function snapshotBackup(input) {
|
|
307
|
+
const dir = path4.join(input.backupRoot, input.profile, input.isoTs);
|
|
308
|
+
await fs6.mkdir(dir, { recursive: true });
|
|
309
|
+
const files = (await walk(input.home)).filter((p) => isPathInScope(p, input.rules));
|
|
310
|
+
const manifestFiles = [];
|
|
311
|
+
for (const rel of files) {
|
|
312
|
+
const src = path4.join(input.home, rel);
|
|
313
|
+
const dst = path4.join(dir, rel);
|
|
314
|
+
await copyFile2(src, dst, { hardlink: input.useHardlinks });
|
|
315
|
+
manifestFiles.push({ path: rel, sha256: await sha256File(src) });
|
|
316
|
+
}
|
|
317
|
+
const manifestPath = path4.join(dir, "MANIFEST.json");
|
|
318
|
+
const manifest = {
|
|
319
|
+
version: 1,
|
|
320
|
+
outgoing: input.profile,
|
|
321
|
+
iso_ts: input.isoTs,
|
|
322
|
+
files: manifestFiles
|
|
323
|
+
};
|
|
324
|
+
await atomicWrite(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
|
|
325
|
+
return { dir, manifestPath, fileCount: files.length };
|
|
326
|
+
}
|
|
327
|
+
async function listBackups(backupRoot, profile) {
|
|
328
|
+
const dir = path4.join(backupRoot, profile);
|
|
329
|
+
let entries;
|
|
330
|
+
try {
|
|
331
|
+
entries = await fs6.readdir(dir);
|
|
332
|
+
} catch {
|
|
333
|
+
return [];
|
|
334
|
+
}
|
|
335
|
+
const sorted = entries.sort().reverse();
|
|
336
|
+
return sorted.map((ts) => ({ profile, ts, dir: path4.join(dir, ts) }));
|
|
337
|
+
}
|
|
338
|
+
async function pruneBackups(backupRoot, profile, keep) {
|
|
339
|
+
if (keep === 0) return [];
|
|
340
|
+
const all = await listBackups(backupRoot, profile);
|
|
341
|
+
const toDelete = all.slice(keep);
|
|
342
|
+
for (const b of toDelete) {
|
|
343
|
+
await fs6.rm(b.dir, { recursive: true, force: true });
|
|
344
|
+
}
|
|
345
|
+
return toDelete.map((b) => b.ts);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// src/io/git.ts
|
|
349
|
+
import { execa } from "execa";
|
|
350
|
+
async function run(cwd, args, opts = {}) {
|
|
351
|
+
try {
|
|
352
|
+
return await execa("git", args, { cwd, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } });
|
|
353
|
+
} catch (err) {
|
|
354
|
+
if (opts.allowFail) return null;
|
|
355
|
+
throw err;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
async function status(cwd) {
|
|
359
|
+
const res = await run(cwd, ["status", "--porcelain=v1", "-z"]);
|
|
360
|
+
if (!res?.stdout || typeof res.stdout !== "string") return [];
|
|
361
|
+
return res.stdout.split("\0").filter(Boolean).map((line) => ({ code: line.slice(0, 2).trim(), path: line.slice(3) }));
|
|
362
|
+
}
|
|
363
|
+
async function isClean(cwd) {
|
|
364
|
+
return (await status(cwd)).length === 0;
|
|
365
|
+
}
|
|
366
|
+
async function add(cwd, paths) {
|
|
367
|
+
if (paths.length === 0) return;
|
|
368
|
+
await run(cwd, ["add", "--", ...paths]);
|
|
369
|
+
}
|
|
370
|
+
async function hasStaged(cwd) {
|
|
371
|
+
const res = await run(cwd, ["diff", "--cached", "--quiet"], { allowFail: true });
|
|
372
|
+
return res === null;
|
|
373
|
+
}
|
|
374
|
+
async function commit(cwd, message) {
|
|
375
|
+
await run(cwd, ["commit", "-m", message]);
|
|
376
|
+
}
|
|
377
|
+
async function commitIfStaged(cwd, message) {
|
|
378
|
+
if (!await hasStaged(cwd)) return false;
|
|
379
|
+
await commit(cwd, message);
|
|
380
|
+
return true;
|
|
381
|
+
}
|
|
382
|
+
async function fetch(cwd) {
|
|
383
|
+
await run(cwd, ["fetch"]);
|
|
384
|
+
}
|
|
385
|
+
async function pull(cwd, strategy) {
|
|
386
|
+
const flag = strategy === "ff-only" ? "--ff-only" : strategy === "rebase" ? "--rebase" : "--no-rebase";
|
|
387
|
+
await run(cwd, ["pull", flag]);
|
|
388
|
+
}
|
|
389
|
+
async function push(cwd) {
|
|
390
|
+
await run(cwd, ["push"]);
|
|
391
|
+
}
|
|
392
|
+
async function clone(url, dest) {
|
|
393
|
+
await execa("git", ["clone", url, dest], { env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } });
|
|
394
|
+
}
|
|
395
|
+
async function revParse(cwd, ref) {
|
|
396
|
+
const r = await run(cwd, ["rev-parse", ref], { allowFail: true });
|
|
397
|
+
if (!r || typeof r.stdout !== "string") return null;
|
|
398
|
+
return r.stdout.trim();
|
|
399
|
+
}
|
|
400
|
+
async function logBetween(cwd, from, to) {
|
|
401
|
+
const r = await run(cwd, ["log", "--oneline", `${from}..${to}`]);
|
|
402
|
+
if (!r || typeof r.stdout !== "string") return "";
|
|
403
|
+
return r.stdout;
|
|
404
|
+
}
|
|
405
|
+
var git = {
|
|
406
|
+
status,
|
|
407
|
+
isClean,
|
|
408
|
+
add,
|
|
409
|
+
hasStaged,
|
|
410
|
+
commit,
|
|
411
|
+
commitIfStaged,
|
|
412
|
+
fetch,
|
|
413
|
+
pull,
|
|
414
|
+
push,
|
|
415
|
+
clone,
|
|
416
|
+
revParse,
|
|
417
|
+
logBetween
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
// src/core/meta.ts
|
|
421
|
+
function emptyMeta() {
|
|
422
|
+
return { version: 1, files: {}, claude_json: {} };
|
|
423
|
+
}
|
|
424
|
+
function parseMeta(text) {
|
|
425
|
+
const obj = JSON.parse(text);
|
|
426
|
+
if (!isPlainObject(obj) || obj["version"] !== 1) {
|
|
427
|
+
const got = isPlainObject(obj) ? obj["version"] : typeof obj;
|
|
428
|
+
throw new Error(`meta.json version mismatch: expected 1, got ${String(got)}`);
|
|
429
|
+
}
|
|
430
|
+
const files = obj["files"] ?? {};
|
|
431
|
+
const claude_json = obj["claude_json"] ?? {};
|
|
432
|
+
if (!isPlainObject(files)) {
|
|
433
|
+
throw new Error(`meta.json: "files" must be an object, got ${Array.isArray(files) ? "array" : typeof files}`);
|
|
434
|
+
}
|
|
435
|
+
if (!isPlainObject(claude_json)) {
|
|
436
|
+
throw new Error(`meta.json: "claude_json" must be an object, got ${Array.isArray(claude_json) ? "array" : typeof claude_json}`);
|
|
437
|
+
}
|
|
438
|
+
return {
|
|
439
|
+
version: 1,
|
|
440
|
+
files,
|
|
441
|
+
claude_json
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
function serializeMeta(meta) {
|
|
445
|
+
return JSON.stringify(meta, null, 2) + "\n";
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// src/util/time.ts
|
|
449
|
+
var realTime = {
|
|
450
|
+
now: () => Date.now(),
|
|
451
|
+
iso: () => (/* @__PURE__ */ new Date()).toISOString()
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
// src/util/errors.ts
|
|
455
|
+
var CliError = class extends Error {
|
|
456
|
+
code;
|
|
457
|
+
constructor(message, code = 2) {
|
|
458
|
+
super(message);
|
|
459
|
+
this.code = code;
|
|
460
|
+
this.name = "CliError";
|
|
461
|
+
}
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
// src/cli/adopt.ts
|
|
465
|
+
var NAME_RE = /^[a-z0-9_-]+$/;
|
|
466
|
+
var RESERVED = /* @__PURE__ */ new Set(["__preexisting__"]);
|
|
467
|
+
function homeRoot() {
|
|
468
|
+
return process.env.HOME ?? os.homedir();
|
|
469
|
+
}
|
|
470
|
+
function machineHomeRoot() {
|
|
471
|
+
return process.env.CLAUDE_PROFILE_HOME ?? path5.join(homeRoot(), ".claude-profile");
|
|
472
|
+
}
|
|
473
|
+
function attachAdopt(program2) {
|
|
474
|
+
program2.command("adopt").description("Snapshot current ~/.claude/ into profiles/<name>/ (new or existing)").argument("<name>").option("--overwrite").option("--switch").option("--no-push").action(async (name, opts) => {
|
|
475
|
+
if (!NAME_RE.test(name)) throw new CliError(`invalid profile name: ${name}`);
|
|
476
|
+
if (RESERVED.has(name)) throw new CliError(`profile name "${name}" is reserved`);
|
|
477
|
+
const cfgPath = path5.join(machineHomeRoot(), "config.json");
|
|
478
|
+
const cfg = await loadMachineConfig(cfgPath);
|
|
479
|
+
if (!cfg) throw new CliError("Run `claude-profile init --git <url>` first.");
|
|
480
|
+
const checkout = cfg.path;
|
|
481
|
+
const profileDir = path5.join(checkout, "profiles", name);
|
|
482
|
+
const exists = await fs7.stat(profileDir).catch(() => null);
|
|
483
|
+
if (exists && !opts.overwrite) {
|
|
484
|
+
throw new CliError(`Profile "${name}" already exists in the repo. Pass --overwrite to replace it.`);
|
|
485
|
+
}
|
|
486
|
+
const readYaml = async (p) => {
|
|
487
|
+
try {
|
|
488
|
+
return parseYamlConfig(await fs7.readFile(p, "utf8"));
|
|
489
|
+
} catch {
|
|
490
|
+
return {};
|
|
491
|
+
}
|
|
492
|
+
};
|
|
493
|
+
const repoYaml = await readYaml(path5.join(checkout, "claude-profile.yaml"));
|
|
494
|
+
const profileYaml = exists ? await readYaml(path5.join(profileDir, "claude-profile.yaml")) : {};
|
|
495
|
+
const resolved = resolveConfig({
|
|
496
|
+
repo: repoYaml,
|
|
497
|
+
profile: profileYaml
|
|
498
|
+
});
|
|
499
|
+
const rules = { include: resolved.include, exclude: resolved.exclude, denylist: resolved.denylist };
|
|
500
|
+
const claudeHome = path5.join(homeRoot(), ".claude");
|
|
501
|
+
if (name === cfg.active) {
|
|
502
|
+
await snapshotBackup({
|
|
503
|
+
home: claudeHome,
|
|
504
|
+
backupRoot: resolved.backups.dir.replace(/^~/, homeRoot()),
|
|
505
|
+
profile: name,
|
|
506
|
+
rules,
|
|
507
|
+
isoTs: realTime.iso(),
|
|
508
|
+
useHardlinks: resolved.backups.use_hardlinks
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
const homeFiles = (await walk(claudeHome)).filter((p) => isPathInScope(p, rules));
|
|
512
|
+
if (opts.overwrite && exists) {
|
|
513
|
+
const oldFiles = await walk(profileDir);
|
|
514
|
+
for (const rel of oldFiles) {
|
|
515
|
+
if (rel === "claude-profile.yaml") continue;
|
|
516
|
+
if (rel === resolved.claude_json.partial_filename) continue;
|
|
517
|
+
if (!isPathInScope(rel, rules) || !homeFiles.includes(rel)) {
|
|
518
|
+
await fs7.rm(path5.join(profileDir, rel), { force: true });
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
await fs7.mkdir(profileDir, { recursive: true });
|
|
523
|
+
for (const rel of homeFiles) {
|
|
524
|
+
await copyFile2(path5.join(claudeHome, rel), path5.join(profileDir, rel), { hardlink: false });
|
|
525
|
+
}
|
|
526
|
+
const claudeJsonPath = resolved.claude_json.path.replace(/^~/, homeRoot());
|
|
527
|
+
const home = JSON.parse(await fs7.readFile(claudeJsonPath, "utf8").catch(() => "{}"));
|
|
528
|
+
const partial = {};
|
|
529
|
+
for (const k of resolved.claude_json.include_keys) {
|
|
530
|
+
if (k in home) partial[k] = home[k];
|
|
531
|
+
}
|
|
532
|
+
if (Object.keys(partial).length > 0) {
|
|
533
|
+
await atomicWrite(
|
|
534
|
+
path5.join(profileDir, resolved.claude_json.partial_filename),
|
|
535
|
+
JSON.stringify(partial, null, 2) + "\n"
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
const metaPath = path5.join(checkout, ".claude-profile", name, "meta.json");
|
|
539
|
+
const meta = await fs7.readFile(metaPath, "utf8").then(parseMeta).catch(() => emptyMeta());
|
|
540
|
+
const newFiles = {};
|
|
541
|
+
for (const rel of homeFiles) {
|
|
542
|
+
const mt = await statMtime(path5.join(claudeHome, rel));
|
|
543
|
+
if (mt !== null) newFiles[rel] = mt;
|
|
544
|
+
}
|
|
545
|
+
await atomicWrite(metaPath, serializeMeta({ ...meta, files: newFiles }));
|
|
546
|
+
const relP = path5.join("profiles", name);
|
|
547
|
+
const relS = path5.join(".claude-profile", name);
|
|
548
|
+
await git.add(checkout, [relP, relS]);
|
|
549
|
+
const verb = exists ? "re-adopt" : "adopt";
|
|
550
|
+
await git.commitIfStaged(checkout, `${verb} ${name} from ${cfg.host} at ${realTime.iso()}`);
|
|
551
|
+
if (opts.push !== false) {
|
|
552
|
+
await git.push(checkout).catch((err) => process.stderr.write(`warn: push failed: ${err.message}
|
|
553
|
+
`));
|
|
554
|
+
}
|
|
555
|
+
let nextCfg = addProfile(cfg, name);
|
|
556
|
+
if (opts.switch && nextCfg.active !== name) {
|
|
557
|
+
nextCfg = setActive(nextCfg, name);
|
|
558
|
+
}
|
|
559
|
+
await saveMachineConfig(cfgPath, nextCfg);
|
|
560
|
+
const tag = nextCfg.active === name ? "active" : nextCfg.profiles.includes(name) ? "registered" : "unregistered";
|
|
561
|
+
process.stdout.write(`Adopted ~/.claude/ \u2192 profiles/${name}/ (${homeFiles.length} files). [${tag}]
|
|
562
|
+
`);
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// src/cli/backups.ts
|
|
567
|
+
import * as fs8 from "fs/promises";
|
|
568
|
+
import * as path6 from "path";
|
|
569
|
+
import * as os2 from "os";
|
|
570
|
+
function homeRoot2() {
|
|
571
|
+
return process.env.HOME ?? os2.homedir();
|
|
572
|
+
}
|
|
573
|
+
function machineHomeRoot2() {
|
|
574
|
+
return process.env.CLAUDE_PROFILE_HOME ?? path6.join(homeRoot2(), ".claude-profile");
|
|
575
|
+
}
|
|
576
|
+
async function loadCfg() {
|
|
577
|
+
const cfg = await loadMachineConfig(path6.join(machineHomeRoot2(), "config.json"));
|
|
578
|
+
if (!cfg) throw new CliError("Run `init` first.");
|
|
579
|
+
return cfg;
|
|
580
|
+
}
|
|
581
|
+
function backupRootFor() {
|
|
582
|
+
return resolveConfig({}).backups.dir.replace(/^~/, homeRoot2());
|
|
583
|
+
}
|
|
584
|
+
function attachBackups(program2) {
|
|
585
|
+
program2.command("backups").description("List backup snapshots for a profile").option("--profile <name>").action(async (opts) => {
|
|
586
|
+
const cfg = await loadCfg();
|
|
587
|
+
const profile = opts.profile ?? cfg.active;
|
|
588
|
+
if (!profile) throw new CliError("--profile required (no active profile)");
|
|
589
|
+
const entries = await listBackups(backupRootFor(), profile);
|
|
590
|
+
for (const e of entries) {
|
|
591
|
+
const stat9 = await fs8.stat(e.dir);
|
|
592
|
+
process.stdout.write(` ${e.ts} (size: ${stat9.size} bytes, dir: ${e.dir})
|
|
593
|
+
`);
|
|
594
|
+
}
|
|
595
|
+
});
|
|
596
|
+
program2.command("restore").description("Restore a backup snapshot").argument("<profile>").argument("[ts]").action(async (profile, ts) => {
|
|
597
|
+
const cfg = await loadCfg();
|
|
598
|
+
const root = backupRootFor();
|
|
599
|
+
const entries = await listBackups(root, profile);
|
|
600
|
+
const target = ts ? entries.find((e) => e.ts === ts) : entries[0];
|
|
601
|
+
if (!target) throw new CliError(`no backup found for ${profile}${ts ? ` ts=${ts}` : ""}`);
|
|
602
|
+
const claudeHome = path6.join(homeRoot2(), ".claude");
|
|
603
|
+
const resolved = resolveConfig({});
|
|
604
|
+
const rules = { include: resolved.include, exclude: resolved.exclude, denylist: resolved.denylist };
|
|
605
|
+
await snapshotBackup({
|
|
606
|
+
home: claudeHome,
|
|
607
|
+
backupRoot: root,
|
|
608
|
+
profile: cfg.active ?? "__preexisting__",
|
|
609
|
+
rules,
|
|
610
|
+
isoTs: realTime.iso(),
|
|
611
|
+
useHardlinks: resolved.backups.use_hardlinks
|
|
612
|
+
});
|
|
613
|
+
const files = await walk(target.dir);
|
|
614
|
+
for (const rel of files) {
|
|
615
|
+
if (rel === "MANIFEST.json") continue;
|
|
616
|
+
await copyFile2(path6.join(target.dir, rel), path6.join(claudeHome, rel), { hardlink: false });
|
|
617
|
+
}
|
|
618
|
+
process.stdout.write(`Restored ${profile}@${target.ts} into ${claudeHome}
|
|
619
|
+
`);
|
|
620
|
+
});
|
|
621
|
+
program2.command("prune").description("Delete old backups, keep N most recent per profile").option("--keep <n>", "number to keep", "10").action(async (opts) => {
|
|
622
|
+
await loadCfg();
|
|
623
|
+
const keep = parseInt(opts.keep, 10);
|
|
624
|
+
const root = backupRootFor();
|
|
625
|
+
const profiles = await fs8.readdir(root).catch(() => []);
|
|
626
|
+
let total = 0;
|
|
627
|
+
for (const p of profiles) {
|
|
628
|
+
const pruned = await pruneBackups(root, p, keep);
|
|
629
|
+
total += pruned.length;
|
|
630
|
+
}
|
|
631
|
+
process.stdout.write(`Pruned ${total} backup snapshot(s) across ${profiles.length} profile(s)
|
|
632
|
+
`);
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// src/cli/config.ts
|
|
637
|
+
import * as path7 from "path";
|
|
638
|
+
function configFilePath() {
|
|
639
|
+
return process.env.CLAUDE_PROFILE_HOME ? path7.join(process.env.CLAUDE_PROFILE_HOME, "config.json") : defaultMachineConfigPath();
|
|
640
|
+
}
|
|
641
|
+
function emptyConfig() {
|
|
642
|
+
return { git: "", path: "", active: null, profiles: [], host: "" };
|
|
643
|
+
}
|
|
644
|
+
function getKey(cfg, key) {
|
|
645
|
+
if (!cfg) return "(unset)";
|
|
646
|
+
const v = cfg[key];
|
|
647
|
+
if (v === null || v === void 0 || v === "") return "(unset)";
|
|
648
|
+
return Array.isArray(v) ? v.join(",") : String(v);
|
|
649
|
+
}
|
|
650
|
+
function setKey(cfg, key, value) {
|
|
651
|
+
const next = { ...cfg };
|
|
652
|
+
if (key === "profiles") next.profiles = value.split(",").filter(Boolean);
|
|
653
|
+
else next[key] = value;
|
|
654
|
+
return next;
|
|
655
|
+
}
|
|
656
|
+
function unsetKey(cfg, key) {
|
|
657
|
+
const next = { ...cfg };
|
|
658
|
+
if (key === "active") next.active = null;
|
|
659
|
+
else if (key === "profiles") next.profiles = [];
|
|
660
|
+
else next[key] = "";
|
|
661
|
+
return next;
|
|
662
|
+
}
|
|
663
|
+
function attachConfig(program2) {
|
|
664
|
+
const cmd = program2.command("config").description("Get/set/unset per-machine config").argument("<op>", "get|set|unset").argument("[key]", "config key").argument("[value]", "value (for set)").action(async (op, key, value) => {
|
|
665
|
+
const p = configFilePath();
|
|
666
|
+
const cfg = await loadMachineConfig(p) ?? emptyConfig();
|
|
667
|
+
if (op === "get") {
|
|
668
|
+
if (!key) {
|
|
669
|
+
process.stdout.write(JSON.stringify(cfg, null, 2) + "\n");
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
process.stdout.write(getKey(cfg, key) + "\n");
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
if (op === "set") {
|
|
676
|
+
if (!key || value === void 0) {
|
|
677
|
+
process.stderr.write("config set requires <key> <value>\n");
|
|
678
|
+
process.exit(2);
|
|
679
|
+
}
|
|
680
|
+
await saveMachineConfig(p, setKey(cfg, key, value));
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
if (op === "unset") {
|
|
684
|
+
if (!key) {
|
|
685
|
+
process.stderr.write("config unset requires <key>\n");
|
|
686
|
+
process.exit(2);
|
|
687
|
+
}
|
|
688
|
+
await saveMachineConfig(p, unsetKey(cfg, key));
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
process.stderr.write(`unknown config op: ${op}
|
|
692
|
+
`);
|
|
693
|
+
process.exit(2);
|
|
694
|
+
});
|
|
695
|
+
void cmd;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// src/cli/describe.ts
|
|
699
|
+
import * as fs11 from "fs/promises";
|
|
700
|
+
import * as path10 from "path";
|
|
701
|
+
import * as os4 from "os";
|
|
702
|
+
import { execa as execa2 } from "execa";
|
|
703
|
+
|
|
704
|
+
// src/cli/sync.ts
|
|
705
|
+
import * as fs10 from "fs/promises";
|
|
706
|
+
import * as path9 from "path";
|
|
707
|
+
import * as os3 from "os";
|
|
708
|
+
|
|
709
|
+
// src/core/decide.ts
|
|
710
|
+
function decide(input) {
|
|
711
|
+
const { home, repo, lastSync } = input;
|
|
712
|
+
if (lastSync === null) {
|
|
713
|
+
if (home === null) {
|
|
714
|
+
return repo === null ? { kind: "noop" } : { kind: "copy-repo-to-home" };
|
|
715
|
+
}
|
|
716
|
+
if (repo === null) return { kind: "copy-home-to-repo" };
|
|
717
|
+
return home >= repo ? { kind: "copy-home-to-repo" } : { kind: "copy-repo-to-home" };
|
|
718
|
+
}
|
|
719
|
+
if (home === null && repo === null) return { kind: "clear-meta" };
|
|
720
|
+
if (home === null) {
|
|
721
|
+
if (repo === lastSync) return { kind: "delete-repo" };
|
|
722
|
+
return { kind: "delete-vs-change", winner: "repo" };
|
|
723
|
+
}
|
|
724
|
+
if (repo === null) {
|
|
725
|
+
if (home === lastSync) return { kind: "delete-home" };
|
|
726
|
+
return { kind: "delete-vs-change", winner: "home" };
|
|
727
|
+
}
|
|
728
|
+
const homeChanged = home > lastSync;
|
|
729
|
+
const repoChanged = repo > lastSync;
|
|
730
|
+
if (!homeChanged && !repoChanged) return { kind: "noop" };
|
|
731
|
+
if (homeChanged && !repoChanged) return { kind: "copy-home-to-repo" };
|
|
732
|
+
if (!homeChanged && repoChanged) return { kind: "copy-repo-to-home" };
|
|
733
|
+
return { kind: "conflict", winner: home >= repo ? "home" : "repo" };
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// src/core/json-merge.ts
|
|
737
|
+
function cloneValue(v) {
|
|
738
|
+
return v === void 0 ? void 0 : JSON.parse(JSON.stringify(v));
|
|
739
|
+
}
|
|
740
|
+
function mergeObjects(hv, rv, homeWins) {
|
|
741
|
+
const out = { ...hv };
|
|
742
|
+
for (const nk of Object.keys(rv)) {
|
|
743
|
+
if (!(nk in out)) {
|
|
744
|
+
out[nk] = rv[nk];
|
|
745
|
+
} else if (JSON.stringify(out[nk]) !== JSON.stringify(rv[nk])) {
|
|
746
|
+
out[nk] = homeWins ? out[nk] : rv[nk];
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
return out;
|
|
750
|
+
}
|
|
751
|
+
function mergeClaudeJson(input) {
|
|
752
|
+
const { home, repo, meta, tsHome, tsRepo, includeKeys } = input;
|
|
753
|
+
const outHome = { ...home };
|
|
754
|
+
const outRepo = {};
|
|
755
|
+
const outMeta = { ...meta };
|
|
756
|
+
for (const k of includeKeys) {
|
|
757
|
+
if (k in repo) outRepo[k] = repo[k];
|
|
758
|
+
}
|
|
759
|
+
for (const key of includeKeys) {
|
|
760
|
+
const hv = home[key];
|
|
761
|
+
const rv = repo[key];
|
|
762
|
+
const decision = decide({
|
|
763
|
+
home: hv === void 0 ? null : tsHome,
|
|
764
|
+
repo: rv === void 0 ? null : tsRepo,
|
|
765
|
+
lastSync: meta[key] ?? null
|
|
766
|
+
});
|
|
767
|
+
switch (decision.kind) {
|
|
768
|
+
case "noop":
|
|
769
|
+
break;
|
|
770
|
+
case "copy-home-to-repo":
|
|
771
|
+
outRepo[key] = cloneValue(hv);
|
|
772
|
+
outMeta[key] = tsHome;
|
|
773
|
+
break;
|
|
774
|
+
case "copy-repo-to-home":
|
|
775
|
+
outHome[key] = cloneValue(rv);
|
|
776
|
+
outRepo[key] = cloneValue(rv);
|
|
777
|
+
outMeta[key] = tsRepo;
|
|
778
|
+
break;
|
|
779
|
+
case "delete-home":
|
|
780
|
+
delete outHome[key];
|
|
781
|
+
delete outRepo[key];
|
|
782
|
+
delete outMeta[key];
|
|
783
|
+
break;
|
|
784
|
+
case "delete-repo":
|
|
785
|
+
delete outHome[key];
|
|
786
|
+
delete outRepo[key];
|
|
787
|
+
delete outMeta[key];
|
|
788
|
+
break;
|
|
789
|
+
case "clear-meta":
|
|
790
|
+
delete outMeta[key];
|
|
791
|
+
break;
|
|
792
|
+
case "conflict": {
|
|
793
|
+
const homeWins = decision.winner === "home";
|
|
794
|
+
let merged;
|
|
795
|
+
if (isPlainObject(hv) && isPlainObject(rv)) {
|
|
796
|
+
merged = mergeObjects(hv, rv, homeWins);
|
|
797
|
+
} else {
|
|
798
|
+
merged = homeWins ? hv : rv;
|
|
799
|
+
}
|
|
800
|
+
outHome[key] = cloneValue(merged);
|
|
801
|
+
outRepo[key] = cloneValue(merged);
|
|
802
|
+
outMeta[key] = Math.max(tsHome, tsRepo);
|
|
803
|
+
break;
|
|
804
|
+
}
|
|
805
|
+
case "delete-vs-change": {
|
|
806
|
+
const homeWins = decision.winner === "home";
|
|
807
|
+
const winning = homeWins ? hv : rv;
|
|
808
|
+
outHome[key] = cloneValue(winning);
|
|
809
|
+
outRepo[key] = cloneValue(winning);
|
|
810
|
+
outMeta[key] = homeWins ? tsHome : tsRepo;
|
|
811
|
+
break;
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
return { home: outHome, repo: outRepo, meta: outMeta };
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// src/io/lock.ts
|
|
819
|
+
import * as fs9 from "fs/promises";
|
|
820
|
+
import * as path8 from "path";
|
|
821
|
+
import lockfile from "proper-lockfile";
|
|
822
|
+
var LockBusyError = class extends Error {
|
|
823
|
+
constructor(p) {
|
|
824
|
+
super(`another sync in progress (lock: ${p})`);
|
|
825
|
+
this.name = "LockBusyError";
|
|
826
|
+
}
|
|
827
|
+
};
|
|
828
|
+
async function withLock(lockPath, fn) {
|
|
829
|
+
await fs9.mkdir(path8.dirname(lockPath), { recursive: true });
|
|
830
|
+
try {
|
|
831
|
+
const fh = await fs9.open(lockPath, "a");
|
|
832
|
+
await fh.close();
|
|
833
|
+
} catch {
|
|
834
|
+
}
|
|
835
|
+
let release;
|
|
836
|
+
try {
|
|
837
|
+
release = await lockfile.lock(lockPath, { retries: 0, stale: 6e4 });
|
|
838
|
+
} catch (err) {
|
|
839
|
+
if (err.code === "ELOCKED") {
|
|
840
|
+
throw new LockBusyError(lockPath);
|
|
841
|
+
}
|
|
842
|
+
throw err;
|
|
843
|
+
}
|
|
844
|
+
try {
|
|
845
|
+
return await fn();
|
|
846
|
+
} finally {
|
|
847
|
+
await release();
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// src/cli/sync.ts
|
|
852
|
+
function homeRoot3() {
|
|
853
|
+
return process.env.HOME ?? os3.homedir();
|
|
854
|
+
}
|
|
855
|
+
function machineHomeRoot3() {
|
|
856
|
+
return process.env.CLAUDE_PROFILE_HOME ?? path9.join(homeRoot3(), ".claude-profile");
|
|
857
|
+
}
|
|
858
|
+
async function loadCheckoutYaml(checkout, profile) {
|
|
859
|
+
const readMaybe = async (p) => {
|
|
860
|
+
try {
|
|
861
|
+
return parseYamlConfig(await fs10.readFile(p, "utf8"));
|
|
862
|
+
} catch (err) {
|
|
863
|
+
if (err.code === "ENOENT") return {};
|
|
864
|
+
throw err;
|
|
865
|
+
}
|
|
866
|
+
};
|
|
867
|
+
return {
|
|
868
|
+
repo: await readMaybe(path9.join(checkout, "claude-profile.yaml")),
|
|
869
|
+
profile: await readMaybe(path9.join(checkout, "profiles", profile, "claude-profile.yaml"))
|
|
870
|
+
};
|
|
871
|
+
}
|
|
872
|
+
async function loadMeta(checkout, profile) {
|
|
873
|
+
const p = path9.join(checkout, ".claude-profile", profile, "meta.json");
|
|
874
|
+
try {
|
|
875
|
+
return parseMeta(await fs10.readFile(p, "utf8"));
|
|
876
|
+
} catch (err) {
|
|
877
|
+
if (err.code === "ENOENT") return emptyMeta();
|
|
878
|
+
throw err;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
async function saveMeta(checkout, profile, meta) {
|
|
882
|
+
const p = path9.join(checkout, ".claude-profile", profile, "meta.json");
|
|
883
|
+
await atomicWrite(p, serializeMeta(meta));
|
|
884
|
+
}
|
|
885
|
+
function tmpl(s, vars) {
|
|
886
|
+
return s.replace(/\{(\w+)\}/g, (_, k) => vars[k] ?? `{${k}}`);
|
|
887
|
+
}
|
|
888
|
+
async function planSync(cfg, resolved, profile) {
|
|
889
|
+
const checkout = cfg.path;
|
|
890
|
+
const claudeHome = path9.join(homeRoot3(), ".claude");
|
|
891
|
+
const profileDir = path9.join(checkout, "profiles", profile);
|
|
892
|
+
const meta = await loadMeta(checkout, profile);
|
|
893
|
+
const rules = { include: resolved.include, exclude: resolved.exclude, denylist: resolved.denylist };
|
|
894
|
+
const homeFiles = await walk(claudeHome);
|
|
895
|
+
const repoFiles = await walk(profileDir);
|
|
896
|
+
const union = /* @__PURE__ */ new Set();
|
|
897
|
+
for (const p of homeFiles) if (isPathInScope(p, rules)) union.add(p);
|
|
898
|
+
for (const p of repoFiles) if (isPathInScope(p, rules)) union.add(p);
|
|
899
|
+
const out = [];
|
|
900
|
+
for (const rel of union) {
|
|
901
|
+
const home = await statMtime(path9.join(claudeHome, rel));
|
|
902
|
+
const repo = await statMtime(path9.join(profileDir, rel));
|
|
903
|
+
const lastSync = meta.files[rel] ?? null;
|
|
904
|
+
out.push({ rel, decision: decide({ home, repo, lastSync }), home, repo, lastSync });
|
|
905
|
+
}
|
|
906
|
+
return out;
|
|
907
|
+
}
|
|
908
|
+
async function syncActive(cfg, resolved, profile) {
|
|
909
|
+
const checkout = cfg.path;
|
|
910
|
+
const claudeHome = path9.join(homeRoot3(), ".claude");
|
|
911
|
+
const profileDir = path9.join(checkout, "profiles", profile);
|
|
912
|
+
await fs10.mkdir(profileDir, { recursive: true });
|
|
913
|
+
await git.fetch(checkout);
|
|
914
|
+
try {
|
|
915
|
+
await git.pull(checkout, resolved.git.pull_strategy);
|
|
916
|
+
} catch (err) {
|
|
917
|
+
throw new CliError(`git pull (${resolved.git.pull_strategy}) failed: ${err.message}`);
|
|
918
|
+
}
|
|
919
|
+
const meta = await loadMeta(checkout, profile);
|
|
920
|
+
const plan = await planSync(cfg, resolved, profile);
|
|
921
|
+
let conflicts = 0;
|
|
922
|
+
const nextMetaFiles = { ...meta.files };
|
|
923
|
+
const allRepoFiles = await walk(profileDir);
|
|
924
|
+
const rules = { include: resolved.include, exclude: resolved.exclude, denylist: resolved.denylist };
|
|
925
|
+
for (const rel of allRepoFiles) {
|
|
926
|
+
if (rel === "claude-profile.yaml" || rel === resolved.claude_json.partial_filename || rel.startsWith(".conflicts/")) continue;
|
|
927
|
+
if (!isPathInScope(rel, rules)) {
|
|
928
|
+
await fs10.rm(path9.join(profileDir, rel), { force: true });
|
|
929
|
+
delete nextMetaFiles[rel];
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
for (const item of plan) {
|
|
933
|
+
const rel = item.rel;
|
|
934
|
+
const homeAbs = path9.join(claudeHome, rel);
|
|
935
|
+
const repoAbs = path9.join(profileDir, rel);
|
|
936
|
+
const d = item.decision;
|
|
937
|
+
switch (d.kind) {
|
|
938
|
+
case "noop":
|
|
939
|
+
break;
|
|
940
|
+
case "copy-home-to-repo": {
|
|
941
|
+
await copyFile2(homeAbs, repoAbs, { hardlink: false });
|
|
942
|
+
const t = await statMtime(repoAbs);
|
|
943
|
+
if (t !== null) nextMetaFiles[rel] = t;
|
|
944
|
+
break;
|
|
945
|
+
}
|
|
946
|
+
case "copy-repo-to-home": {
|
|
947
|
+
await copyFile2(repoAbs, homeAbs, { hardlink: false });
|
|
948
|
+
const t = await statMtime(homeAbs);
|
|
949
|
+
if (t !== null) nextMetaFiles[rel] = t;
|
|
950
|
+
break;
|
|
951
|
+
}
|
|
952
|
+
case "delete-home":
|
|
953
|
+
await fs10.rm(homeAbs, { force: true });
|
|
954
|
+
delete nextMetaFiles[rel];
|
|
955
|
+
break;
|
|
956
|
+
case "delete-repo":
|
|
957
|
+
await fs10.rm(repoAbs, { force: true });
|
|
958
|
+
delete nextMetaFiles[rel];
|
|
959
|
+
break;
|
|
960
|
+
case "clear-meta":
|
|
961
|
+
delete nextMetaFiles[rel];
|
|
962
|
+
break;
|
|
963
|
+
case "conflict": {
|
|
964
|
+
conflicts++;
|
|
965
|
+
const ts = realTime.iso();
|
|
966
|
+
const loser = d.winner === "home" ? repoAbs : homeAbs;
|
|
967
|
+
const conflictDest = path9.join(profileDir, ".conflicts", `${rel}.${cfg.host}.${ts}`);
|
|
968
|
+
await copyFile2(loser, conflictDest, { hardlink: false });
|
|
969
|
+
const winnerAbs = d.winner === "home" ? homeAbs : repoAbs;
|
|
970
|
+
const otherAbs = d.winner === "home" ? repoAbs : homeAbs;
|
|
971
|
+
await copyFile2(winnerAbs, otherAbs, { hardlink: false });
|
|
972
|
+
const t = await statMtime(otherAbs);
|
|
973
|
+
if (t !== null) nextMetaFiles[rel] = t;
|
|
974
|
+
break;
|
|
975
|
+
}
|
|
976
|
+
case "delete-vs-change": {
|
|
977
|
+
if (d.winner === "repo") {
|
|
978
|
+
await copyFile2(repoAbs, homeAbs, { hardlink: false });
|
|
979
|
+
const t = await statMtime(homeAbs);
|
|
980
|
+
if (t !== null) nextMetaFiles[rel] = t;
|
|
981
|
+
} else {
|
|
982
|
+
await copyFile2(homeAbs, repoAbs, { hardlink: false });
|
|
983
|
+
const t = await statMtime(repoAbs);
|
|
984
|
+
if (t !== null) nextMetaFiles[rel] = t;
|
|
985
|
+
}
|
|
986
|
+
process.stderr.write(`warning: delete-vs-change for ${rel}; ${d.winner} won
|
|
987
|
+
`);
|
|
988
|
+
break;
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
const claudeJsonPath = resolved.claude_json.path.replace(/^~/, homeRoot3());
|
|
993
|
+
const partialPath = path9.join(profileDir, resolved.claude_json.partial_filename);
|
|
994
|
+
const homeJson = JSON.parse(
|
|
995
|
+
await fs10.readFile(claudeJsonPath, "utf8").catch(() => "{}")
|
|
996
|
+
);
|
|
997
|
+
const repoJson = JSON.parse(
|
|
998
|
+
await fs10.readFile(partialPath, "utf8").catch(() => "{}")
|
|
999
|
+
);
|
|
1000
|
+
const tsHome = await statMtime(claudeJsonPath) ?? 0;
|
|
1001
|
+
const tsRepo = await statMtime(partialPath) ?? 0;
|
|
1002
|
+
const mergedJson = mergeClaudeJson({
|
|
1003
|
+
home: homeJson,
|
|
1004
|
+
repo: repoJson,
|
|
1005
|
+
meta: meta.claude_json,
|
|
1006
|
+
tsHome,
|
|
1007
|
+
tsRepo,
|
|
1008
|
+
includeKeys: resolved.claude_json.include_keys
|
|
1009
|
+
});
|
|
1010
|
+
if (Object.keys(mergedJson.home).length > 0) {
|
|
1011
|
+
await atomicWrite(claudeJsonPath, JSON.stringify(mergedJson.home, null, 2) + "\n");
|
|
1012
|
+
}
|
|
1013
|
+
if (Object.keys(mergedJson.repo).length > 0) {
|
|
1014
|
+
await atomicWrite(partialPath, JSON.stringify(mergedJson.repo, null, 2) + "\n");
|
|
1015
|
+
}
|
|
1016
|
+
await saveMeta(checkout, profile, {
|
|
1017
|
+
...meta,
|
|
1018
|
+
files: nextMetaFiles,
|
|
1019
|
+
claude_json: mergedJson.meta
|
|
1020
|
+
});
|
|
1021
|
+
const relProfile = path9.join("profiles", profile);
|
|
1022
|
+
const relState = path9.join(".claude-profile", profile);
|
|
1023
|
+
await git.add(checkout, [relProfile, relState]);
|
|
1024
|
+
const msg = tmpl(resolved.git.commit_message_template, {
|
|
1025
|
+
host: cfg.host,
|
|
1026
|
+
iso_ts: realTime.iso()
|
|
1027
|
+
});
|
|
1028
|
+
const committed = await git.commitIfStaged(checkout, msg);
|
|
1029
|
+
if (committed && resolved.git.push) {
|
|
1030
|
+
await git.push(checkout).catch((err) => {
|
|
1031
|
+
process.stderr.write(`warn: push failed: ${err.message}
|
|
1032
|
+
`);
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
await atomicWrite(
|
|
1036
|
+
path9.join(machineHomeRoot3(), "last-sync.timestamp"),
|
|
1037
|
+
realTime.iso() + "\n"
|
|
1038
|
+
);
|
|
1039
|
+
return { conflicts };
|
|
1040
|
+
}
|
|
1041
|
+
function attachSync(program2) {
|
|
1042
|
+
program2.command("sync").description("Pull, merge, commit, push the active (or named) profile").option("--profile <name>").option("--dry-run", "print plan; no writes", false).option("--quiet", "suppress non-error output", false).option("--background", "re-exec detached", false).option("--pull", "internal: SessionStart hook \u2014 pull only", false).option("--push", "internal: Stop hook \u2014 push only", false).action(
|
|
1043
|
+
async (opts) => {
|
|
1044
|
+
if (opts.quiet) process.env.CLAUDE_PROFILE_QUIET = "1";
|
|
1045
|
+
const cfgPath = path9.join(machineHomeRoot3(), "config.json");
|
|
1046
|
+
const cfg = await loadMachineConfig(cfgPath);
|
|
1047
|
+
if (!cfg) throw new CliError("Run `claude-profile init --git <url>` first.");
|
|
1048
|
+
const profile = opts.profile ?? cfg.active;
|
|
1049
|
+
if (!profile) throw new CliError("No active profile. Pass --profile or activate one.");
|
|
1050
|
+
const { repo, profile: pyaml } = await loadCheckoutYaml(cfg.path, profile);
|
|
1051
|
+
const resolved = resolveConfig({
|
|
1052
|
+
repo,
|
|
1053
|
+
profile: pyaml,
|
|
1054
|
+
env: process.env
|
|
1055
|
+
});
|
|
1056
|
+
if (opts.pull) {
|
|
1057
|
+
const checkout = cfg.path;
|
|
1058
|
+
const { repo: repo2, profile: pyaml2 } = await loadCheckoutYaml(checkout, profile);
|
|
1059
|
+
const resolved2 = resolveConfig({
|
|
1060
|
+
repo: repo2,
|
|
1061
|
+
profile: pyaml2,
|
|
1062
|
+
env: process.env
|
|
1063
|
+
});
|
|
1064
|
+
const plan = await planSync(cfg, resolved2, profile);
|
|
1065
|
+
const hasHomeChanges = plan.some((p) => p.decision.kind === "copy-home-to-repo");
|
|
1066
|
+
if (hasHomeChanges) {
|
|
1067
|
+
await syncActive(cfg, resolved2, profile);
|
|
1068
|
+
}
|
|
1069
|
+
try {
|
|
1070
|
+
await git.fetch(checkout);
|
|
1071
|
+
} catch {
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
try {
|
|
1075
|
+
await git.pull(checkout, resolved2.git.pull_strategy);
|
|
1076
|
+
} catch {
|
|
1077
|
+
return;
|
|
1078
|
+
}
|
|
1079
|
+
await syncActive(cfg, resolved2, profile);
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
if (opts.push) {
|
|
1083
|
+
const debounceFile = path9.join(machineHomeRoot3(), "last-push.timestamp");
|
|
1084
|
+
const lastPushIso = await fs10.readFile(debounceFile, "utf8").catch(() => null);
|
|
1085
|
+
const { repo: repo2, profile: pyaml2 } = await loadCheckoutYaml(cfg.path, profile);
|
|
1086
|
+
const resolved2 = resolveConfig({
|
|
1087
|
+
repo: repo2,
|
|
1088
|
+
profile: pyaml2,
|
|
1089
|
+
env: process.env
|
|
1090
|
+
});
|
|
1091
|
+
if (lastPushIso) {
|
|
1092
|
+
const lastMs = Date.parse(lastPushIso.trim());
|
|
1093
|
+
if (!isNaN(lastMs) && Date.now() - lastMs < resolved2.autosync.push_debounce_seconds * 1e3) {
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
await syncActive(cfg, resolved2, profile);
|
|
1098
|
+
await atomicWrite(debounceFile, realTime.iso() + "\n");
|
|
1099
|
+
return;
|
|
1100
|
+
}
|
|
1101
|
+
if (profile !== cfg.active) {
|
|
1102
|
+
await git.fetch(cfg.path);
|
|
1103
|
+
await git.pull(cfg.path, resolved.git.pull_strategy);
|
|
1104
|
+
const metaPath = path9.join(cfg.path, ".claude-profile", profile, "meta.json");
|
|
1105
|
+
if (!await fs10.stat(metaPath).catch(() => null)) {
|
|
1106
|
+
await atomicWrite(metaPath, serializeMeta(emptyMeta()));
|
|
1107
|
+
await git.add(cfg.path, [path9.join(".claude-profile", profile)]);
|
|
1108
|
+
await git.commitIfStaged(cfg.path, `init meta for ${profile} from ${cfg.host}`);
|
|
1109
|
+
if (resolved.git.push) await git.push(cfg.path).catch(() => {
|
|
1110
|
+
});
|
|
1111
|
+
}
|
|
1112
|
+
return;
|
|
1113
|
+
}
|
|
1114
|
+
const lockPath = path9.join(machineHomeRoot3(), "sync.lock");
|
|
1115
|
+
try {
|
|
1116
|
+
const { conflicts } = await withLock(
|
|
1117
|
+
lockPath,
|
|
1118
|
+
async () => syncActive(cfg, resolved, profile)
|
|
1119
|
+
);
|
|
1120
|
+
if (conflicts > 0) process.exit(1);
|
|
1121
|
+
} catch (err) {
|
|
1122
|
+
if (err instanceof LockBusyError) {
|
|
1123
|
+
process.stderr.write(err.message + "\n");
|
|
1124
|
+
process.exit(2);
|
|
1125
|
+
}
|
|
1126
|
+
throw err;
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
);
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
// src/cli/describe.ts
|
|
1133
|
+
function homeRoot4() {
|
|
1134
|
+
return process.env.HOME ?? os4.homedir();
|
|
1135
|
+
}
|
|
1136
|
+
function machineHomeRoot4() {
|
|
1137
|
+
return process.env.CLAUDE_PROFILE_HOME ?? path10.join(homeRoot4(), ".claude-profile");
|
|
1138
|
+
}
|
|
1139
|
+
function parseFrontmatter(text) {
|
|
1140
|
+
if (!text.startsWith("---")) return {};
|
|
1141
|
+
const end = text.indexOf("\n---", 3);
|
|
1142
|
+
if (end < 0) return {};
|
|
1143
|
+
const block = text.slice(3, end);
|
|
1144
|
+
const out = {};
|
|
1145
|
+
for (const raw of block.split(/\r?\n/)) {
|
|
1146
|
+
const line = raw.trim();
|
|
1147
|
+
if (!line || line.startsWith("#")) continue;
|
|
1148
|
+
const m = /^([A-Za-z0-9_-]+)\s*:\s*(.*)$/.exec(line);
|
|
1149
|
+
if (!m) continue;
|
|
1150
|
+
const key = m[1] ?? "";
|
|
1151
|
+
let val = (m[2] ?? "").trim();
|
|
1152
|
+
if (val.startsWith('"') && val.endsWith('"') || val.startsWith("'") && val.endsWith("'")) {
|
|
1153
|
+
val = val.slice(1, -1);
|
|
1154
|
+
}
|
|
1155
|
+
if (key === "name") out.name = val;
|
|
1156
|
+
if (key === "description") out.description = val;
|
|
1157
|
+
}
|
|
1158
|
+
return out;
|
|
1159
|
+
}
|
|
1160
|
+
async function readJson(p) {
|
|
1161
|
+
try {
|
|
1162
|
+
return JSON.parse(await fs11.readFile(p, "utf8"));
|
|
1163
|
+
} catch (err) {
|
|
1164
|
+
if (err.code === "ENOENT") return null;
|
|
1165
|
+
return null;
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
function isRecord(x) {
|
|
1169
|
+
return typeof x === "object" && x !== null && !Array.isArray(x);
|
|
1170
|
+
}
|
|
1171
|
+
function summarizeSettings(obj) {
|
|
1172
|
+
if (!isRecord(obj)) return null;
|
|
1173
|
+
const permissions = isRecord(obj.permissions) ? obj.permissions : {};
|
|
1174
|
+
const count = (k) => {
|
|
1175
|
+
const v = permissions[k];
|
|
1176
|
+
return Array.isArray(v) ? v.length : 0;
|
|
1177
|
+
};
|
|
1178
|
+
const env = isRecord(obj.env) ? Object.keys(obj.env) : [];
|
|
1179
|
+
return {
|
|
1180
|
+
model: typeof obj.model === "string" ? obj.model : null,
|
|
1181
|
+
permissionMode: typeof permissions.defaultMode === "string" ? permissions.defaultMode : null,
|
|
1182
|
+
permissions: { allow: count("allow"), deny: count("deny"), ask: count("ask") },
|
|
1183
|
+
envVars: env,
|
|
1184
|
+
statusLine: isRecord(obj.statusLine) && typeof obj.statusLine.type === "string" ? obj.statusLine.type : typeof obj.statusLine === "string" ? obj.statusLine : null,
|
|
1185
|
+
outputStyle: typeof obj.outputStyle === "string" ? obj.outputStyle : null,
|
|
1186
|
+
topLevelKeys: Object.keys(obj).sort()
|
|
1187
|
+
};
|
|
1188
|
+
}
|
|
1189
|
+
function extractHooks(obj) {
|
|
1190
|
+
if (!isRecord(obj) || !isRecord(obj.hooks)) return [];
|
|
1191
|
+
const out = [];
|
|
1192
|
+
for (const [event, raw] of Object.entries(obj.hooks)) {
|
|
1193
|
+
const arr = Array.isArray(raw) ? raw : [];
|
|
1194
|
+
for (const item of arr) {
|
|
1195
|
+
if (!isRecord(item)) continue;
|
|
1196
|
+
const matcher = typeof item.matcher === "string" ? item.matcher : "*";
|
|
1197
|
+
const hooks = Array.isArray(item.hooks) ? item.hooks : [];
|
|
1198
|
+
for (const h of hooks) {
|
|
1199
|
+
if (!isRecord(h)) continue;
|
|
1200
|
+
const handler = typeof h.command === "string" ? "command" : typeof h.type === "string" ? h.type : "unknown";
|
|
1201
|
+
out.push({ event, matcher, handler });
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
return out;
|
|
1206
|
+
}
|
|
1207
|
+
function extractMcp(settings, mcpJson) {
|
|
1208
|
+
const out = [];
|
|
1209
|
+
const addServers = (source, servers) => {
|
|
1210
|
+
if (!isRecord(servers)) return;
|
|
1211
|
+
for (const [name, def] of Object.entries(servers)) {
|
|
1212
|
+
if (!isRecord(def)) continue;
|
|
1213
|
+
const transport = typeof def.type === "string" ? def.type : typeof def.transport === "string" ? def.transport : typeof def.url === "string" ? "http" : typeof def.command === "string" ? "stdio" : "unknown";
|
|
1214
|
+
out.push({ name, transport, source });
|
|
1215
|
+
}
|
|
1216
|
+
};
|
|
1217
|
+
if (isRecord(settings)) addServers("settings.json", settings.mcpServers);
|
|
1218
|
+
if (isRecord(mcpJson)) addServers(".mcp.json", mcpJson.mcpServers ?? mcpJson);
|
|
1219
|
+
return out;
|
|
1220
|
+
}
|
|
1221
|
+
async function listMarkdownEntries(root) {
|
|
1222
|
+
const files = await walk(root);
|
|
1223
|
+
const out = [];
|
|
1224
|
+
for (const rel of files) {
|
|
1225
|
+
if (!rel.endsWith(".md")) continue;
|
|
1226
|
+
const abs = path10.join(root, rel);
|
|
1227
|
+
try {
|
|
1228
|
+
const text = await fs11.readFile(abs, "utf8");
|
|
1229
|
+
out.push({ rel, fm: parseFrontmatter(text) });
|
|
1230
|
+
} catch {
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
return out;
|
|
1234
|
+
}
|
|
1235
|
+
async function readSkills(skillsDir) {
|
|
1236
|
+
const out = [];
|
|
1237
|
+
let entries;
|
|
1238
|
+
try {
|
|
1239
|
+
entries = await fs11.readdir(skillsDir, { withFileTypes: true });
|
|
1240
|
+
} catch {
|
|
1241
|
+
return out;
|
|
1242
|
+
}
|
|
1243
|
+
for (const ent of entries) {
|
|
1244
|
+
if (!ent.isDirectory()) continue;
|
|
1245
|
+
const skillFile = path10.join(skillsDir, ent.name, "SKILL.md");
|
|
1246
|
+
try {
|
|
1247
|
+
const text = await fs11.readFile(skillFile, "utf8");
|
|
1248
|
+
const fm = parseFrontmatter(text);
|
|
1249
|
+
out.push({
|
|
1250
|
+
dir: ent.name,
|
|
1251
|
+
name: fm.name ?? ent.name,
|
|
1252
|
+
description: fm.description ?? ""
|
|
1253
|
+
});
|
|
1254
|
+
} catch {
|
|
1255
|
+
out.push({ dir: ent.name, name: ent.name, description: "(no SKILL.md)" });
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
return out;
|
|
1259
|
+
}
|
|
1260
|
+
async function readListedDirs(parent) {
|
|
1261
|
+
try {
|
|
1262
|
+
const entries = await fs11.readdir(parent, { withFileTypes: true });
|
|
1263
|
+
return entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
1264
|
+
} catch {
|
|
1265
|
+
return [];
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
async function readListedFiles(parent, ext) {
|
|
1269
|
+
try {
|
|
1270
|
+
const entries = await fs11.readdir(parent, { withFileTypes: true });
|
|
1271
|
+
return entries.filter((e) => e.isFile() && e.name.endsWith(ext)).map((e) => e.name.replace(new RegExp(`${ext.replace(/\./g, "\\.")}$`), ""));
|
|
1272
|
+
} catch {
|
|
1273
|
+
return [];
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
async function fileSize(p) {
|
|
1277
|
+
try {
|
|
1278
|
+
return (await fs11.stat(p)).size;
|
|
1279
|
+
} catch {
|
|
1280
|
+
return 0;
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
async function gitInfo(cwd) {
|
|
1284
|
+
const opts = { cwd, reject: false, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } };
|
|
1285
|
+
const br = await execa2("git", ["rev-parse", "--abbrev-ref", "HEAD"], opts);
|
|
1286
|
+
const branch = br.exitCode === 0 && typeof br.stdout === "string" ? br.stdout.trim() : null;
|
|
1287
|
+
const lg = await execa2(
|
|
1288
|
+
"git",
|
|
1289
|
+
["log", "-1", "--format=%H%x09%cI%x09%s"],
|
|
1290
|
+
opts
|
|
1291
|
+
);
|
|
1292
|
+
if (lg.exitCode !== 0 || typeof lg.stdout !== "string" || lg.stdout.trim() === "") {
|
|
1293
|
+
return { branch, commit: null };
|
|
1294
|
+
}
|
|
1295
|
+
const [hash, iso, subject] = lg.stdout.trim().split(" ");
|
|
1296
|
+
return {
|
|
1297
|
+
branch,
|
|
1298
|
+
commit: {
|
|
1299
|
+
hash: (hash ?? "").slice(0, 12),
|
|
1300
|
+
iso: iso ?? "",
|
|
1301
|
+
subject: subject ?? ""
|
|
1302
|
+
}
|
|
1303
|
+
};
|
|
1304
|
+
}
|
|
1305
|
+
async function countBackups(profile) {
|
|
1306
|
+
const dir = path10.join(machineHomeRoot4(), "backups", profile);
|
|
1307
|
+
let entries;
|
|
1308
|
+
try {
|
|
1309
|
+
entries = await fs11.readdir(dir, { withFileTypes: true });
|
|
1310
|
+
} catch {
|
|
1311
|
+
return { count: 0, newest: null, oldest: null };
|
|
1312
|
+
}
|
|
1313
|
+
const names = entries.filter((e) => e.isDirectory()).map((e) => e.name).sort();
|
|
1314
|
+
if (names.length === 0) return { count: 0, newest: null, oldest: null };
|
|
1315
|
+
return { count: names.length, newest: names[names.length - 1] ?? null, oldest: names[0] ?? null };
|
|
1316
|
+
}
|
|
1317
|
+
async function buildReport(cfg, profile, resolved) {
|
|
1318
|
+
const checkout = cfg.path;
|
|
1319
|
+
const profileDir = path10.join(checkout, "profiles", profile);
|
|
1320
|
+
const allFiles = await walk(profileDir);
|
|
1321
|
+
let totalBytes = 0;
|
|
1322
|
+
const byTopDir = {};
|
|
1323
|
+
for (const rel of allFiles) {
|
|
1324
|
+
totalBytes += await fileSize(path10.join(profileDir, rel));
|
|
1325
|
+
const top = rel.includes("/") ? rel.slice(0, rel.indexOf("/")) : rel;
|
|
1326
|
+
byTopDir[top] = (byTopDir[top] ?? 0) + 1;
|
|
1327
|
+
}
|
|
1328
|
+
const settings = await readJson(path10.join(profileDir, "settings.json"));
|
|
1329
|
+
const settingsLocal = await readJson(path10.join(profileDir, "settings.local.json"));
|
|
1330
|
+
const mcpJson = await readJson(path10.join(profileDir, ".mcp.json"));
|
|
1331
|
+
const cmdEntries = await listMarkdownEntries(path10.join(profileDir, "commands"));
|
|
1332
|
+
const commands = cmdEntries.map((e) => ({
|
|
1333
|
+
rel: e.rel,
|
|
1334
|
+
name: e.fm.name ?? e.rel.replace(/\.md$/, "").replace(/\//g, ":"),
|
|
1335
|
+
description: e.fm.description ?? ""
|
|
1336
|
+
}));
|
|
1337
|
+
const skills = await readSkills(path10.join(profileDir, "skills"));
|
|
1338
|
+
const agentEntries = await listMarkdownEntries(path10.join(profileDir, "agents"));
|
|
1339
|
+
const agents = agentEntries.map((e) => ({
|
|
1340
|
+
rel: e.rel,
|
|
1341
|
+
name: e.fm.name ?? e.rel.replace(/\.md$/, "").replace(/\//g, ":"),
|
|
1342
|
+
description: e.fm.description ?? ""
|
|
1343
|
+
}));
|
|
1344
|
+
const plugins = await readListedDirs(path10.join(profileDir, "plugins"));
|
|
1345
|
+
const outputStyles = [
|
|
1346
|
+
...await readListedFiles(path10.join(profileDir, "output-styles"), ".md"),
|
|
1347
|
+
...await readListedFiles(path10.join(profileDir, "output-styles"), ".json")
|
|
1348
|
+
];
|
|
1349
|
+
const partial = await readJson(
|
|
1350
|
+
path10.join(profileDir, resolved.claude_json.partial_filename)
|
|
1351
|
+
);
|
|
1352
|
+
const trackedJsonKeys = partial ? Object.entries(partial).map(([k, v]) => ({ key: k, type: Array.isArray(v) ? "array" : typeof v })) : [];
|
|
1353
|
+
const homeJsonPath = resolved.claude_json.path.replace(/^~/, homeRoot4());
|
|
1354
|
+
const homeJson = await readJson(homeJsonPath);
|
|
1355
|
+
const homeJsonAvailable = homeJson !== null;
|
|
1356
|
+
const includeSet = new Set(resolved.claude_json.include_keys);
|
|
1357
|
+
const excludedJsonKeys = homeJson ? Object.entries(homeJson).filter(([k]) => !includeSet.has(k)).map(([k, v]) => ({
|
|
1358
|
+
key: k,
|
|
1359
|
+
type: Array.isArray(v) ? "array" : typeof v,
|
|
1360
|
+
bytes: JSON.stringify(v).length
|
|
1361
|
+
})).sort((a, b) => b.bytes - a.bytes) : [];
|
|
1362
|
+
const { branch, commit: commit2 } = await gitInfo(checkout);
|
|
1363
|
+
const lastSyncFile = path10.join(machineHomeRoot4(), "last-sync.timestamp");
|
|
1364
|
+
let lastSync = null;
|
|
1365
|
+
if (cfg.active === profile) {
|
|
1366
|
+
try {
|
|
1367
|
+
lastSync = (await fs11.readFile(lastSyncFile, "utf8")).trim() || null;
|
|
1368
|
+
} catch {
|
|
1369
|
+
lastSync = null;
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
return {
|
|
1373
|
+
profile: {
|
|
1374
|
+
name: profile,
|
|
1375
|
+
active: cfg.active === profile,
|
|
1376
|
+
registered: cfg.profiles.includes(profile),
|
|
1377
|
+
host: cfg.host,
|
|
1378
|
+
repo: checkout,
|
|
1379
|
+
branch,
|
|
1380
|
+
lastCommit: commit2,
|
|
1381
|
+
lastSync
|
|
1382
|
+
},
|
|
1383
|
+
rules: {
|
|
1384
|
+
include: resolved.include,
|
|
1385
|
+
exclude: resolved.exclude,
|
|
1386
|
+
denylist: resolved.denylist
|
|
1387
|
+
},
|
|
1388
|
+
files: { count: allFiles.length, bytes: totalBytes, byTopDir },
|
|
1389
|
+
settings: summarizeSettings(settings),
|
|
1390
|
+
settingsLocal: summarizeSettings(settingsLocal),
|
|
1391
|
+
hooks: [...extractHooks(settings), ...extractHooks(settingsLocal)],
|
|
1392
|
+
mcp: extractMcp(settings, mcpJson),
|
|
1393
|
+
commands,
|
|
1394
|
+
skills,
|
|
1395
|
+
agents,
|
|
1396
|
+
plugins,
|
|
1397
|
+
outputStyles,
|
|
1398
|
+
trackedJsonKeys,
|
|
1399
|
+
excludedJsonKeys,
|
|
1400
|
+
homeJsonAvailable,
|
|
1401
|
+
backups: await countBackups(profile)
|
|
1402
|
+
};
|
|
1403
|
+
}
|
|
1404
|
+
function fmtBytes(n) {
|
|
1405
|
+
if (n < 1024) return `${n} B`;
|
|
1406
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
1407
|
+
return `${(n / 1024 / 1024).toFixed(2)} MB`;
|
|
1408
|
+
}
|
|
1409
|
+
function pad(s, n) {
|
|
1410
|
+
return s.length >= n ? s + " " : s + " ".repeat(n - s.length);
|
|
1411
|
+
}
|
|
1412
|
+
function renderHuman(r, brief) {
|
|
1413
|
+
const lines = [];
|
|
1414
|
+
const tags = [];
|
|
1415
|
+
if (r.profile.active) tags.push("active");
|
|
1416
|
+
if (r.profile.registered && !r.profile.active) tags.push("registered");
|
|
1417
|
+
if (!r.profile.registered) tags.push("repo-only");
|
|
1418
|
+
lines.push(`Profile: ${r.profile.name} (${tags.join(", ")})`);
|
|
1419
|
+
lines.push("\u2500".repeat(60));
|
|
1420
|
+
lines.push(`Host: ${r.profile.host}`);
|
|
1421
|
+
lines.push(`Repo: ${r.profile.repo}`);
|
|
1422
|
+
if (r.profile.branch) lines.push(`Branch: ${r.profile.branch}`);
|
|
1423
|
+
if (r.profile.lastCommit) {
|
|
1424
|
+
lines.push(
|
|
1425
|
+
`Last commit: ${r.profile.lastCommit.hash} \u2014 ${r.profile.lastCommit.iso} \u2014 ${r.profile.lastCommit.subject}`
|
|
1426
|
+
);
|
|
1427
|
+
}
|
|
1428
|
+
if (r.profile.lastSync) lines.push(`Last sync: ${r.profile.lastSync}`);
|
|
1429
|
+
lines.push("");
|
|
1430
|
+
lines.push("Sync rules");
|
|
1431
|
+
lines.push(` include: ${r.rules.include.length} patterns`);
|
|
1432
|
+
lines.push(` exclude: ${r.rules.exclude.length} patterns`);
|
|
1433
|
+
lines.push(` denylist: ${r.rules.denylist.length} patterns`);
|
|
1434
|
+
lines.push(` tracked: ${r.files.count} files (${fmtBytes(r.files.bytes)})`);
|
|
1435
|
+
const topDirs = Object.entries(r.files.byTopDir).sort((a, b) => b[1] - a[1]);
|
|
1436
|
+
if (!brief && topDirs.length > 0) {
|
|
1437
|
+
for (const [d, c] of topDirs) lines.push(` ${pad(d, 24)} ${c} files`);
|
|
1438
|
+
}
|
|
1439
|
+
lines.push("");
|
|
1440
|
+
const renderSettings = (label, s) => {
|
|
1441
|
+
lines.push(label);
|
|
1442
|
+
if (!s) {
|
|
1443
|
+
lines.push(" (none)");
|
|
1444
|
+
lines.push("");
|
|
1445
|
+
return;
|
|
1446
|
+
}
|
|
1447
|
+
lines.push(` model: ${s.model ?? "(default)"}`);
|
|
1448
|
+
lines.push(` permissions: ${s.permissionMode ?? "(default)"} (allow: ${s.permissions.allow}, deny: ${s.permissions.deny}, ask: ${s.permissions.ask})`);
|
|
1449
|
+
lines.push(` env vars: ${s.envVars.length}${s.envVars.length > 0 && !brief ? ` (${s.envVars.join(", ")})` : ""}`);
|
|
1450
|
+
lines.push(` statusLine: ${s.statusLine ?? "(none)"}`);
|
|
1451
|
+
lines.push(` outputStyle: ${s.outputStyle ?? "(default)"}`);
|
|
1452
|
+
if (!brief) lines.push(` top-level: ${s.topLevelKeys.join(", ") || "(empty)"}`);
|
|
1453
|
+
lines.push("");
|
|
1454
|
+
};
|
|
1455
|
+
renderSettings("Settings (settings.json)", r.settings);
|
|
1456
|
+
if (r.settingsLocal) renderSettings("Settings (settings.local.json)", r.settingsLocal);
|
|
1457
|
+
lines.push(`Hooks (${r.hooks.length})`);
|
|
1458
|
+
if (r.hooks.length === 0) lines.push(" (none)");
|
|
1459
|
+
else if (!brief) {
|
|
1460
|
+
const byEvent = /* @__PURE__ */ new Map();
|
|
1461
|
+
for (const h of r.hooks) {
|
|
1462
|
+
if (!byEvent.has(h.event)) byEvent.set(h.event, []);
|
|
1463
|
+
byEvent.get(h.event).push(h);
|
|
1464
|
+
}
|
|
1465
|
+
for (const [event, items] of byEvent) {
|
|
1466
|
+
lines.push(` ${event} (${items.length})`);
|
|
1467
|
+
for (const h of items) lines.push(` - matcher=${h.matcher} handler=${h.handler}`);
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
lines.push("");
|
|
1471
|
+
lines.push(`MCP servers (${r.mcp.length})`);
|
|
1472
|
+
if (r.mcp.length === 0) lines.push(" (none)");
|
|
1473
|
+
else if (!brief) {
|
|
1474
|
+
for (const m of r.mcp) lines.push(` - ${pad(m.name, 28)} ${pad(m.transport, 10)} ${m.source}`);
|
|
1475
|
+
}
|
|
1476
|
+
lines.push("");
|
|
1477
|
+
lines.push(`Slash commands (${r.commands.length})`);
|
|
1478
|
+
if (!brief) for (const c of r.commands) lines.push(` /${pad(c.name, 28)} ${c.description}`);
|
|
1479
|
+
lines.push("");
|
|
1480
|
+
lines.push(`Skills (${r.skills.length})`);
|
|
1481
|
+
if (!brief) for (const s of r.skills) lines.push(` ${pad(s.name, 28)} ${s.description}`);
|
|
1482
|
+
lines.push("");
|
|
1483
|
+
lines.push(`Subagents (${r.agents.length})`);
|
|
1484
|
+
if (!brief) for (const a of r.agents) lines.push(` ${pad(a.name, 28)} ${a.description}`);
|
|
1485
|
+
lines.push("");
|
|
1486
|
+
lines.push(`Plugins (${r.plugins.length})`);
|
|
1487
|
+
if (!brief && r.plugins.length > 0) for (const p of r.plugins) lines.push(` ${p}`);
|
|
1488
|
+
lines.push("");
|
|
1489
|
+
lines.push(`Output styles (${r.outputStyles.length})`);
|
|
1490
|
+
if (!brief && r.outputStyles.length > 0) for (const o of r.outputStyles) lines.push(` ${o}`);
|
|
1491
|
+
lines.push("");
|
|
1492
|
+
lines.push(`Tracked ~/.claude.json keys (${r.trackedJsonKeys.length})`);
|
|
1493
|
+
if (!brief) for (const k of r.trackedJsonKeys) lines.push(` - ${pad(k.key, 28)} ${k.type}`);
|
|
1494
|
+
lines.push("");
|
|
1495
|
+
lines.push(`Excluded ~/.claude.json keys (${r.excludedJsonKeys.length})`);
|
|
1496
|
+
if (!r.homeJsonAvailable) {
|
|
1497
|
+
lines.push(" (~/.claude.json not readable on this machine \u2014 only meaningful on the active profile's host)");
|
|
1498
|
+
} else if (!brief) {
|
|
1499
|
+
for (const k of r.excludedJsonKeys) {
|
|
1500
|
+
lines.push(` - ${pad(k.key, 28)} ${pad(k.type, 10)} ${fmtBytes(k.bytes)}`);
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
lines.push("");
|
|
1504
|
+
lines.push(`Backups: ${r.backups.count}` + (r.backups.count > 0 ? ` (newest ${r.backups.newest}, oldest ${r.backups.oldest})` : ""));
|
|
1505
|
+
return lines.join("\n") + "\n";
|
|
1506
|
+
}
|
|
1507
|
+
function attachDescribe(program2) {
|
|
1508
|
+
program2.command("describe [name]").description("Show everything in a profile: settings, hooks, MCP, commands, skills, agents, plugins, backups").option("--json", "emit JSON", false).option("--brief", "counts only; no per-item listings", false).action(async (name, opts) => {
|
|
1509
|
+
const cfg = await loadMachineConfig(path10.join(machineHomeRoot4(), "config.json"));
|
|
1510
|
+
if (!cfg) throw new CliError("Run `claude-profile init --git <url>` first.");
|
|
1511
|
+
const profile = name ?? cfg.active;
|
|
1512
|
+
if (!profile) throw new CliError("No active profile. Pass a profile name or activate one.");
|
|
1513
|
+
const profileDir = path10.join(cfg.path, "profiles", profile);
|
|
1514
|
+
try {
|
|
1515
|
+
await fs11.stat(profileDir);
|
|
1516
|
+
} catch {
|
|
1517
|
+
throw new CliError(`Profile "${profile}" not found at ${profileDir}.`);
|
|
1518
|
+
}
|
|
1519
|
+
const { repo, profile: pyaml } = await loadCheckoutYaml(cfg.path, profile);
|
|
1520
|
+
const resolved = resolveConfig({
|
|
1521
|
+
repo,
|
|
1522
|
+
profile: pyaml,
|
|
1523
|
+
env: process.env
|
|
1524
|
+
});
|
|
1525
|
+
const report = await buildReport(cfg, profile, resolved);
|
|
1526
|
+
if (opts.json) {
|
|
1527
|
+
process.stdout.write(JSON.stringify(report, null, 2) + "\n");
|
|
1528
|
+
} else {
|
|
1529
|
+
process.stdout.write(renderHuman(report, opts.brief === true));
|
|
1530
|
+
}
|
|
1531
|
+
});
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
// src/cli/diff.ts
|
|
1535
|
+
import * as fs12 from "fs/promises";
|
|
1536
|
+
import * as path11 from "path";
|
|
1537
|
+
import * as os5 from "os";
|
|
1538
|
+
import { execa as execa3 } from "execa";
|
|
1539
|
+
function configPath() {
|
|
1540
|
+
const root = process.env.CLAUDE_PROFILE_HOME ?? path11.join(process.env.HOME ?? os5.homedir(), ".claude-profile");
|
|
1541
|
+
return path11.join(root, "config.json");
|
|
1542
|
+
}
|
|
1543
|
+
function homeRoot5() {
|
|
1544
|
+
return process.env.HOME ?? os5.homedir();
|
|
1545
|
+
}
|
|
1546
|
+
function attachDiff(program2) {
|
|
1547
|
+
program2.command("diff").description("Unified diff between home and repo for one path").argument("<path>").option("--profile <name>", "profile (defaults to active)").action(async (relPath, opts) => {
|
|
1548
|
+
const cfg = await loadMachineConfig(configPath());
|
|
1549
|
+
if (!cfg) {
|
|
1550
|
+
process.stderr.write("Run `init` first.\n");
|
|
1551
|
+
process.exit(2);
|
|
1552
|
+
}
|
|
1553
|
+
const profile = opts.profile ?? cfg.active;
|
|
1554
|
+
if (!profile) {
|
|
1555
|
+
process.stderr.write("No active profile.\n");
|
|
1556
|
+
process.exit(2);
|
|
1557
|
+
}
|
|
1558
|
+
if (relPath === "claude.json") {
|
|
1559
|
+
const home = JSON.parse(
|
|
1560
|
+
await fs12.readFile(path11.join(homeRoot5(), ".claude.json"), "utf8").catch(() => "{}")
|
|
1561
|
+
);
|
|
1562
|
+
const partial = JSON.parse(
|
|
1563
|
+
await fs12.readFile(
|
|
1564
|
+
path11.join(cfg.path, "profiles", profile, "claude.json.partial"),
|
|
1565
|
+
"utf8"
|
|
1566
|
+
).catch(() => "{}")
|
|
1567
|
+
);
|
|
1568
|
+
process.stdout.write(JSON.stringify({ home, repo: partial }, null, 2));
|
|
1569
|
+
return;
|
|
1570
|
+
}
|
|
1571
|
+
const homePath = path11.join(homeRoot5(), ".claude", relPath);
|
|
1572
|
+
const repoPath = path11.join(cfg.path, "profiles", profile, relPath);
|
|
1573
|
+
const res = await execa3("diff", ["-u", repoPath, homePath], {
|
|
1574
|
+
reject: false
|
|
1575
|
+
});
|
|
1576
|
+
if (res.exitCode === 2) {
|
|
1577
|
+
const stderr = typeof res.stderr === "string" ? res.stderr : "";
|
|
1578
|
+
process.stderr.write(stderr + "\n");
|
|
1579
|
+
process.exit(2);
|
|
1580
|
+
}
|
|
1581
|
+
const stdout = typeof res.stdout === "string" ? res.stdout : "";
|
|
1582
|
+
process.stdout.write(stdout);
|
|
1583
|
+
});
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
// src/cli/init.ts
|
|
1587
|
+
import * as fs13 from "fs/promises";
|
|
1588
|
+
import * as path12 from "path";
|
|
1589
|
+
import * as os6 from "os";
|
|
1590
|
+
function machineHomeRoot5() {
|
|
1591
|
+
return process.env.CLAUDE_PROFILE_HOME ?? path12.join(process.env.HOME ?? os6.homedir(), ".claude-profile");
|
|
1592
|
+
}
|
|
1593
|
+
function configPath2() {
|
|
1594
|
+
return path12.join(machineHomeRoot5(), "config.json");
|
|
1595
|
+
}
|
|
1596
|
+
function defaultCheckoutPath() {
|
|
1597
|
+
return path12.join(machineHomeRoot5(), "git");
|
|
1598
|
+
}
|
|
1599
|
+
function claudeHomeRoot() {
|
|
1600
|
+
return path12.join(process.env.HOME ?? os6.homedir(), ".claude");
|
|
1601
|
+
}
|
|
1602
|
+
async function isGitRepo(p) {
|
|
1603
|
+
return await git.revParse(p, "HEAD").catch(() => null) !== null || await fs13.stat(path12.join(p, ".git")).catch(() => null) !== null;
|
|
1604
|
+
}
|
|
1605
|
+
async function seedProfileFromHome(opts) {
|
|
1606
|
+
const cfg = resolveConfig({});
|
|
1607
|
+
const rules = { include: cfg.include, exclude: cfg.exclude, denylist: cfg.denylist };
|
|
1608
|
+
const profileDir = path12.join(opts.checkout, "profiles", opts.profile);
|
|
1609
|
+
await fs13.mkdir(profileDir, { recursive: true });
|
|
1610
|
+
const all = await walk(opts.claudeHome);
|
|
1611
|
+
const files = all.filter((p) => isPathInScope(p, rules));
|
|
1612
|
+
const metaFiles = {};
|
|
1613
|
+
for (const rel of files) {
|
|
1614
|
+
await copyFile2(path12.join(opts.claudeHome, rel), path12.join(profileDir, rel), { hardlink: false });
|
|
1615
|
+
const t = await statMtime(path12.join(profileDir, rel));
|
|
1616
|
+
if (t !== null) metaFiles[rel] = t;
|
|
1617
|
+
}
|
|
1618
|
+
const metaDir = path12.join(opts.checkout, ".claude-profile", opts.profile);
|
|
1619
|
+
await fs13.mkdir(metaDir, { recursive: true });
|
|
1620
|
+
const meta = { version: 1, files: metaFiles, claude_json: {} };
|
|
1621
|
+
await atomicWrite(path12.join(metaDir, "meta.json"), serializeMeta(meta));
|
|
1622
|
+
return files.length;
|
|
1623
|
+
}
|
|
1624
|
+
function attachInit(program2) {
|
|
1625
|
+
program2.command("init").description("Clone repo and register a profile on this machine").requiredOption("--git <url>", "git remote URL").option("--profile <name>", "profile name", "default").option("--path <dir>", "local clone path").option("--activate", "activate this profile after init").action(
|
|
1626
|
+
async (opts) => {
|
|
1627
|
+
const checkout = opts.path ?? defaultCheckoutPath();
|
|
1628
|
+
const cfgPath = configPath2();
|
|
1629
|
+
const existing = await loadMachineConfig(cfgPath);
|
|
1630
|
+
if (existing) {
|
|
1631
|
+
if (existing.git !== opts.git) {
|
|
1632
|
+
throw new CliError(
|
|
1633
|
+
`config already set for repo ${existing.git}; run \`claude-profile config set git ${opts.git}\` to repoint`
|
|
1634
|
+
);
|
|
1635
|
+
}
|
|
1636
|
+
if (existing.path !== checkout) {
|
|
1637
|
+
throw new CliError(
|
|
1638
|
+
`config already set for path ${existing.path}; run \`claude-profile config set path ${checkout}\``
|
|
1639
|
+
);
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
const exists = await fs13.stat(checkout).catch(() => null);
|
|
1643
|
+
if (exists && !await isGitRepo(checkout)) {
|
|
1644
|
+
const entries = await fs13.readdir(checkout);
|
|
1645
|
+
if (entries.length > 0) {
|
|
1646
|
+
throw new CliError(`checkout path ${checkout} exists and is not a git repo`);
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
if (await isGitRepo(checkout)) {
|
|
1650
|
+
await git.fetch(checkout);
|
|
1651
|
+
} else {
|
|
1652
|
+
await fs13.mkdir(path12.dirname(checkout), { recursive: true });
|
|
1653
|
+
await git.clone(opts.git, checkout);
|
|
1654
|
+
}
|
|
1655
|
+
const host = existing?.host || os6.hostname().split(".")[0] || "host";
|
|
1656
|
+
let cfg = existing ?? {
|
|
1657
|
+
git: opts.git,
|
|
1658
|
+
path: checkout,
|
|
1659
|
+
active: null,
|
|
1660
|
+
profiles: [],
|
|
1661
|
+
host
|
|
1662
|
+
};
|
|
1663
|
+
cfg = addProfile(cfg, opts.profile);
|
|
1664
|
+
if (cfg.active === null) cfg.active = opts.profile;
|
|
1665
|
+
if (opts.activate) cfg.active = opts.profile;
|
|
1666
|
+
await saveMachineConfig(cfgPath, cfg);
|
|
1667
|
+
const claudeHome = claudeHomeRoot();
|
|
1668
|
+
const profileDir = path12.join(checkout, "profiles", opts.profile);
|
|
1669
|
+
const profileExists = await fs13.stat(profileDir).catch(() => null);
|
|
1670
|
+
if (!profileExists) {
|
|
1671
|
+
const n = await seedProfileFromHome({ checkout, profile: opts.profile, claudeHome });
|
|
1672
|
+
const stateDir = path12.join(".claude-profile", opts.profile);
|
|
1673
|
+
await git.add(checkout, [path12.join("profiles", opts.profile), stateDir]);
|
|
1674
|
+
await git.commitIfStaged(checkout, `scaffold ${opts.profile} from ${cfg.host} (${n} files)`);
|
|
1675
|
+
await git.push(checkout).catch(() => {
|
|
1676
|
+
});
|
|
1677
|
+
} else if (opts.activate) {
|
|
1678
|
+
const resolved = resolveConfig({});
|
|
1679
|
+
const rules = { include: resolved.include, exclude: resolved.exclude, denylist: resolved.denylist };
|
|
1680
|
+
const allRepo = await walk(profileDir);
|
|
1681
|
+
const files = allRepo.filter((p) => isPathInScope(p, rules));
|
|
1682
|
+
for (const rel of files) {
|
|
1683
|
+
await copyFile2(path12.join(profileDir, rel), path12.join(claudeHome, rel), { hardlink: false });
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
process.stdout.write(`Initialized profile "${opts.profile}" at ${checkout}
|
|
1687
|
+
`);
|
|
1688
|
+
}
|
|
1689
|
+
);
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
// src/cli/list.ts
|
|
1693
|
+
import * as fs14 from "fs/promises";
|
|
1694
|
+
import * as path13 from "path";
|
|
1695
|
+
import * as os7 from "os";
|
|
1696
|
+
function configPath3() {
|
|
1697
|
+
const root = process.env.CLAUDE_PROFILE_HOME ?? path13.join(process.env.HOME ?? os7.homedir(), ".claude-profile");
|
|
1698
|
+
return path13.join(root, "config.json");
|
|
1699
|
+
}
|
|
1700
|
+
function attachList(program2) {
|
|
1701
|
+
program2.command("list").description("List registered profiles, mark active and repo-only profiles").action(async () => {
|
|
1702
|
+
const cfg = await loadMachineConfig(configPath3());
|
|
1703
|
+
if (!cfg) {
|
|
1704
|
+
process.stderr.write("Run `claude-profile init --git <url>` first.\n");
|
|
1705
|
+
process.exit(2);
|
|
1706
|
+
}
|
|
1707
|
+
let repoProfiles = [];
|
|
1708
|
+
try {
|
|
1709
|
+
const dirs = await fs14.readdir(path13.join(cfg.path, "profiles"), {
|
|
1710
|
+
withFileTypes: true
|
|
1711
|
+
});
|
|
1712
|
+
repoProfiles = dirs.filter((d) => d.isDirectory()).map((d) => d.name);
|
|
1713
|
+
} catch {
|
|
1714
|
+
}
|
|
1715
|
+
const lines = [];
|
|
1716
|
+
for (const name of cfg.profiles) {
|
|
1717
|
+
const tag = name === cfg.active ? "(active)" : "(registered)";
|
|
1718
|
+
lines.push(` ${name.padEnd(20)} ${tag}`);
|
|
1719
|
+
}
|
|
1720
|
+
for (const name of repoProfiles) {
|
|
1721
|
+
if (cfg.profiles.includes(name)) continue;
|
|
1722
|
+
lines.push(
|
|
1723
|
+
` ${name.padEnd(20)} (in repo, not registered on this machine \u2014 run \`init --profile ${name}\` to add)`
|
|
1724
|
+
);
|
|
1725
|
+
}
|
|
1726
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
1727
|
+
});
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
// src/cli/install-skill.ts
|
|
1731
|
+
import * as fs15 from "fs/promises";
|
|
1732
|
+
import * as path14 from "path";
|
|
1733
|
+
import * as os8 from "os";
|
|
1734
|
+
function homeRoot6() {
|
|
1735
|
+
return process.env.HOME ?? os8.homedir();
|
|
1736
|
+
}
|
|
1737
|
+
var SKILL_CONTENT = `---
|
|
1738
|
+
name: profile
|
|
1739
|
+
description: Use when the user mentions syncing/pushing/pulling Claude Code config, switching profiles, adopting current ~/.claude/ as a profile, merging profiles, inspecting profile drift, or listing profiles. Also fires when the user is about to edit files under ~/.claude/. Documents the auto-sync behavior so Claude does not double-trigger syncs.
|
|
1740
|
+
---
|
|
1741
|
+
|
|
1742
|
+
# /profile \u2014 claude-profile CLI
|
|
1743
|
+
|
|
1744
|
+
\`claude-profile\` (npm package) syncs ~/.claude/ across machines via a shared git repo
|
|
1745
|
+
that holds multiple named profiles. Each machine has one active profile reflected
|
|
1746
|
+
into ~/.claude/ and can switch between any profile it has registered.
|
|
1747
|
+
|
|
1748
|
+
## Auto-sync is already wired up
|
|
1749
|
+
|
|
1750
|
+
Two hooks bracket every Claude session:
|
|
1751
|
+
|
|
1752
|
+
- **SessionStart** runs \`claude-profile sync --pull\` (synchronous, 3 s timeout) \u2014 fast-forwards from the remote and materializes any new commits into ~/.claude/ before the session starts.
|
|
1753
|
+
- **Stop** runs \`claude-profile sync --push\` (background) \u2014 diffs ~/.claude/ against the repo and, if anything in the tracked paths changed, commits and pushes.
|
|
1754
|
+
|
|
1755
|
+
You do not need to manually invoke sync after editing skills, agents, hooks, settings, etc. during a session \u2014 the Stop hook will pick it up. The only time to run \`sync\` by hand is when the user edited config *outside* a Claude session, or explicitly asks for an immediate push/pull.
|
|
1756
|
+
|
|
1757
|
+
## When to run sync manually
|
|
1758
|
+
|
|
1759
|
+
- The user explicitly asked ("sync my profile", "push my claude config").
|
|
1760
|
+
- The user is about to switch machines and wants a clean push before logging off.
|
|
1761
|
+
- After resolving a conflict file under \`profiles/<active>/.conflicts/\`.
|
|
1762
|
+
|
|
1763
|
+
## Commands
|
|
1764
|
+
|
|
1765
|
+
| Command | Purpose |
|
|
1766
|
+
|---|---|
|
|
1767
|
+
| \`claude-profile sync\` | Full cycle: pull, merge, commit, push (active profile). |
|
|
1768
|
+
| \`claude-profile status\` | Show what sync would do; no writes. |
|
|
1769
|
+
| \`claude-profile diff <path>\` | Unified diff between home and repo for one path. |
|
|
1770
|
+
| \`claude-profile use <name>\` | Switch active profile (rewrites ~/.claude/). |
|
|
1771
|
+
| \`claude-profile adopt <name> [--overwrite] [--switch]\` | Snapshot current ~/.claude/ into \`profiles/<name>/\`. |
|
|
1772
|
+
| \`claude-profile merge <source> --into <dest> [--strategy \u2026]\` | Best-effort merge of one profile into another. |
|
|
1773
|
+
| \`claude-profile list\` | Show all profiles in the repo. |
|
|
1774
|
+
| \`claude-profile init --git <url> [--profile <name>]\` | First-time setup, or add a new profile. |
|
|
1775
|
+
| \`claude-profile config get/set/unset\` | Per-machine config. |
|
|
1776
|
+
|
|
1777
|
+
## Conflicts
|
|
1778
|
+
|
|
1779
|
+
If both machines edited the same file between syncs, the newer is kept and the
|
|
1780
|
+
loser is written to \`profiles/<active>/.conflicts/<path>.<host>.<ts>\`.
|
|
1781
|
+
|
|
1782
|
+
## Scope
|
|
1783
|
+
|
|
1784
|
+
Synced: settings.json, agents/, skills/, hooks/, statusline, CLAUDE.md, plugin
|
|
1785
|
+
install list, marketplaces metadata, and selective ~/.claude.json keys.
|
|
1786
|
+
|
|
1787
|
+
Not synced: credentials, caches, sessions, daemon state, project history,
|
|
1788
|
+
oauthAccount, plugin code.
|
|
1789
|
+
`;
|
|
1790
|
+
function hookEntry(id, command, timeoutSec) {
|
|
1791
|
+
const inner = { type: "command", command };
|
|
1792
|
+
if (timeoutSec !== void 0) inner.timeout_seconds = timeoutSec;
|
|
1793
|
+
return { id, hooks: [inner] };
|
|
1794
|
+
}
|
|
1795
|
+
async function mergeHooks(claudeHome, withHooks) {
|
|
1796
|
+
const settingsPath = path14.join(claudeHome, "settings.json");
|
|
1797
|
+
const raw = await fs15.readFile(settingsPath, "utf8").catch(() => "{}");
|
|
1798
|
+
const cfg = JSON.parse(raw);
|
|
1799
|
+
cfg.hooks = cfg.hooks ?? {};
|
|
1800
|
+
cfg.hooks.SessionStart = cfg.hooks.SessionStart ?? [];
|
|
1801
|
+
cfg.hooks.Stop = cfg.hooks.Stop ?? [];
|
|
1802
|
+
if (!withHooks) {
|
|
1803
|
+
cfg.hooks.SessionStart = cfg.hooks.SessionStart.filter((e) => e.id !== "claude-profile-pull");
|
|
1804
|
+
cfg.hooks.Stop = cfg.hooks.Stop.filter((e) => e.id !== "claude-profile-push");
|
|
1805
|
+
} else {
|
|
1806
|
+
const resolved = resolveConfig({});
|
|
1807
|
+
const pullCmd = `timeout ${resolved.autosync.pull_timeout_seconds} ${resolved.autosync.pull_command} || true`;
|
|
1808
|
+
const pullId = resolved.autosync.hook_id_pull;
|
|
1809
|
+
const pushCmd = resolved.autosync.push_command;
|
|
1810
|
+
const pushId = resolved.autosync.hook_id_push;
|
|
1811
|
+
cfg.hooks.SessionStart = cfg.hooks.SessionStart.filter((e) => e.id !== pullId);
|
|
1812
|
+
cfg.hooks.SessionStart.push(hookEntry(pullId, pullCmd, resolved.autosync.pull_timeout_seconds));
|
|
1813
|
+
cfg.hooks.Stop = cfg.hooks.Stop.filter((e) => e.id !== pushId);
|
|
1814
|
+
cfg.hooks.Stop.push(hookEntry(pushId, pushCmd));
|
|
1815
|
+
}
|
|
1816
|
+
await atomicWrite(settingsPath, JSON.stringify(cfg, null, 2) + "\n");
|
|
1817
|
+
}
|
|
1818
|
+
function attachInstallSkill(program2) {
|
|
1819
|
+
program2.command("install-skill").description("Install the /profile skill + SessionStart/Stop hooks").option("--no-hook", "skip hook installation").action(async (opts) => {
|
|
1820
|
+
const claudeHome = path14.join(homeRoot6(), ".claude");
|
|
1821
|
+
const skillPath = path14.join(claudeHome, "skills", "profile", "SKILL.md");
|
|
1822
|
+
await atomicWrite(skillPath, SKILL_CONTENT);
|
|
1823
|
+
await mergeHooks(claudeHome, opts.hook !== false);
|
|
1824
|
+
process.stdout.write(`Installed skill at ${skillPath}
|
|
1825
|
+
`);
|
|
1826
|
+
});
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
// src/cli/merge.ts
|
|
1830
|
+
import * as fs16 from "fs/promises";
|
|
1831
|
+
import * as path15 from "path";
|
|
1832
|
+
import * as os9 from "os";
|
|
1833
|
+
function homeRoot7() {
|
|
1834
|
+
return process.env.HOME ?? os9.homedir();
|
|
1835
|
+
}
|
|
1836
|
+
function machineHomeRoot6() {
|
|
1837
|
+
return process.env.CLAUDE_PROFILE_HOME ?? path15.join(homeRoot7(), ".claude-profile");
|
|
1838
|
+
}
|
|
1839
|
+
function attachMerge(program2) {
|
|
1840
|
+
program2.command("merge").description("Best-effort merge of one profile into another").argument("<source>").requiredOption("--into <dest>").option("--strategy <s>", "prefer-dest|prefer-source|newer|mark-conflicts", "prefer-dest").option("--dry-run", "print plan; no writes", false).option("--no-apply", "skip post-merge sync into ~/.claude/ when dest is active", false).option("--no-push", "do not push after commit", false).action(async (source, opts) => {
|
|
1841
|
+
const cfg = await loadMachineConfig(path15.join(machineHomeRoot6(), "config.json"));
|
|
1842
|
+
if (!cfg) throw new CliError("Run `init` first.");
|
|
1843
|
+
const checkout = cfg.path;
|
|
1844
|
+
const srcDir = path15.join(checkout, "profiles", source);
|
|
1845
|
+
const dstDir = path15.join(checkout, "profiles", opts.into);
|
|
1846
|
+
const srcExists = await fs16.stat(srcDir).catch(() => null);
|
|
1847
|
+
const dstExists = await fs16.stat(dstDir).catch(() => null);
|
|
1848
|
+
if (!srcExists) throw new CliError(`source profile "${source}" not found in repo`);
|
|
1849
|
+
if (!dstExists) throw new CliError(`dest profile "${opts.into}" not found in repo`);
|
|
1850
|
+
const readYaml = async (p) => {
|
|
1851
|
+
try {
|
|
1852
|
+
return parseYamlConfig(await fs16.readFile(p, "utf8"));
|
|
1853
|
+
} catch {
|
|
1854
|
+
return {};
|
|
1855
|
+
}
|
|
1856
|
+
};
|
|
1857
|
+
const repoYaml = await readYaml(path15.join(checkout, "claude-profile.yaml"));
|
|
1858
|
+
const dstYaml = await readYaml(path15.join(dstDir, "claude-profile.yaml"));
|
|
1859
|
+
const resolved = resolveConfig({
|
|
1860
|
+
repo: repoYaml,
|
|
1861
|
+
profile: dstYaml
|
|
1862
|
+
});
|
|
1863
|
+
const rules = { include: resolved.include, exclude: resolved.exclude, denylist: resolved.denylist };
|
|
1864
|
+
const srcFiles = await walk(srcDir);
|
|
1865
|
+
const dstFiles = await walk(dstDir);
|
|
1866
|
+
const union = /* @__PURE__ */ new Set([...srcFiles, ...dstFiles]);
|
|
1867
|
+
const items = [];
|
|
1868
|
+
for (const rel of union) {
|
|
1869
|
+
if (rel === "claude-profile.yaml") continue;
|
|
1870
|
+
if (rel === resolved.claude_json.partial_filename) continue;
|
|
1871
|
+
if (rel.startsWith(".conflicts/")) continue;
|
|
1872
|
+
const inScope = isPathInScope(rel, rules);
|
|
1873
|
+
if (!inScope) {
|
|
1874
|
+
if (srcFiles.includes(rel) && !dstFiles.includes(rel)) {
|
|
1875
|
+
items.push({ rel, classification: "out-of-scope", srcMtime: null, dstMtime: null });
|
|
1876
|
+
}
|
|
1877
|
+
continue;
|
|
1878
|
+
}
|
|
1879
|
+
const srcMt = srcFiles.includes(rel) ? await statMtime(path15.join(srcDir, rel)) : null;
|
|
1880
|
+
const dstMt = dstFiles.includes(rel) ? await statMtime(path15.join(dstDir, rel)) : null;
|
|
1881
|
+
let cls;
|
|
1882
|
+
if (srcMt !== null && dstMt === null) cls = "source-only";
|
|
1883
|
+
else if (srcMt === null && dstMt !== null) cls = "dest-only";
|
|
1884
|
+
else {
|
|
1885
|
+
const srcSha = await sha256File(path15.join(srcDir, rel));
|
|
1886
|
+
const dstSha = await sha256File(path15.join(dstDir, rel));
|
|
1887
|
+
cls = srcSha === dstSha ? "identical" : "conflict";
|
|
1888
|
+
}
|
|
1889
|
+
items.push({ rel, classification: cls, srcMtime: srcMt, dstMtime: dstMt });
|
|
1890
|
+
}
|
|
1891
|
+
const counts = { "source-only": 0, "dest-only": 0, "identical": 0, "conflict": 0, "out-of-scope": 0 };
|
|
1892
|
+
for (const it of items) counts[it.classification]++;
|
|
1893
|
+
const outOfScopePaths = items.filter((i) => i.classification === "out-of-scope").map((i) => i.rel);
|
|
1894
|
+
if (opts.dryRun) {
|
|
1895
|
+
process.stdout.write(`Plan: merge ${source} \u2192 ${opts.into} (strategy: ${opts.strategy})
|
|
1896
|
+
`);
|
|
1897
|
+
for (const [k, n] of Object.entries(counts)) process.stdout.write(` ${k}: ${n}
|
|
1898
|
+
`);
|
|
1899
|
+
for (const p of outOfScopePaths) process.stdout.write(` out-of-scope: ${p}
|
|
1900
|
+
`);
|
|
1901
|
+
return;
|
|
1902
|
+
}
|
|
1903
|
+
let conflictCount = 0;
|
|
1904
|
+
for (const it of items) {
|
|
1905
|
+
if (it.classification === "out-of-scope") {
|
|
1906
|
+
process.stderr.write(`skipped: ${it.rel} \u2014 not in ${opts.into}'s include rules
|
|
1907
|
+
`);
|
|
1908
|
+
continue;
|
|
1909
|
+
}
|
|
1910
|
+
if (it.classification === "identical" || it.classification === "dest-only") continue;
|
|
1911
|
+
if (it.classification === "source-only") {
|
|
1912
|
+
await copyFile2(path15.join(srcDir, it.rel), path15.join(dstDir, it.rel), { hardlink: false });
|
|
1913
|
+
continue;
|
|
1914
|
+
}
|
|
1915
|
+
switch (opts.strategy) {
|
|
1916
|
+
case "prefer-dest":
|
|
1917
|
+
break;
|
|
1918
|
+
case "prefer-source":
|
|
1919
|
+
await copyFile2(path15.join(srcDir, it.rel), path15.join(dstDir, it.rel), { hardlink: false });
|
|
1920
|
+
break;
|
|
1921
|
+
case "newer": {
|
|
1922
|
+
const useSrc = (it.srcMtime ?? 0) > (it.dstMtime ?? 0);
|
|
1923
|
+
if (useSrc) await copyFile2(path15.join(srcDir, it.rel), path15.join(dstDir, it.rel), { hardlink: false });
|
|
1924
|
+
break;
|
|
1925
|
+
}
|
|
1926
|
+
case "mark-conflicts": {
|
|
1927
|
+
conflictCount++;
|
|
1928
|
+
const dest = path15.join(dstDir, ".conflicts", `${it.rel}.${source}.${realTime.iso()}`);
|
|
1929
|
+
await copyFile2(path15.join(srcDir, it.rel), dest, { hardlink: false });
|
|
1930
|
+
break;
|
|
1931
|
+
}
|
|
1932
|
+
}
|
|
1933
|
+
}
|
|
1934
|
+
const srcPartialPath = path15.join(srcDir, resolved.claude_json.partial_filename);
|
|
1935
|
+
const dstPartialPath = path15.join(dstDir, resolved.claude_json.partial_filename);
|
|
1936
|
+
const srcPartial = JSON.parse(await fs16.readFile(srcPartialPath, "utf8").catch(() => "{}"));
|
|
1937
|
+
const dstPartial = JSON.parse(await fs16.readFile(dstPartialPath, "utf8").catch(() => "{}"));
|
|
1938
|
+
const merged = { ...dstPartial };
|
|
1939
|
+
for (const k of Object.keys(srcPartial)) {
|
|
1940
|
+
if (!(k in merged)) merged[k] = srcPartial[k];
|
|
1941
|
+
else if (opts.strategy === "prefer-source") merged[k] = srcPartial[k];
|
|
1942
|
+
else if (opts.strategy === "mark-conflicts" && JSON.stringify(merged[k]) !== JSON.stringify(srcPartial[k])) {
|
|
1943
|
+
const marker = path15.join(dstDir, ".conflicts", `${resolved.claude_json.partial_filename}.${k}.${source}.${realTime.iso()}.json`);
|
|
1944
|
+
await atomicWrite(marker, JSON.stringify(srcPartial[k], null, 2) + "\n");
|
|
1945
|
+
conflictCount++;
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1948
|
+
if (Object.keys(merged).length > 0) {
|
|
1949
|
+
await atomicWrite(dstPartialPath, JSON.stringify(merged, null, 2) + "\n");
|
|
1950
|
+
}
|
|
1951
|
+
const metaPath = path15.join(checkout, ".claude-profile", opts.into, "meta.json");
|
|
1952
|
+
const meta = await fs16.readFile(metaPath, "utf8").then(parseMeta).catch(() => emptyMeta());
|
|
1953
|
+
const newFiles = { ...meta.files };
|
|
1954
|
+
const allFiles = await walk(dstDir);
|
|
1955
|
+
for (const rel of allFiles) {
|
|
1956
|
+
if (rel.startsWith(".conflicts/")) continue;
|
|
1957
|
+
if (rel === "claude-profile.yaml") continue;
|
|
1958
|
+
if (rel === resolved.claude_json.partial_filename) continue;
|
|
1959
|
+
const mt = await statMtime(path15.join(dstDir, rel));
|
|
1960
|
+
if (mt !== null) newFiles[rel] = mt;
|
|
1961
|
+
}
|
|
1962
|
+
await atomicWrite(metaPath, serializeMeta({ ...meta, files: newFiles }));
|
|
1963
|
+
const relP = path15.join("profiles", opts.into);
|
|
1964
|
+
const relS = path15.join(".claude-profile", opts.into);
|
|
1965
|
+
await git.add(checkout, [relP, relS]);
|
|
1966
|
+
let msg = `merge ${source} into ${opts.into} (${opts.strategy}) from ${cfg.host} at ${realTime.iso()}`;
|
|
1967
|
+
if (conflictCount > 0) msg += ` [${conflictCount} conflict(s)]`;
|
|
1968
|
+
await git.commitIfStaged(checkout, msg);
|
|
1969
|
+
if (opts.push !== false) await git.push(checkout).catch(() => {
|
|
1970
|
+
});
|
|
1971
|
+
if (opts.into === cfg.active && opts.apply !== false) {
|
|
1972
|
+
const { execa: execa5 } = await import("execa");
|
|
1973
|
+
await execa5("npx", ["tsx", "src/bin/claude-profile.ts", "sync"], { reject: false });
|
|
1974
|
+
}
|
|
1975
|
+
process.stdout.write(`Merged ${source} \u2192 ${opts.into} (strategy: ${opts.strategy})
|
|
1976
|
+
`);
|
|
1977
|
+
for (const [k, n] of Object.entries(counts)) process.stdout.write(` ${k}: ${n}
|
|
1978
|
+
`);
|
|
1979
|
+
if (opts.strategy === "mark-conflicts" && conflictCount > 0) process.exit(1);
|
|
1980
|
+
});
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
// src/cli/status.ts
|
|
1984
|
+
import * as path16 from "path";
|
|
1985
|
+
import * as os10 from "os";
|
|
1986
|
+
function machineHomeRoot7() {
|
|
1987
|
+
return process.env.CLAUDE_PROFILE_HOME ?? path16.join(process.env.HOME ?? os10.homedir(), ".claude-profile");
|
|
1988
|
+
}
|
|
1989
|
+
function attachStatus(program2) {
|
|
1990
|
+
program2.command("status").description("Show what sync would do; no writes").option("--profile <name>").action(async (opts) => {
|
|
1991
|
+
const cfg = await loadMachineConfig(path16.join(machineHomeRoot7(), "config.json"));
|
|
1992
|
+
if (!cfg) throw new CliError("Run `init` first.");
|
|
1993
|
+
const profile = opts.profile ?? cfg.active;
|
|
1994
|
+
if (!profile) throw new CliError("No active profile.");
|
|
1995
|
+
const { repo, profile: pyaml } = await loadCheckoutYaml(cfg.path, profile);
|
|
1996
|
+
const resolved = resolveConfig({
|
|
1997
|
+
repo,
|
|
1998
|
+
profile: pyaml,
|
|
1999
|
+
env: process.env
|
|
2000
|
+
});
|
|
2001
|
+
const plan = await planSync(cfg, resolved, profile);
|
|
2002
|
+
for (const item of plan) {
|
|
2003
|
+
if (item.decision.kind === "noop") continue;
|
|
2004
|
+
const label = item.decision.kind.replace("copy-home-to-repo", "home\u2192repo").replace("copy-repo-to-home", "repo\u2192home");
|
|
2005
|
+
process.stdout.write(`${label.padEnd(14)} ${item.rel}
|
|
2006
|
+
`);
|
|
2007
|
+
}
|
|
2008
|
+
});
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
// src/cli/use.ts
|
|
2012
|
+
import * as fs17 from "fs/promises";
|
|
2013
|
+
import * as path17 from "path";
|
|
2014
|
+
import * as os11 from "os";
|
|
2015
|
+
import { execa as execa4 } from "execa";
|
|
2016
|
+
function homeRoot8() {
|
|
2017
|
+
return process.env.HOME ?? os11.homedir();
|
|
2018
|
+
}
|
|
2019
|
+
function machineHomeRoot8() {
|
|
2020
|
+
return process.env.CLAUDE_PROFILE_HOME ?? path17.join(homeRoot8(), ".claude-profile");
|
|
2021
|
+
}
|
|
2022
|
+
function attachUse(program2) {
|
|
2023
|
+
program2.command("use").description("Switch active profile (always backs up first)").argument("<name>").action(async (name) => {
|
|
2024
|
+
const cfgPath = path17.join(machineHomeRoot8(), "config.json");
|
|
2025
|
+
const cfg = await loadMachineConfig(cfgPath);
|
|
2026
|
+
if (!cfg) throw new CliError("Run `init` first.");
|
|
2027
|
+
if (!cfg.profiles.includes(name)) {
|
|
2028
|
+
throw new CliError(
|
|
2029
|
+
`profile "${name}" is not registered on this machine; run \`claude-profile init --profile ${name} --activate\``
|
|
2030
|
+
);
|
|
2031
|
+
}
|
|
2032
|
+
const outgoing = cfg.active;
|
|
2033
|
+
const claudeHome = path17.join(homeRoot8(), ".claude");
|
|
2034
|
+
if (outgoing) {
|
|
2035
|
+
await execa4("npx", ["tsx", "src/bin/claude-profile.ts", "sync"], {
|
|
2036
|
+
env: process.env,
|
|
2037
|
+
cwd: process.cwd(),
|
|
2038
|
+
reject: false
|
|
2039
|
+
});
|
|
2040
|
+
}
|
|
2041
|
+
const readYaml = async (p) => {
|
|
2042
|
+
try {
|
|
2043
|
+
return parseYamlConfig(await fs17.readFile(p, "utf8"));
|
|
2044
|
+
} catch {
|
|
2045
|
+
return {};
|
|
2046
|
+
}
|
|
2047
|
+
};
|
|
2048
|
+
const repoYaml = await readYaml(path17.join(cfg.path, "claude-profile.yaml"));
|
|
2049
|
+
const outgoingYaml = outgoing ? await readYaml(path17.join(cfg.path, "profiles", outgoing, "claude-profile.yaml")) : {};
|
|
2050
|
+
const incomingYaml = await readYaml(
|
|
2051
|
+
path17.join(cfg.path, "profiles", name, "claude-profile.yaml")
|
|
2052
|
+
);
|
|
2053
|
+
const outgoingCfg = resolveConfig({
|
|
2054
|
+
repo: repoYaml,
|
|
2055
|
+
profile: outgoingYaml
|
|
2056
|
+
});
|
|
2057
|
+
const incomingCfg = resolveConfig({
|
|
2058
|
+
repo: repoYaml,
|
|
2059
|
+
profile: incomingYaml
|
|
2060
|
+
});
|
|
2061
|
+
const backupRoot = outgoingCfg.backups.dir.replace(/^~/, homeRoot8());
|
|
2062
|
+
const backupProfile = outgoing ?? "__preexisting__";
|
|
2063
|
+
const outgoingRules = {
|
|
2064
|
+
include: outgoingCfg.include,
|
|
2065
|
+
exclude: outgoingCfg.exclude,
|
|
2066
|
+
denylist: outgoingCfg.denylist
|
|
2067
|
+
};
|
|
2068
|
+
await snapshotBackup({
|
|
2069
|
+
home: claudeHome,
|
|
2070
|
+
backupRoot,
|
|
2071
|
+
profile: backupProfile,
|
|
2072
|
+
rules: outgoingRules,
|
|
2073
|
+
isoTs: realTime.iso(),
|
|
2074
|
+
useHardlinks: outgoingCfg.backups.use_hardlinks
|
|
2075
|
+
});
|
|
2076
|
+
const incomingRules = {
|
|
2077
|
+
include: incomingCfg.include,
|
|
2078
|
+
exclude: incomingCfg.exclude,
|
|
2079
|
+
denylist: incomingCfg.denylist
|
|
2080
|
+
};
|
|
2081
|
+
if (outgoing) {
|
|
2082
|
+
const homePaths = await walk(claudeHome);
|
|
2083
|
+
for (const rel of homePaths) {
|
|
2084
|
+
if (isPathInScope(rel, outgoingRules) && !isPathInScope(rel, incomingRules)) {
|
|
2085
|
+
await fs17.rm(path17.join(claudeHome, rel), { force: true });
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
const incomingProfileDir = path17.join(cfg.path, "profiles", name);
|
|
2090
|
+
const incomingFiles = (await walk(incomingProfileDir)).filter(
|
|
2091
|
+
(p) => isPathInScope(p, incomingRules)
|
|
2092
|
+
);
|
|
2093
|
+
for (const rel of incomingFiles) {
|
|
2094
|
+
await copyFile2(
|
|
2095
|
+
path17.join(incomingProfileDir, rel),
|
|
2096
|
+
path17.join(claudeHome, rel),
|
|
2097
|
+
{ hardlink: false }
|
|
2098
|
+
);
|
|
2099
|
+
}
|
|
2100
|
+
const partialPath = path17.join(
|
|
2101
|
+
incomingProfileDir,
|
|
2102
|
+
incomingCfg.claude_json.partial_filename
|
|
2103
|
+
);
|
|
2104
|
+
const claudeJsonPath = incomingCfg.claude_json.path.replace(/^~/, homeRoot8());
|
|
2105
|
+
let partialText;
|
|
2106
|
+
try {
|
|
2107
|
+
partialText = await fs17.readFile(partialPath, "utf8");
|
|
2108
|
+
} catch (err) {
|
|
2109
|
+
if (err.code === "ENOENT") {
|
|
2110
|
+
partialText = "";
|
|
2111
|
+
} else {
|
|
2112
|
+
throw err;
|
|
2113
|
+
}
|
|
2114
|
+
}
|
|
2115
|
+
if (partialText !== "") {
|
|
2116
|
+
const partial = JSON.parse(partialText);
|
|
2117
|
+
const homeText = await fs17.readFile(claudeJsonPath, "utf8").catch((err) => {
|
|
2118
|
+
if (err.code === "ENOENT") return "{}";
|
|
2119
|
+
throw err;
|
|
2120
|
+
});
|
|
2121
|
+
const homeJson = JSON.parse(homeText);
|
|
2122
|
+
for (const k of incomingCfg.claude_json.include_keys) {
|
|
2123
|
+
if (k in partial) homeJson[k] = partial[k];
|
|
2124
|
+
}
|
|
2125
|
+
await atomicWrite(claudeJsonPath, JSON.stringify(homeJson, null, 2) + "\n");
|
|
2126
|
+
}
|
|
2127
|
+
const newCfg = setActive(cfg, name);
|
|
2128
|
+
await saveMachineConfig(cfgPath, newCfg);
|
|
2129
|
+
const metaPath = path17.join(cfg.path, ".claude-profile", name, "meta.json");
|
|
2130
|
+
const meta = await fs17.readFile(metaPath, "utf8").then(parseMeta).catch(() => emptyMeta());
|
|
2131
|
+
const newFiles = {};
|
|
2132
|
+
for (const rel of incomingFiles) {
|
|
2133
|
+
const st = await fs17.stat(path17.join(claudeHome, rel));
|
|
2134
|
+
newFiles[rel] = Math.floor(st.mtimeMs);
|
|
2135
|
+
}
|
|
2136
|
+
await atomicWrite(metaPath, serializeMeta({ ...meta, files: newFiles }));
|
|
2137
|
+
let prunedCount = 0;
|
|
2138
|
+
if (incomingCfg.backups.auto_prune_on_use) {
|
|
2139
|
+
const pruned = await pruneBackups(
|
|
2140
|
+
backupRoot,
|
|
2141
|
+
backupProfile,
|
|
2142
|
+
incomingCfg.backups.keep_per_profile
|
|
2143
|
+
);
|
|
2144
|
+
prunedCount = pruned.length;
|
|
2145
|
+
}
|
|
2146
|
+
process.stdout.write(
|
|
2147
|
+
`Switched to profile ${name}. Backup saved at ${backupRoot}/${backupProfile}/.${prunedCount ? ` Pruned ${prunedCount} older backups.` : ""}
|
|
2148
|
+
`
|
|
2149
|
+
);
|
|
2150
|
+
});
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
// src/cli/index.ts
|
|
2154
|
+
function readPackageVersion() {
|
|
2155
|
+
try {
|
|
2156
|
+
const url = new URL("../../package.json", import.meta.url);
|
|
2157
|
+
const pkg = JSON.parse(readFileSync(fileURLToPath(url), "utf8"));
|
|
2158
|
+
return pkg.version ?? "0.0.0-dev";
|
|
2159
|
+
} catch {
|
|
2160
|
+
return "0.0.0-dev";
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
2163
|
+
function buildProgram() {
|
|
2164
|
+
const program2 = new Command();
|
|
2165
|
+
program2.name("claude-profile").description("Sync ~/.claude/ across machines via git-backed profiles").version(readPackageVersion());
|
|
2166
|
+
attachInit(program2);
|
|
2167
|
+
attachSync(program2);
|
|
2168
|
+
attachStatus(program2);
|
|
2169
|
+
attachDiff(program2);
|
|
2170
|
+
attachUse(program2);
|
|
2171
|
+
attachBackups(program2);
|
|
2172
|
+
attachList(program2);
|
|
2173
|
+
attachConfig(program2);
|
|
2174
|
+
attachAdopt(program2);
|
|
2175
|
+
attachMerge(program2);
|
|
2176
|
+
attachDescribe(program2);
|
|
2177
|
+
attachInstallSkill(program2);
|
|
2178
|
+
program2.command("autosync").description("Internal: deprecated; hook now invokes sync --pull/--push").action(() => {
|
|
2179
|
+
process.stdout.write("autosync is deprecated; the hook now calls sync --pull/--push directly\n");
|
|
2180
|
+
});
|
|
2181
|
+
return program2;
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2184
|
+
// src/bin/claude-profile.ts
|
|
2185
|
+
var program = buildProgram();
|
|
2186
|
+
program.parseAsync(process.argv).catch((err) => {
|
|
2187
|
+
process.stderr.write(`${err.message}
|
|
2188
|
+
`);
|
|
2189
|
+
process.exit(2);
|
|
2190
|
+
});
|