cchost 0.2.0 → 0.2.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/README.md +40 -19
- package/lib/cli.js +104 -41
- package/lib/migrate.js +51 -10
- package/lib/share.js +39 -22
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -18,10 +18,10 @@ Requires Node.js 18+ and Claude Code 2.1.140+ already installed. Get Claude Code
|
|
|
18
18
|
| --- | --- |
|
|
19
19
|
| `cc add <profile>` | Create a profile directory. |
|
|
20
20
|
| `cc use <profile> [...args]` | Launch Claude under that profile. Profile must already exist. Args after the name are forwarded verbatim to `claude`. |
|
|
21
|
-
| `cc list [--json]` | Show every profile: login state, account email, and
|
|
22
|
-
| `cc migrate <src> <dest
|
|
23
|
-
| `cc link <profile> [<profile>...]` | Link a profile's
|
|
24
|
-
| `cc unlink <profile>` | Restore a profile to its own private
|
|
21
|
+
| `cc list [--json]` | Show every profile: login state, account email, and how many artifact dirs are linked to shared (N/4). |
|
|
22
|
+
| `cc migrate <src> <dest> [--force]` | Copy artifacts from `default`\|`shared` to `shared`\|`<profile>`. Skip-on-collision by default; `--force` overwrites. |
|
|
23
|
+
| `cc link <profile> [<profile>...]` | Link a profile's artifact dirs to `~/.claude-shared/`. Migrates existing content; renames on collision. |
|
|
24
|
+
| `cc unlink <profile>` | Restore a profile to its own private artifact dirs (copies shared content back). |
|
|
25
25
|
| `cc remove <profile>` | Delete a profile. Requires typing the profile name to confirm. |
|
|
26
26
|
| `cc doctor [--fix [--force]] [--json]` | Diagnose environment, storage, and platform issues. |
|
|
27
27
|
| `cc help [command]` | Print usage (top-level, or for a single command). |
|
|
@@ -40,42 +40,63 @@ cc remove old-profile
|
|
|
40
40
|
`cc list` output:
|
|
41
41
|
|
|
42
42
|
```
|
|
43
|
-
Profile LoggedIn Email
|
|
44
|
-
personal true personal@example.com
|
|
45
|
-
scratch false -
|
|
46
|
-
work true work@example.com
|
|
43
|
+
Profile LoggedIn Email Shared
|
|
44
|
+
personal true personal@example.com 4/4
|
|
45
|
+
scratch false - 0/4
|
|
46
|
+
work true work@example.com 0/4
|
|
47
47
|
```
|
|
48
48
|
|
|
49
|
-
###
|
|
49
|
+
### What migrate copies and what link shares
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
| Artifact | `cc migrate` copies it | `cc link` shares it | Notes |
|
|
52
|
+
| --- | :---: | :---: | --- |
|
|
53
|
+
| `projects/` | ✓ | ✓ | Resumable conversation transcripts |
|
|
54
|
+
| `skills/` | ✓ | ✓ | Custom slash-command scripts |
|
|
55
|
+
| `agents/` | ✓ | ✓ | Custom agent definitions |
|
|
56
|
+
| `commands/` | ✓ | ✓ | Custom command configs |
|
|
57
|
+
| `mcp.json` | ✓ (default→profile only) | — | Per-profile MCP server config; may carry auth tokens |
|
|
58
|
+
| `settings.json` | ✓ (default→profile only) | — | Per-profile UI/feature settings |
|
|
59
|
+
| `CLAUDE.md` | ✓ (default→profile only) | — | Per-profile project instructions |
|
|
60
|
+
| `sessions/` | — | — | Transient per-process state; sharing would corrupt bookkeeping |
|
|
61
|
+
| `plugins/` | — | — | Carries auth tokens; deferred to a future opt-in flag |
|
|
62
|
+
| `.credentials.json` | — | — | Never touched — each profile keeps its own auth |
|
|
63
|
+
| `.claude.json` | — | — | Never touched — re-synced from server on every launch |
|
|
64
|
+
|
|
65
|
+
`mcp.json`, `settings.json`, and `CLAUDE.md` are **not** copied to `shared` and are **not** linked because they typically carry auth tokens or per-profile configuration that should not leak across accounts.
|
|
66
|
+
|
|
67
|
+
### Migrating existing artifacts
|
|
68
|
+
|
|
69
|
+
If you've been using Claude Code without profiles, your history lives in `~/.claude/`. Pull it into a profile or the shared pool:
|
|
52
70
|
|
|
53
71
|
```bash
|
|
54
|
-
cc migrate default work # copy ~/.claude
|
|
55
|
-
cc migrate default shared # copy ~/.claude/projects → ~/.claude-shared/
|
|
56
|
-
cc migrate shared personal # copy ~/.claude-shared
|
|
72
|
+
cc migrate default work # copy ~/.claude/* → ~/.claude-profiles/work/
|
|
73
|
+
cc migrate default shared # copy ~/.claude/projects,skills,agents,commands → ~/.claude-shared/
|
|
74
|
+
cc migrate shared personal # copy ~/.claude-shared/* → ~/.claude-profiles/personal/
|
|
75
|
+
cc migrate default work --force # same as first, overwriting anything that already exists
|
|
57
76
|
```
|
|
58
77
|
|
|
59
|
-
This is a **copy** — the source is left untouched.
|
|
78
|
+
This is a **copy** — the source is left untouched. By default, if an entry already exists at the destination it is silently skipped. Pass `--force` to overwrite instead — existing destination content will be cleared.
|
|
79
|
+
|
|
80
|
+
Credential files (`.credentials.json`, `.claude.json`) are never copied: each profile keeps its own auth state.
|
|
60
81
|
|
|
61
|
-
### Shared
|
|
82
|
+
### Shared artifacts across profiles
|
|
62
83
|
|
|
63
|
-
If you want each profile to keep separate credentials but share project history (e.g. rotate accounts on the same project when usage limits hit):
|
|
84
|
+
If you want each profile to keep separate credentials but share project history and skills (e.g. rotate accounts on the same project when usage limits hit):
|
|
64
85
|
|
|
65
86
|
```bash
|
|
66
87
|
cc link work
|
|
67
88
|
cc link personal
|
|
68
89
|
```
|
|
69
90
|
|
|
70
|
-
`work
|
|
91
|
+
`work/` and `personal/` each get four linked dirs (`projects`, `skills`, `agents`, `commands`) pointing to `~/.claude-shared/`. On Windows this uses directory junctions (no admin/Developer Mode required); on macOS/Linux, regular symlinks.
|
|
71
92
|
|
|
72
|
-
|
|
93
|
+
`sessions/` is **never** linked. It holds transient per-process registry state — PID, working directory, status, timestamps — and sharing it would corrupt Claude's running-process bookkeeping. It stays per-profile under `~/.claude-profiles/<name>/sessions/`.
|
|
73
94
|
|
|
74
95
|
`cc link a b c` links several profiles at once; per-profile failures are reported but don't abort the rest of the batch.
|
|
75
96
|
|
|
76
97
|
If two profiles share history and you launch them against the same project at the same time, both will write to the same session files (last write wins). Don't do that.
|
|
77
98
|
|
|
78
|
-
`cc remove` is safe with shared profiles — it deletes only the
|
|
99
|
+
`cc remove` is safe with shared profiles — it deletes only the links, never recursing into the shared target.
|
|
79
100
|
|
|
80
101
|
## How it works
|
|
81
102
|
|
package/lib/cli.js
CHANGED
|
@@ -16,10 +16,10 @@ const HELP = `cc - Multi-profile launcher for Claude Code
|
|
|
16
16
|
Usage:
|
|
17
17
|
cc add <profile> Create a profile directory
|
|
18
18
|
cc use <profile> [...args] Launch Claude under a named profile
|
|
19
|
-
cc list [--json] Show all profiles: login state, email,
|
|
20
|
-
cc migrate <src> <dest>
|
|
21
|
-
cc link <p>...
|
|
22
|
-
cc unlink <profile> Restore a profile's private
|
|
19
|
+
cc list [--json] Show all profiles: login state, email, shared-dir count (N/4)
|
|
20
|
+
cc migrate <src> <dest> [--force] Copy artifact dirs (and config files) between profiles
|
|
21
|
+
cc link <p>... Link each profile's artifact dirs to ~/.claude-shared/
|
|
22
|
+
cc unlink <profile> Restore a profile's private artifact dirs
|
|
23
23
|
cc remove <profile> Delete a profile and all its data (typed confirmation)
|
|
24
24
|
cc doctor [--fix [--force]] [--json] Diagnose environment, storage, and platform issues
|
|
25
25
|
cc help [command] Show usage (optionally for a single command)
|
|
@@ -58,47 +58,64 @@ Columns:
|
|
|
58
58
|
Profile - profile name
|
|
59
59
|
LoggedIn - true if .credentials.json exists
|
|
60
60
|
Email - account email from .claude.json; - if not yet logged in
|
|
61
|
-
|
|
61
|
+
Shared - N/4 linked to ~/.claude-shared/ (projects, skills, agents, commands)
|
|
62
|
+
4/4 = fully shared, 0/4 = fully isolated
|
|
62
63
|
(sessions/ is transient per-process state and is never shared)
|
|
63
64
|
|
|
64
|
-
With --json, emits an array of {name, dir, loggedIn, email,
|
|
65
|
+
With --json, emits an array of {name, dir, loggedIn, email, shared}.
|
|
65
66
|
`,
|
|
66
|
-
migrate: `cc migrate <src> <dest>
|
|
67
|
+
migrate: `cc migrate <src> <dest> [--force]
|
|
67
68
|
|
|
68
|
-
Copy
|
|
69
|
-
|
|
69
|
+
Copy artifact directories from a source to a destination. This is a COPY — the
|
|
70
|
+
source is left untouched. sessions/ is never migrated (transient per-process state).
|
|
70
71
|
|
|
71
72
|
<src>: default | shared
|
|
72
73
|
default = ~/.claude/ (Claude's built-in non-profile data)
|
|
73
74
|
shared = ~/.claude-shared/ (the cc shared pool)
|
|
74
75
|
<dest>: shared | <profile-name>
|
|
75
76
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
77
|
+
Directories copied in all cases:
|
|
78
|
+
projects/, skills/, agents/, commands/
|
|
79
|
+
|
|
80
|
+
Extra files copied only when src=default and dest=<profile>:
|
|
81
|
+
mcp.json, settings.json, CLAUDE.md
|
|
82
|
+
(These carry per-profile config and are NOT copied to shared.)
|
|
83
|
+
|
|
84
|
+
Never copied: .credentials.json, .claude.json, sessions/, plugins/
|
|
85
|
+
|
|
86
|
+
Collision behavior (default): existing entries at the destination are silently
|
|
87
|
+
skipped — the source entry is left alone.
|
|
88
|
+
|
|
89
|
+
--force Overwrite existing entries instead of skipping.
|
|
90
|
+
Warning: existing content at the destination will be cleared.
|
|
79
91
|
|
|
80
92
|
Examples:
|
|
81
|
-
cc migrate default work
|
|
82
|
-
cc migrate default shared
|
|
83
|
-
cc migrate shared work
|
|
93
|
+
cc migrate default work # copy ~/.claude/* to ~/.claude-profiles/work/
|
|
94
|
+
cc migrate default shared # copy dirs to ~/.claude-shared/
|
|
95
|
+
cc migrate shared work # copy shared dirs to ~/.claude-profiles/work/
|
|
96
|
+
cc migrate default work --force # same, overwriting any existing items
|
|
84
97
|
`,
|
|
85
98
|
link: `cc link <profile> [<profile>...]
|
|
86
99
|
|
|
87
|
-
Replace each profile's
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
100
|
+
Replace each profile's artifact directories with links to ~/.claude-shared/:
|
|
101
|
+
projects/, skills/, agents/, commands/
|
|
102
|
+
|
|
103
|
+
Existing content in each directory is migrated into shared first; collisions
|
|
104
|
+
are renamed with a __<profile> suffix (never overwritten). sessions/ (transient
|
|
105
|
+
per-process state) is intentionally never linked — it stays per-profile.
|
|
91
106
|
|
|
92
107
|
On Windows, link uses directory junctions (no admin/Developer Mode needed).
|
|
93
108
|
On macOS/Linux, regular symlinks.
|
|
94
109
|
|
|
95
|
-
Multiple profiles can be linked at once: 'cc link a b c'.
|
|
96
|
-
|
|
110
|
+
Multiple profiles can be linked at once: 'cc link a b c'. Failures are handled
|
|
111
|
+
per directory and per profile: one directory failing does not stop the other
|
|
112
|
+
three, and one profile failing does not stop the rest of the batch. A
|
|
113
|
+
per-directory summary is printed; exit code is 1 if anything failed.
|
|
97
114
|
`,
|
|
98
115
|
unlink: `cc unlink <profile>
|
|
99
116
|
|
|
100
|
-
Remove
|
|
101
|
-
back into the profile as
|
|
117
|
+
Remove each linked artifact directory (projects/, skills/, agents/, commands/)
|
|
118
|
+
and copy the shared content back into the profile as private dirs.
|
|
102
119
|
`,
|
|
103
120
|
remove: `cc remove <profile>
|
|
104
121
|
|
|
@@ -240,15 +257,18 @@ async function cmdUse(rest) {
|
|
|
240
257
|
function cmdList(rest) {
|
|
241
258
|
const { flags } = parseFlagsStrict(rest, ['--json']);
|
|
242
259
|
const list = profiles.listProfiles();
|
|
260
|
+
const total = share.SHARED_SUBDIRS.length;
|
|
243
261
|
|
|
244
262
|
const enriched = list.map((p) => {
|
|
245
|
-
const
|
|
263
|
+
const sharedCount = share.SHARED_SUBDIRS.filter(sub =>
|
|
264
|
+
isLinkLike(path.join(p.dir, sub))
|
|
265
|
+
).length;
|
|
246
266
|
return {
|
|
247
267
|
name: p.name,
|
|
248
268
|
dir: p.dir,
|
|
249
269
|
loggedIn: p.loggedIn,
|
|
250
270
|
email: p.email,
|
|
251
|
-
|
|
271
|
+
shared: sharedCount,
|
|
252
272
|
};
|
|
253
273
|
});
|
|
254
274
|
|
|
@@ -263,13 +283,13 @@ function cmdList(rest) {
|
|
|
263
283
|
return 0;
|
|
264
284
|
}
|
|
265
285
|
|
|
266
|
-
const rows = [['Profile', 'LoggedIn', 'Email', '
|
|
286
|
+
const rows = [['Profile', 'LoggedIn', 'Email', 'Shared']];
|
|
267
287
|
for (const p of enriched) {
|
|
268
288
|
rows.push([
|
|
269
289
|
p.name,
|
|
270
290
|
p.loggedIn ? 'true' : 'false',
|
|
271
291
|
p.email ? p.email : '-',
|
|
272
|
-
p.
|
|
292
|
+
`${p.shared}/${total}`,
|
|
273
293
|
]);
|
|
274
294
|
}
|
|
275
295
|
console.log(renderTable(rows));
|
|
@@ -311,29 +331,67 @@ async function cmdRemove(rest) {
|
|
|
311
331
|
|
|
312
332
|
async function cmdLink(rest) {
|
|
313
333
|
if (rest.length === 0) throw usageError('Usage: cc link <profile> [<profile>...]');
|
|
314
|
-
const
|
|
334
|
+
const multi = rest.length > 1;
|
|
335
|
+
const summary = [];
|
|
315
336
|
for (const name of rest) {
|
|
316
|
-
if (
|
|
337
|
+
if (multi) console.log(`\n[${name}]`);
|
|
338
|
+
let results;
|
|
317
339
|
try {
|
|
318
|
-
share.shareLink(name);
|
|
340
|
+
results = share.shareLink(name);
|
|
319
341
|
} catch (err) {
|
|
342
|
+
// Profile-level failure (invalid name, profile missing) — nothing linked.
|
|
320
343
|
process.stderr.write(color.stderr.red(` error: ${err.message}`) + '\n');
|
|
321
|
-
|
|
344
|
+
summary.push({ name, linked: 0, total: share.SHARED_SUBDIRS.length, error: err.message });
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
for (const r of results) {
|
|
348
|
+
if (r.status === 'failed') {
|
|
349
|
+
process.stderr.write(color.stderr.red(` ${r.sub}: failed — ${r.message}`) + '\n');
|
|
350
|
+
} else if (r.status === 'already-linked') {
|
|
351
|
+
console.log(` ${r.sub}: already linked`);
|
|
352
|
+
} else {
|
|
353
|
+
const note = r.moved ? ` (migrated ${r.moved} item(s) into shared)` : '';
|
|
354
|
+
console.log(` ${r.sub}: linked${note}`);
|
|
355
|
+
}
|
|
322
356
|
}
|
|
357
|
+
const failed = results.filter((r) => r.status === 'failed');
|
|
358
|
+
const linked = results.length - failed.length;
|
|
359
|
+
console.log(` ${linked}/${results.length} directories linked${failed.length ? `, ${failed.length} failed` : ''}.`);
|
|
360
|
+
summary.push({ name, linked, total: results.length, failed: failed.map((f) => f.sub) });
|
|
323
361
|
}
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
362
|
+
|
|
363
|
+
const anyFailure = summary.some((s) => s.error || (s.failed && s.failed.length));
|
|
364
|
+
if (multi) {
|
|
365
|
+
console.log('\nSummary:');
|
|
366
|
+
for (const s of summary) {
|
|
367
|
+
if (s.error) {
|
|
368
|
+
console.log(` ${s.name}: error — ${s.error}`);
|
|
369
|
+
} else if (s.failed && s.failed.length) {
|
|
370
|
+
console.log(` ${s.name}: ${s.linked}/${s.total} linked (failed: ${s.failed.join(', ')})`);
|
|
371
|
+
} else {
|
|
372
|
+
console.log(` ${s.name}: ${s.linked}/${s.total} linked`);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
328
375
|
}
|
|
329
|
-
return
|
|
376
|
+
return anyFailure ? 1 : 0;
|
|
330
377
|
}
|
|
331
378
|
|
|
332
379
|
async function cmdUnlink(rest) {
|
|
333
380
|
const [name] = rest;
|
|
334
381
|
if (!name) throw usageError('Usage: cc unlink <profile>');
|
|
335
|
-
share.shareUnlink(name);
|
|
336
|
-
|
|
382
|
+
const results = share.shareUnlink(name);
|
|
383
|
+
let anyFailure = false;
|
|
384
|
+
for (const r of results) {
|
|
385
|
+
if (r.status === 'failed') {
|
|
386
|
+
anyFailure = true;
|
|
387
|
+
process.stderr.write(color.stderr.red(` ${r.sub}: failed — ${r.message}`) + '\n');
|
|
388
|
+
} else if (r.status === 'not-linked') {
|
|
389
|
+
console.log(` ${r.sub}: not linked, skipping`);
|
|
390
|
+
} else {
|
|
391
|
+
console.log(` ${r.sub}: unlinked, content copied back`);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return anyFailure ? 1 : 0;
|
|
337
395
|
}
|
|
338
396
|
|
|
339
397
|
async function cmdDoctor(rest) {
|
|
@@ -347,10 +405,15 @@ async function cmdDoctor(rest) {
|
|
|
347
405
|
}
|
|
348
406
|
|
|
349
407
|
async function cmdMigrate(rest) {
|
|
350
|
-
const
|
|
351
|
-
|
|
408
|
+
const { flags, positional } = parseFlagsStrict(rest, ['--force'], { allowPositional: true });
|
|
409
|
+
const [src, dest, ...extra] = positional;
|
|
410
|
+
if (!src || !dest) throw usageError('Usage: cc migrate <src> <dest> [--force]');
|
|
352
411
|
if (extra.length > 0) throw usageError(`Unexpected extra arguments: ${extra.join(' ')}`);
|
|
353
|
-
|
|
412
|
+
const force = flags.has('--force');
|
|
413
|
+
if (force) {
|
|
414
|
+
process.stdout.write('Warning: --force is set. Existing items at the destination will be overwritten and their current content will be cleared.\n');
|
|
415
|
+
}
|
|
416
|
+
migrateLib.migrate(src, dest, { force });
|
|
354
417
|
return 0;
|
|
355
418
|
}
|
|
356
419
|
|
package/lib/migrate.js
CHANGED
|
@@ -6,9 +6,14 @@ const os = require('os');
|
|
|
6
6
|
|
|
7
7
|
const profiles = require('./profiles');
|
|
8
8
|
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
const SUBDIRS = ['projects'];
|
|
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
|
+
|
|
12
17
|
const EXCLUDED_FILES = new Set(['.credentials.json', '.claude.json']);
|
|
13
18
|
|
|
14
19
|
function defaultClaudeDir() {
|
|
@@ -36,7 +41,7 @@ function resolveDest(dest) {
|
|
|
36
41
|
return { kind: 'profile', name: dest, dir: profiles.profileDir(dest) };
|
|
37
42
|
}
|
|
38
43
|
|
|
39
|
-
function copyTreeSkipCollisions(srcRoot, dstRoot) {
|
|
44
|
+
function copyTreeSkipCollisions(srcRoot, dstRoot, { force = false } = {}) {
|
|
40
45
|
if (!fs.existsSync(srcRoot)) return { copied: 0, skipped: 0 };
|
|
41
46
|
fs.mkdirSync(dstRoot, { recursive: true });
|
|
42
47
|
let copied = 0;
|
|
@@ -46,8 +51,12 @@ function copyTreeSkipCollisions(srcRoot, dstRoot) {
|
|
|
46
51
|
const from = path.join(srcRoot, ent.name);
|
|
47
52
|
const to = path.join(dstRoot, ent.name);
|
|
48
53
|
if (fs.existsSync(to)) {
|
|
49
|
-
|
|
50
|
-
|
|
54
|
+
if (force) {
|
|
55
|
+
fs.rmSync(to, { recursive: true, force: true });
|
|
56
|
+
} else {
|
|
57
|
+
skipped += 1;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
51
60
|
}
|
|
52
61
|
fs.cpSync(from, to, { recursive: true, verbatimSymlinks: true });
|
|
53
62
|
copied += 1;
|
|
@@ -55,7 +64,23 @@ function copyTreeSkipCollisions(srcRoot, dstRoot) {
|
|
|
55
64
|
return { copied, skipped };
|
|
56
65
|
}
|
|
57
66
|
|
|
58
|
-
|
|
67
|
+
// Returns 'copied', 'skipped', or 'absent'.
|
|
68
|
+
function copySingleFile(srcDir, dstDir, fileName, { force = false } = {}) {
|
|
69
|
+
const from = path.join(srcDir, fileName);
|
|
70
|
+
if (!fs.existsSync(from)) return 'absent';
|
|
71
|
+
const to = path.join(dstDir, fileName);
|
|
72
|
+
if (fs.existsSync(to)) {
|
|
73
|
+
if (force) {
|
|
74
|
+
fs.rmSync(to, { recursive: true, force: true });
|
|
75
|
+
} else {
|
|
76
|
+
return 'skipped';
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
fs.copyFileSync(from, to);
|
|
80
|
+
return 'copied';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function migrate(srcArg, destArg, { force = false } = {}) {
|
|
59
84
|
const src = resolveSource(srcArg);
|
|
60
85
|
const dest = resolveDest(destArg);
|
|
61
86
|
|
|
@@ -71,7 +96,7 @@ function migrate(srcArg, destArg) {
|
|
|
71
96
|
|
|
72
97
|
if (!fs.existsSync(src.dir)) {
|
|
73
98
|
console.log(`Source ${src.dir} does not exist. Nothing to migrate.`);
|
|
74
|
-
return { copied: 0, skipped: 0 };
|
|
99
|
+
return { copied: 0, skipped: 0, files: {} };
|
|
75
100
|
}
|
|
76
101
|
|
|
77
102
|
let totalCopied = 0;
|
|
@@ -80,13 +105,27 @@ function migrate(srcArg, destArg) {
|
|
|
80
105
|
const srcSub = path.join(src.dir, sub);
|
|
81
106
|
const dstSub = path.join(dest.dir, sub);
|
|
82
107
|
if (!fs.existsSync(srcSub)) continue;
|
|
83
|
-
const { copied, skipped } = copyTreeSkipCollisions(srcSub, dstSub);
|
|
108
|
+
const { copied, skipped } = copyTreeSkipCollisions(srcSub, dstSub, { force });
|
|
84
109
|
console.log(` ${sub}: copied ${copied}, skipped ${skipped}`);
|
|
85
110
|
totalCopied += copied;
|
|
86
111
|
totalSkipped += skipped;
|
|
87
112
|
}
|
|
113
|
+
|
|
114
|
+
// Single config files: only when migrating from default into a profile.
|
|
115
|
+
const fileResults = {};
|
|
116
|
+
if (src.kind === 'default' && dest.kind === 'profile') {
|
|
117
|
+
for (const f of SINGLE_FILES) {
|
|
118
|
+
const result = copySingleFile(src.dir, dest.dir, f, { force });
|
|
119
|
+
fileResults[f] = result;
|
|
120
|
+
const label = result === 'absent' ? 'not present at source' : result;
|
|
121
|
+
console.log(` ${f}: ${label}`);
|
|
122
|
+
if (result === 'copied') totalCopied += 1;
|
|
123
|
+
else if (result === 'skipped') totalSkipped += 1;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
88
127
|
console.log(`Done. Total copied: ${totalCopied}, total skipped (already present): ${totalSkipped}.`);
|
|
89
|
-
return { copied: totalCopied, skipped: totalSkipped };
|
|
128
|
+
return { copied: totalCopied, skipped: totalSkipped, files: fileResults };
|
|
90
129
|
}
|
|
91
130
|
|
|
92
131
|
module.exports = {
|
|
@@ -95,6 +134,8 @@ module.exports = {
|
|
|
95
134
|
resolveSource,
|
|
96
135
|
resolveDest,
|
|
97
136
|
copyTreeSkipCollisions,
|
|
137
|
+
copySingleFile,
|
|
98
138
|
SUBDIRS,
|
|
139
|
+
SINGLE_FILES,
|
|
99
140
|
EXCLUDED_FILES,
|
|
100
141
|
};
|
package/lib/share.js
CHANGED
|
@@ -5,10 +5,11 @@ const path = require('path');
|
|
|
5
5
|
|
|
6
6
|
const profiles = require('./profiles');
|
|
7
7
|
|
|
8
|
-
//
|
|
8
|
+
// Artifact directories that are safe to share across profiles via junctions/symlinks.
|
|
9
9
|
// sessions/ is transient per-process registry state (PID, cwd, status, timestamps) and
|
|
10
10
|
// must stay per-profile — sharing it would corrupt Claude's running-process bookkeeping.
|
|
11
|
-
|
|
11
|
+
// plugins/ carries auth tokens and is intentionally excluded (future opt-in).
|
|
12
|
+
const SHARED_SUBDIRS = ['projects', 'skills', 'agents', 'commands'];
|
|
12
13
|
|
|
13
14
|
function symlinkType() {
|
|
14
15
|
return process.platform === 'win32' ? 'junction' : 'dir';
|
|
@@ -70,6 +71,11 @@ function migrateIntoShared(profileName, src, sharedTarget) {
|
|
|
70
71
|
return moved;
|
|
71
72
|
}
|
|
72
73
|
|
|
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'
|
|
73
79
|
function shareLink(profileName) {
|
|
74
80
|
profiles.validateProfileName(profileName);
|
|
75
81
|
const pdir = profiles.profileDir(profileName);
|
|
@@ -79,26 +85,31 @@ function shareLink(profileName) {
|
|
|
79
85
|
throw err;
|
|
80
86
|
}
|
|
81
87
|
ensureSharedDirs();
|
|
88
|
+
const results = [];
|
|
82
89
|
for (const sub of SHARED_SUBDIRS) {
|
|
83
90
|
const target = path.join(profiles.sharedDir(), sub);
|
|
84
91
|
const link = path.join(pdir, sub);
|
|
85
|
-
if (isLink(link)) {
|
|
86
|
-
console.log(` ${sub}: already linked`);
|
|
87
|
-
continue;
|
|
88
|
-
}
|
|
89
|
-
if (fs.existsSync(link)) {
|
|
90
|
-
const moved = migrateIntoShared(profileName, link, target);
|
|
91
|
-
console.log(` ${sub}: migrated ${moved} item(s) into shared`);
|
|
92
|
-
}
|
|
93
92
|
try {
|
|
93
|
+
if (isLink(link)) {
|
|
94
|
+
results.push({ sub, status: 'already-linked' });
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
let moved = 0;
|
|
98
|
+
if (fs.existsSync(link)) {
|
|
99
|
+
moved = migrateIntoShared(profileName, link, target);
|
|
100
|
+
}
|
|
94
101
|
fs.symlinkSync(target, link, symlinkType());
|
|
102
|
+
results.push({ sub, status: 'linked', moved });
|
|
95
103
|
} catch (err) {
|
|
96
|
-
|
|
104
|
+
results.push({ sub, status: 'failed', message: err.message });
|
|
97
105
|
}
|
|
98
|
-
console.log(` ${sub}: linked -> ${target}`);
|
|
99
106
|
}
|
|
107
|
+
return results;
|
|
100
108
|
}
|
|
101
109
|
|
|
110
|
+
// Mirror of shareLink: each directory is restored independently.
|
|
111
|
+
// Returns [{ sub, status, message? }]; status is one of:
|
|
112
|
+
// 'unlinked' | 'not-linked' | 'failed'
|
|
102
113
|
function shareUnlink(profileName) {
|
|
103
114
|
profiles.validateProfileName(profileName);
|
|
104
115
|
const pdir = profiles.profileDir(profileName);
|
|
@@ -107,22 +118,28 @@ function shareUnlink(profileName) {
|
|
|
107
118
|
err.exitCode = 1;
|
|
108
119
|
throw err;
|
|
109
120
|
}
|
|
121
|
+
const results = [];
|
|
110
122
|
for (const sub of SHARED_SUBDIRS) {
|
|
111
123
|
const link = path.join(pdir, sub);
|
|
112
124
|
const target = path.join(profiles.sharedDir(), sub);
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
removeLink(link);
|
|
118
|
-
fs.mkdirSync(link, { recursive: true });
|
|
119
|
-
if (fs.existsSync(target)) {
|
|
120
|
-
for (const ent of fs.readdirSync(target)) {
|
|
121
|
-
copyRecursive(path.join(target, ent), path.join(link, ent));
|
|
125
|
+
try {
|
|
126
|
+
if (!isLink(link)) {
|
|
127
|
+
results.push({ sub, status: 'not-linked' });
|
|
128
|
+
continue;
|
|
122
129
|
}
|
|
130
|
+
removeLink(link);
|
|
131
|
+
fs.mkdirSync(link, { recursive: true });
|
|
132
|
+
if (fs.existsSync(target)) {
|
|
133
|
+
for (const ent of fs.readdirSync(target)) {
|
|
134
|
+
copyRecursive(path.join(target, ent), path.join(link, ent));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
results.push({ sub, status: 'unlinked' });
|
|
138
|
+
} catch (err) {
|
|
139
|
+
results.push({ sub, status: 'failed', message: err.message });
|
|
123
140
|
}
|
|
124
|
-
console.log(` ${sub}: unlinked, content copied back`);
|
|
125
141
|
}
|
|
142
|
+
return results;
|
|
126
143
|
}
|
|
127
144
|
|
|
128
145
|
module.exports = {
|