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/CHANGELOG.md +113 -0
- package/README.md +82 -70
- package/lib/banner.js +29 -0
- package/lib/cli.js +341 -151
- package/lib/copy.js +242 -0
- package/lib/doctor.js +154 -22
- package/lib/env.js +20 -0
- package/lib/profiles.js +10 -0
- package/lib/share.js +343 -57
- package/package.json +1 -1
- package/lib/migrate.js +0 -145
package/lib/share.js
CHANGED
|
@@ -5,11 +5,34 @@ const path = require('path');
|
|
|
5
5
|
|
|
6
6
|
const profiles = require('./profiles');
|
|
7
7
|
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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 ${
|
|
136
|
+
throw new Error(`Cannot create ${target}: ${err.message}`);
|
|
47
137
|
}
|
|
48
138
|
}
|
|
49
139
|
return base;
|
|
50
140
|
}
|
|
51
141
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
68
|
-
moved += 1;
|
|
163
|
+
throw err;
|
|
69
164
|
}
|
|
70
|
-
fs.rmdirSync(src);
|
|
71
|
-
return moved;
|
|
72
165
|
}
|
|
73
166
|
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
|
|
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(
|
|
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
|
-
|
|
185
|
+
const subset = resolveSubset(opts);
|
|
186
|
+
ensurePoolResources(subset);
|
|
187
|
+
const pool = poolDir();
|
|
188
|
+
const legacyPool = profiles.sharedDir();
|
|
88
189
|
const results = [];
|
|
89
|
-
for (const
|
|
90
|
-
const target = path.join(
|
|
91
|
-
const link = path.join(pdir,
|
|
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
|
-
|
|
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
|
-
|
|
219
|
+
|
|
220
|
+
// Case 3: a real path exists at the link location — private content.
|
|
98
221
|
if (fs.existsSync(link)) {
|
|
99
|
-
|
|
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
|
-
|
|
102
|
-
|
|
240
|
+
|
|
241
|
+
const r = createResourceLink(target, link, resource);
|
|
242
|
+
results.push({ resource, status: 'linked', kind: r.kind });
|
|
103
243
|
} catch (err) {
|
|
104
|
-
results.push({
|
|
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
|
|
111
|
-
//
|
|
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
|
|
123
|
-
const link = path.join(pdir,
|
|
124
|
-
const target = path.join(
|
|
266
|
+
for (const resource of subset) {
|
|
267
|
+
const link = path.join(pdir, resource);
|
|
268
|
+
const target = path.join(pool, resource);
|
|
125
269
|
try {
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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({
|
|
311
|
+
results.push({ resource, status: 'unlinked' });
|
|
138
312
|
} catch (err) {
|
|
139
|
-
results.push({
|
|
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
|
-
|
|
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
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
|
-
};
|