cchost 0.3.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/share.js CHANGED
@@ -5,11 +5,34 @@ const path = require('path');
5
5
 
6
6
  const profiles = require('./profiles');
7
7
 
8
- // Artifact directories that are safe to share across profiles via junctions/symlinks.
9
- // sessions/ is transient per-process registry state (PID, cwd, status, timestamps) and
10
- // must stay per-profile — sharing it would corrupt Claude's running-process bookkeeping.
11
- // plugins/ carries auth tokens and is intentionally excluded (future opt-in).
12
- const SHARED_SUBDIRS = ['projects', 'skills', 'agents', 'commands'];
8
+ // v0.4.1: every profile shares 7 resources with ~/.claude/ (the pool).
9
+ // - 4 directories: projects/, skills/, agents/, commands/
10
+ // - 3 single files: mcp.json, settings.json, CLAUDE.md
11
+ // Each resource is a symlink (junction on Windows for dirs; file symlink for
12
+ // files — Developer Mode required on Windows or it's skipped with a warning).
13
+ //
14
+ // Resources are grouped into 3 buckets so callers can target a subset:
15
+ // --projects → projects/
16
+ // --tools → skills/, agents/, commands/
17
+ // --config → mcp.json, settings.json, CLAUDE.md
18
+ //
19
+ // sessions/ is transient per-process state — never shared.
20
+ // plugins/ carries auth tokens — never shared.
21
+ // .credentials.json, .claude.json, .cc-env.json stay per-profile (identity).
22
+ const PROJECT_SUBDIRS = ['projects'];
23
+ const TOOL_SUBDIRS = ['skills', 'agents', 'commands'];
24
+ const CONFIG_FILES = ['mcp.json', 'settings.json', 'CLAUDE.md'];
25
+
26
+ const SHARED_DIRS = [...PROJECT_SUBDIRS, ...TOOL_SUBDIRS];
27
+ const SHARED_RESOURCES = [...SHARED_DIRS, ...CONFIG_FILES];
28
+
29
+ // Legacy alias — older callers / tests expect the name SHARED_SUBDIRS for the
30
+ // 4-dir set. Kept exported so we don't break them.
31
+ const SHARED_SUBDIRS = SHARED_DIRS;
32
+
33
+ function isFileResource(name) {
34
+ return CONFIG_FILES.includes(name);
35
+ }
13
36
 
14
37
  function symlinkType() {
15
38
  return process.platform === 'win32' ? 'junction' : 'dir';
@@ -23,6 +46,45 @@ function isLink(p) {
23
46
  }
24
47
  }
25
48
 
49
+ // True if `linkPath` is currently sharing `targetPath` — either via a symlink
50
+ // that resolves to it, or (Windows fallback only) via a hard link to the same
51
+ // inode. Used for "Shared: N/7" counting and for the doctor check.
52
+ function isSharedResource(profileDir, resource) {
53
+ const linkPath = path.join(profileDir, resource);
54
+ const targetPath = path.join(poolDir(), resource);
55
+ let lstat;
56
+ try {
57
+ lstat = fs.lstatSync(linkPath);
58
+ } catch {
59
+ return false;
60
+ }
61
+ if (lstat.isSymbolicLink()) {
62
+ try {
63
+ return fs.realpathSync(linkPath) === fs.realpathSync(targetPath);
64
+ } catch {
65
+ return false;
66
+ }
67
+ }
68
+ // Hard link fallback: a file resource may have been hard-linked on Windows
69
+ // when symlink creation failed. Compare inodes.
70
+ if (lstat.isFile() && isFileResource(resource)) {
71
+ try {
72
+ const targetStat = fs.statSync(targetPath);
73
+ return (
74
+ lstat.ino !== 0 &&
75
+ lstat.ino === targetStat.ino &&
76
+ lstat.dev === targetStat.dev
77
+ );
78
+ } catch {
79
+ return false;
80
+ }
81
+ }
82
+ return false;
83
+ }
84
+
85
+ // Windows junctions are removed via rmdir, not unlink. POSIX symlinks via
86
+ // unlink. File symlinks via unlink on both platforms. removeLink tries unlink
87
+ // first and falls back to rmdir on Windows. The link target is untouched.
26
88
  function removeLink(p) {
27
89
  try {
28
90
  fs.unlinkSync(p);
@@ -37,80 +99,160 @@ function copyRecursive(src, dst) {
37
99
  fs.cpSync(src, dst, { recursive: true, verbatimSymlinks: true });
38
100
  }
39
101
 
40
- function ensureSharedDirs() {
41
- const base = profiles.sharedDir();
42
- for (const sub of SHARED_SUBDIRS) {
102
+ // Resolve which resources to operate on. Default (no flags) = all 7.
103
+ function resolveSubset({ tools, projects, config } = {}) {
104
+ const any = tools || projects || config;
105
+ if (!any) return SHARED_RESOURCES.slice();
106
+ const out = [];
107
+ if (projects) out.push(...PROJECT_SUBDIRS);
108
+ if (tools) out.push(...TOOL_SUBDIRS);
109
+ if (config) out.push(...CONFIG_FILES);
110
+ return out;
111
+ }
112
+
113
+ function poolDir() {
114
+ return profiles.defaultClaudeDir();
115
+ }
116
+
117
+ // Make sure the pool side of each requested resource exists. Dirs are mkdir'd;
118
+ // files get an empty placeholder if missing so the symlink has something to
119
+ // point at.
120
+ function ensurePoolResources(subset = SHARED_RESOURCES) {
121
+ const base = poolDir();
122
+ try {
123
+ fs.mkdirSync(base, { recursive: true });
124
+ } catch (err) {
125
+ throw new Error(`Cannot create ${base}: ${err.message}`);
126
+ }
127
+ for (const resource of subset) {
128
+ const target = path.join(base, resource);
43
129
  try {
44
- fs.mkdirSync(path.join(base, sub), { recursive: true });
130
+ if (isFileResource(resource)) {
131
+ if (!fs.existsSync(target)) fs.writeFileSync(target, '');
132
+ } else {
133
+ fs.mkdirSync(target, { recursive: true });
134
+ }
45
135
  } catch (err) {
46
- throw new Error(`Cannot create ${path.join(base, sub)}: ${err.message}`);
136
+ throw new Error(`Cannot create ${target}: ${err.message}`);
47
137
  }
48
138
  }
49
139
  return base;
50
140
  }
51
141
 
52
- function migrateIntoShared(profileName, src, sharedTarget) {
53
- if (!fs.existsSync(src)) return 0;
54
- fs.mkdirSync(sharedTarget, { recursive: true });
55
- let moved = 0;
56
- for (const ent of fs.readdirSync(src)) {
57
- const from = path.join(src, ent);
58
- let to = path.join(sharedTarget, ent);
59
- if (fs.existsSync(to)) {
60
- to = path.join(sharedTarget, `${ent}__${profileName}`);
61
- let i = 2;
62
- while (fs.existsSync(to)) {
63
- to = path.join(sharedTarget, `${ent}__${profileName}_${i}`);
64
- i += 1;
65
- }
142
+ // Create the symlink for one resource. For dirs uses junction (Windows) or dir
143
+ // symlink (POSIX). For files attempts a file symlink first; on Windows without
144
+ // Developer Mode this fails with EPERM/UNKNOWN and we fall back to a hard
145
+ // link, which works without elevation and behaves identically for normal
146
+ // edit-in-place flows (the leak: `rm + create` breaks the link).
147
+ function createResourceLink(target, link, resource) {
148
+ if (!isFileResource(resource)) {
149
+ fs.symlinkSync(target, link, symlinkType());
150
+ return { kind: 'symlink' };
151
+ }
152
+ try {
153
+ fs.symlinkSync(target, link, 'file');
154
+ return { kind: 'symlink' };
155
+ } catch (err) {
156
+ if (
157
+ process.platform === 'win32' &&
158
+ (err.code === 'EPERM' || err.code === 'UNKNOWN' || err.code === 'EACCES')
159
+ ) {
160
+ fs.linkSync(target, link);
161
+ return { kind: 'hardlink' };
66
162
  }
67
- fs.renameSync(from, to);
68
- moved += 1;
163
+ throw err;
69
164
  }
70
- fs.rmdirSync(src);
71
- return moved;
72
165
  }
73
166
 
74
- // Links each of the four artifact dirs independently. A failure on one
75
- // directory is recorded and the remaining directories are still attempted —
76
- // callers get a per-directory result and decide how to report it.
77
- // Returns [{ sub, status, moved?, message? }]; status is one of:
78
- // 'linked' | 'already-linked' | 'failed'
79
- function shareLink(profileName) {
167
+ // Link each requested resource of a profile into the shared pool (~/.claude/).
168
+ // Each resource is attempted independently — one failure doesn't abort the
169
+ // rest. Returns [{ resource, status, ... }]; status ∈
170
+ // 'linked' | 'already-linked' | 'refused' | 'failed'
171
+ //
172
+ // 'refused' is the v0.4.1 behavior when the profile holds private content at
173
+ // the resource path: we do NOT auto-move it into the pool (was the v0.4.0
174
+ // behavior for dirs). The user must move/remove it manually.
175
+ function shareLink(profileName, opts = {}) {
80
176
  profiles.validateProfileName(profileName);
81
177
  const pdir = profiles.profileDir(profileName);
82
178
  if (!fs.existsSync(pdir)) {
83
- const err = new Error(`Profile "${profileName}" does not exist. Create it with \`cc add ${profileName}\` first.`);
179
+ const err = new Error(
180
+ `Profile "${profileName}" does not exist. Create it with \`cc add ${profileName}\` first.`,
181
+ );
84
182
  err.exitCode = 1;
85
183
  throw err;
86
184
  }
87
- ensureSharedDirs();
185
+ const subset = resolveSubset(opts);
186
+ ensurePoolResources(subset);
187
+ const pool = poolDir();
188
+ const legacyPool = profiles.sharedDir();
88
189
  const results = [];
89
- for (const sub of SHARED_SUBDIRS) {
90
- const target = path.join(profiles.sharedDir(), sub);
91
- const link = path.join(pdir, sub);
190
+ for (const resource of subset) {
191
+ const target = path.join(pool, resource);
192
+ const link = path.join(pdir, resource);
92
193
  try {
194
+ // Case 1: already a symlink.
93
195
  if (isLink(link)) {
94
- results.push({ sub, status: 'already-linked' });
196
+ const current = fs.readlinkSync(link);
197
+ const resolved = path.resolve(pdir, current);
198
+ if (resolved === target) {
199
+ results.push({ resource, status: 'already-linked' });
200
+ continue;
201
+ }
202
+ if (resolved === path.join(legacyPool, resource)) {
203
+ // Legacy v0.3 pointer — silently relink to the new pool location.
204
+ removeLink(link);
205
+ const r = createResourceLink(target, link, resource);
206
+ results.push({ resource, status: 'linked', relinkedFromLegacy: true, kind: r.kind });
207
+ continue;
208
+ }
209
+ // Points somewhere unexpected — don't touch it.
210
+ results.push({ resource, status: 'already-linked' });
211
+ continue;
212
+ }
213
+
214
+ // Case 2: already linked via hard link (Windows file fallback).
215
+ if (isSharedResource(pdir, resource)) {
216
+ results.push({ resource, status: 'already-linked' });
95
217
  continue;
96
218
  }
97
- let moved = 0;
219
+
220
+ // Case 3: a real path exists at the link location — private content.
98
221
  if (fs.existsSync(link)) {
99
- moved = migrateIntoShared(profileName, link, target);
222
+ const hasContent = isFileResource(resource)
223
+ ? true // any private file counts as content
224
+ : (() => {
225
+ try { return fs.readdirSync(link).length > 0; } catch { return false; }
226
+ })();
227
+ if (hasContent) {
228
+ results.push({
229
+ resource,
230
+ status: 'refused',
231
+ message:
232
+ `private content exists at ${link} — move or remove it manually, ` +
233
+ `then re-run \`cc link ${profileName}\``,
234
+ });
235
+ continue;
236
+ }
237
+ // Empty private dir — safe to remove and replace with a symlink.
238
+ try { fs.rmdirSync(link); } catch { /* fall through; create will fail clearly */ }
100
239
  }
101
- fs.symlinkSync(target, link, symlinkType());
102
- results.push({ sub, status: 'linked', moved });
240
+
241
+ const r = createResourceLink(target, link, resource);
242
+ results.push({ resource, status: 'linked', kind: r.kind });
103
243
  } catch (err) {
104
- results.push({ sub, status: 'failed', message: err.message });
244
+ results.push({ resource, status: 'failed', message: err.message });
105
245
  }
106
246
  }
107
247
  return results;
108
248
  }
109
249
 
110
- // Mirror of shareLink: each directory is restored independently.
111
- // Returns [{ sub, status, message? }]; status is one of:
250
+ // Mirror of shareLink: each requested resource is restored to a private real
251
+ // directory or file. Content is copied from the pool (skip same-name
252
+ // collisions, defensive) so the profile keeps its current view.
253
+ // Returns [{ resource, status, ... }]; status ∈
112
254
  // 'unlinked' | 'not-linked' | 'failed'
113
- function shareUnlink(profileName) {
255
+ function shareUnlink(profileName, opts = {}) {
114
256
  profiles.validateProfileName(profileName);
115
257
  const pdir = profiles.profileDir(profileName);
116
258
  if (!fs.existsSync(pdir)) {
@@ -118,34 +260,178 @@ function shareUnlink(profileName) {
118
260
  err.exitCode = 1;
119
261
  throw err;
120
262
  }
263
+ const subset = resolveSubset(opts);
264
+ const pool = poolDir();
121
265
  const results = [];
122
- for (const sub of SHARED_SUBDIRS) {
123
- const link = path.join(pdir, sub);
124
- const target = path.join(profiles.sharedDir(), sub);
266
+ for (const resource of subset) {
267
+ const link = path.join(pdir, resource);
268
+ const target = path.join(pool, resource);
125
269
  try {
126
- if (!isLink(link)) {
127
- results.push({ sub, status: 'not-linked' });
270
+ const symLinked = isLink(link);
271
+ const hardLinked = !symLinked && isSharedResource(pdir, resource);
272
+ if (!symLinked && !hardLinked) {
273
+ results.push({ resource, status: 'not-linked' });
128
274
  continue;
129
275
  }
276
+
277
+ // Read pool content BEFORE removing the link (in case the link is the
278
+ // only path to it... well, not really — target is reachable directly —
279
+ // but reading first means we can fail without leaving a deleted link).
280
+ if (isFileResource(resource)) {
281
+ // Files: read current content, then replace link with a real file.
282
+ let buf = null;
283
+ if (fs.existsSync(target)) {
284
+ try { buf = fs.readFileSync(target); } catch { buf = null; }
285
+ }
286
+ // For a hard link, unlink decrements ref count — target file in pool
287
+ // survives. For a symlink, unlink removes the link only.
288
+ removeLink(link);
289
+ if (buf != null) {
290
+ fs.writeFileSync(link, buf);
291
+ } else {
292
+ fs.writeFileSync(link, '');
293
+ }
294
+ results.push({ resource, status: 'unlinked' });
295
+ continue;
296
+ }
297
+
298
+ // Dirs: replace symlink with a real dir, then copy contents (skip
299
+ // same-name collisions — defensive, dir is fresh so collisions
300
+ // shouldn't happen).
130
301
  removeLink(link);
131
302
  fs.mkdirSync(link, { recursive: true });
132
303
  if (fs.existsSync(target)) {
133
304
  for (const ent of fs.readdirSync(target)) {
134
- copyRecursive(path.join(target, ent), path.join(link, ent));
305
+ const from = path.join(target, ent);
306
+ const to = path.join(link, ent);
307
+ if (fs.existsSync(to)) continue; // skip same-name
308
+ copyRecursive(from, to);
135
309
  }
136
310
  }
137
- results.push({ sub, status: 'unlinked' });
311
+ results.push({ resource, status: 'unlinked' });
138
312
  } catch (err) {
139
- results.push({ sub, status: 'failed', message: err.message });
313
+ results.push({ resource, status: 'failed', message: err.message });
140
314
  }
141
315
  }
142
316
  return results;
143
317
  }
144
318
 
319
+ // One-time migration from the v0.2/v0.3 legacy pool at ~/.claude-shared/ to
320
+ // the v0.4 pool at ~/.claude/. Idempotent: a second call is a no-op once the
321
+ // legacy dir is gone. Non-destructive: existing entries in ~/.claude/ survive
322
+ // via the `__legacy` collision rename used here.
323
+ //
324
+ // Returns { migrated, moves: [...], relinked: [...], failures: [...] }
325
+ function migrateLegacyPool({ log = () => {} } = {}) {
326
+ const legacy = profiles.sharedDir();
327
+ if (!fs.existsSync(legacy)) {
328
+ return { migrated: false, moves: [], relinked: [], failures: [] };
329
+ }
330
+ const pool = poolDir();
331
+ const moves = [];
332
+ const relinked = [];
333
+ const failures = [];
334
+
335
+ // Legacy pool only ever held the 4 dirs — config files are new in v0.4.1.
336
+ for (const sub of SHARED_DIRS) {
337
+ const legacySub = path.join(legacy, sub);
338
+ if (!fs.existsSync(legacySub)) continue;
339
+ try {
340
+ fs.mkdirSync(path.join(pool, sub), { recursive: true });
341
+ } catch (err) {
342
+ failures.push({ kind: 'mkdir', sub, message: err.message });
343
+ continue;
344
+ }
345
+ let entries;
346
+ try {
347
+ entries = fs.readdirSync(legacySub);
348
+ } catch (err) {
349
+ failures.push({ kind: 'readdir', sub, message: err.message });
350
+ continue;
351
+ }
352
+ for (const ent of entries) {
353
+ const from = path.join(legacySub, ent);
354
+ let to = path.join(pool, sub, ent);
355
+ if (fs.existsSync(to)) {
356
+ to = path.join(pool, sub, `${ent}__legacy`);
357
+ let i = 2;
358
+ while (fs.existsSync(to)) {
359
+ to = path.join(pool, sub, `${ent}__legacy_${i}`);
360
+ i += 1;
361
+ }
362
+ }
363
+ try {
364
+ fs.renameSync(from, to);
365
+ moves.push({ sub, name: ent, to });
366
+ log(` ${sub}/${ent} → ${to}`);
367
+ } catch (err) {
368
+ failures.push({ kind: 'move', sub, name: ent, message: err.message });
369
+ }
370
+ }
371
+ try {
372
+ if (fs.readdirSync(legacySub).length === 0) fs.rmdirSync(legacySub);
373
+ } catch {
374
+ // best-effort
375
+ }
376
+ }
377
+
378
+ // Re-point any existing profile symlinks from legacy/<sub> to pool/<sub>.
379
+ let allProfiles;
380
+ try {
381
+ allProfiles = profiles.listProfiles();
382
+ } catch {
383
+ allProfiles = [];
384
+ }
385
+ for (const p of allProfiles) {
386
+ for (const sub of SHARED_DIRS) {
387
+ const link = path.join(p.dir, sub);
388
+ if (!isLink(link)) continue;
389
+ let resolved;
390
+ try {
391
+ resolved = path.resolve(p.dir, fs.readlinkSync(link));
392
+ } catch {
393
+ continue;
394
+ }
395
+ if (resolved !== path.join(legacy, sub)) continue;
396
+ try {
397
+ removeLink(link);
398
+ fs.symlinkSync(path.join(pool, sub), link, symlinkType());
399
+ relinked.push({ profile: p.name, sub });
400
+ log(` ${p.name}/${sub}: relinked → ~/.claude/${sub}/`);
401
+ } catch (err) {
402
+ failures.push({ kind: 'relink', profile: p.name, sub, message: err.message });
403
+ }
404
+ }
405
+ }
406
+
407
+ // Remove the legacy pool dir if it ended up empty.
408
+ try {
409
+ if (fs.existsSync(legacy) && fs.readdirSync(legacy).length === 0) {
410
+ fs.rmdirSync(legacy);
411
+ log(' removed empty ~/.claude-shared/');
412
+ }
413
+ } catch {
414
+ // best-effort
415
+ }
416
+
417
+ return { migrated: true, moves, relinked, failures };
418
+ }
419
+
145
420
  module.exports = {
146
- SHARED_SUBDIRS,
421
+ SHARED_RESOURCES,
422
+ SHARED_DIRS,
423
+ SHARED_SUBDIRS, // legacy alias for SHARED_DIRS
424
+ TOOL_SUBDIRS,
425
+ PROJECT_SUBDIRS,
426
+ CONFIG_FILES,
427
+ isFileResource,
147
428
  isLink,
429
+ isSharedResource,
148
430
  removeLink,
149
431
  shareLink,
150
432
  shareUnlink,
433
+ resolveSubset,
434
+ poolDir,
435
+ ensurePoolResources,
436
+ migrateLegacyPool,
151
437
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cchost",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "description": "Host multiple isolated Claude Code accounts on one machine via the `cc` command.",
5
5
  "keywords": [
6
6
  "claude",
package/lib/migrate.js DELETED
@@ -1,145 +0,0 @@
1
- 'use strict';
2
-
3
- const fs = require('fs');
4
- const path = require('path');
5
- const os = require('os');
6
-
7
- const profiles = require('./profiles');
8
-
9
- // sessions/ is transient per-process state — intentionally not migrated.
10
- // plugins/ carries auth tokens — excluded until an explicit opt-in flag is added.
11
- const SUBDIRS = ['projects', 'skills', 'agents', 'commands'];
12
-
13
- // Single files copied only when src=default and dest=<profile>.
14
- // Not copied to shared: these carry per-profile config / auth tokens.
15
- const SINGLE_FILES = ['mcp.json', 'settings.json', 'CLAUDE.md'];
16
-
17
- // .cc-env.json carries a third-party API key — same security category as
18
- // .credentials.json. It must NEVER be copied to a shared pool or another
19
- // profile, so it is excluded explicitly here (it also lives at the profile
20
- // root, outside SUBDIRS, but this guards against any future copy path).
21
- const EXCLUDED_FILES = new Set(['.credentials.json', '.claude.json', '.cc-env.json']);
22
-
23
- function defaultClaudeDir() {
24
- return path.join(os.homedir(), '.claude');
25
- }
26
-
27
- function usageError(msg) {
28
- const e = new Error(msg);
29
- e.exitCode = 2;
30
- return e;
31
- }
32
-
33
- function resolveSource(src) {
34
- if (src === 'default') return { kind: 'default', dir: defaultClaudeDir() };
35
- if (src === 'shared') return { kind: 'shared', dir: profiles.sharedDir() };
36
- throw usageError(`Invalid migrate source "${src}". Allowed: default, shared.`);
37
- }
38
-
39
- function resolveDest(dest) {
40
- if (dest === 'shared') return { kind: 'shared', dir: profiles.sharedDir() };
41
- if (dest === 'default') {
42
- throw usageError('Cannot migrate into "default". Allowed destinations: shared, <profile>.');
43
- }
44
- profiles.validateProfileName(dest);
45
- return { kind: 'profile', name: dest, dir: profiles.profileDir(dest) };
46
- }
47
-
48
- function copyTreeSkipCollisions(srcRoot, dstRoot, { force = false } = {}) {
49
- if (!fs.existsSync(srcRoot)) return { copied: 0, skipped: 0 };
50
- fs.mkdirSync(dstRoot, { recursive: true });
51
- let copied = 0;
52
- let skipped = 0;
53
- for (const ent of fs.readdirSync(srcRoot, { withFileTypes: true })) {
54
- if (EXCLUDED_FILES.has(ent.name)) continue;
55
- const from = path.join(srcRoot, ent.name);
56
- const to = path.join(dstRoot, ent.name);
57
- if (fs.existsSync(to)) {
58
- if (force) {
59
- fs.rmSync(to, { recursive: true, force: true });
60
- } else {
61
- skipped += 1;
62
- continue;
63
- }
64
- }
65
- fs.cpSync(from, to, { recursive: true, verbatimSymlinks: true });
66
- copied += 1;
67
- }
68
- return { copied, skipped };
69
- }
70
-
71
- // Returns 'copied', 'skipped', or 'absent'.
72
- function copySingleFile(srcDir, dstDir, fileName, { force = false } = {}) {
73
- const from = path.join(srcDir, fileName);
74
- if (!fs.existsSync(from)) return 'absent';
75
- const to = path.join(dstDir, fileName);
76
- if (fs.existsSync(to)) {
77
- if (force) {
78
- fs.rmSync(to, { recursive: true, force: true });
79
- } else {
80
- return 'skipped';
81
- }
82
- }
83
- fs.copyFileSync(from, to);
84
- return 'copied';
85
- }
86
-
87
- function migrate(srcArg, destArg, { force = false } = {}) {
88
- const src = resolveSource(srcArg);
89
- const dest = resolveDest(destArg);
90
-
91
- if (path.resolve(src.dir) === path.resolve(dest.dir)) {
92
- throw usageError(`Source and destination resolve to the same directory (${src.dir}).`);
93
- }
94
-
95
- if (dest.kind === 'profile') {
96
- profiles.ensureProfileDir(dest.name);
97
- } else {
98
- fs.mkdirSync(dest.dir, { recursive: true });
99
- }
100
-
101
- if (!fs.existsSync(src.dir)) {
102
- console.log(`Source ${src.dir} does not exist. Nothing to migrate.`);
103
- return { copied: 0, skipped: 0, files: {} };
104
- }
105
-
106
- let totalCopied = 0;
107
- let totalSkipped = 0;
108
- for (const sub of SUBDIRS) {
109
- const srcSub = path.join(src.dir, sub);
110
- const dstSub = path.join(dest.dir, sub);
111
- if (!fs.existsSync(srcSub)) continue;
112
- const { copied, skipped } = copyTreeSkipCollisions(srcSub, dstSub, { force });
113
- console.log(` ${sub}: copied ${copied}, skipped ${skipped}`);
114
- totalCopied += copied;
115
- totalSkipped += skipped;
116
- }
117
-
118
- // Single config files: only when migrating from default into a profile.
119
- const fileResults = {};
120
- if (src.kind === 'default' && dest.kind === 'profile') {
121
- for (const f of SINGLE_FILES) {
122
- const result = copySingleFile(src.dir, dest.dir, f, { force });
123
- fileResults[f] = result;
124
- const label = result === 'absent' ? 'not present at source' : result;
125
- console.log(` ${f}: ${label}`);
126
- if (result === 'copied') totalCopied += 1;
127
- else if (result === 'skipped') totalSkipped += 1;
128
- }
129
- }
130
-
131
- console.log(`Done. Total copied: ${totalCopied}, total skipped (already present): ${totalSkipped}.`);
132
- return { copied: totalCopied, skipped: totalSkipped, files: fileResults };
133
- }
134
-
135
- module.exports = {
136
- migrate,
137
- defaultClaudeDir,
138
- resolveSource,
139
- resolveDest,
140
- copyTreeSkipCollisions,
141
- copySingleFile,
142
- SUBDIRS,
143
- SINGLE_FILES,
144
- EXCLUDED_FILES,
145
- };