cchost 0.3.1 → 0.4.2

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/cli.js CHANGED
@@ -6,26 +6,35 @@ const readline = require('readline');
6
6
 
7
7
  const profiles = require('./profiles');
8
8
  const share = require('./share');
9
- const migrateLib = require('./migrate');
9
+ const copyLib = require('./copy');
10
10
  const envLib = require('./env');
11
11
  const { spawnClaude } = require('./spawn');
12
12
  const { renderTable } = require('./format');
13
13
  const color = require('./color');
14
+ const { renderBanner } = require('./banner');
14
15
 
15
16
  const HELP = `cc - Multi-profile launcher for Claude Code
16
17
 
17
18
  Usage:
18
- cc add <profile> [--custom [...]] Create a profile (--custom: route through a custom API endpoint)
19
+ cc add <profile> [--custom [...]] Create a profile (auto-links all 7 resources to ~/.claude/)
19
20
  cc use <profile> [...args] Launch Claude under a named profile
20
- cc list [<profile>] [--json] [--reveal] List all profiles, or show one profile in detail
21
- cc migrate <src> <dest> [--force] Copy artifact dirs (and config files) between profiles
22
- cc link <p>... Link each profile's artifact dirs to ~/.claude-shared/
23
- cc unlink <profile> Restore a profile's private artifact dirs
24
- cc remove <profile> Delete a profile and all its data (typed confirmation)
21
+ cc list [<profile>] [--json] [--reveal] List profiles, or show one in detail
22
+ cc copy default <profile> [--force] Copy ~/.claude/ resources into a profile (only when unlinked)
23
+ cc link <p>... [--projects|--tools|--config] (Re-)link a profile's resources to ~/.claude/
24
+ cc unlink <profile> [--projects|--tools|--config] Restore a profile's private resources (non-destructive)
25
+ cc remove <profile> [--force] Delete a profile and all its data
25
26
  cc doctor [--fix [--force]] [--json] Diagnose environment, storage, and platform issues
26
27
  cc help [command] Show usage (optionally for a single command)
27
28
  cc --version Print the installed cchost version
28
29
 
30
+ Shared resources (linked into ~/.claude/ by default):
31
+ --projects projects/ (session history)
32
+ --tools skills/ agents/ commands/ (user tooling)
33
+ --config mcp.json settings.json CLAUDE.md (MCP, settings, user memory)
34
+
35
+ Never shared (stay per-profile):
36
+ .credentials.json .claude.json .cc-env.json (identity / auth)
37
+
29
38
  Profiles live in ~/.claude-profiles/<profile>/ and become CLAUDE_CONFIG_DIR
30
39
  for that launch. Args after the profile name are forwarded verbatim to claude.
31
40
  `;
@@ -33,7 +42,10 @@ for that launch. Args after the profile name are forwarded verbatim to claude.
33
42
  const HELP_BY_CMD = {
34
43
  add: `cc add <profile> [--custom [--base-url=URL --token=TOKEN ...]]
35
44
 
36
- Create a new profile directory without launching Claude.
45
+ Create a new profile directory without launching Claude. All 7 shared
46
+ resources (projects/, skills/, agents/, commands/, mcp.json, settings.json,
47
+ CLAUDE.md) are auto-linked into ~/.claude/ so they're visible to every
48
+ profile (and to bare \`claude\`) from the moment the profile is created.
37
49
 
38
50
  cc add <profile>
39
51
  Create a regular profile. It logs in with your Anthropic
@@ -50,31 +62,27 @@ Create a new profile directory without launching Claude.
50
62
  [--haiku=NAME] [--subagent=NAME]
51
63
  Same, non-interactive. --base-url and --token are required.
52
64
 
53
- Re-running 'cc add <profile> --custom' on an existing endpoint profile
54
- EDITS its configuration: the wizard prefills current values, and with
55
- flags only the flags you pass are changed. View a config with
56
- 'cc list <profile>'.
65
+ Re-running 'cc add <profile> --custom' on an existing endpoint profile EDITS
66
+ its configuration. View any profile with 'cc list <profile>'.
57
67
 
58
68
  Billing: a custom-endpoint profile bills through the third-party provider,
59
69
  NOT your Anthropic subscription. A profile is born subscription or endpoint
60
- and stays that way — 'cc add --custom' refuses a profile that already has
61
- Anthropic OAuth credentials.
70
+ and stays that way.
62
71
 
63
72
  The endpoint config lives at ~/.claude-profiles/<profile>/.cc-env.json with
64
- user-only permissions. It is never migrated and never linked.
73
+ user-only permissions. It is never linked and never copied.
65
74
 
66
75
  Examples:
67
76
  cc add work
68
77
  cc add kimi --custom
69
78
  cc add kimi --custom --base-url=https://api.moonshot.ai/anthropic --token=sk-... --model=kimi-k2.5
70
- cc add kimi --custom --model=kimi-k3 # edit just the model later
71
79
  `,
72
80
  use: `cc use <profile> [...args]
73
81
 
74
82
  Launch Claude under a named profile. The profile must already exist (create
75
83
  it with 'cc add <profile>'). Its directory under ~/.claude-profiles/<profile>/
76
- becomes CLAUDE_CONFIG_DIR for the spawned claude — credentials, sessions,
77
- projects, plugins, and history are all isolated.
84
+ becomes CLAUDE_CONFIG_DIR for the spawned claude — credentials and per-
85
+ profile config are isolated; the 7 shared resources are visible via symlinks.
78
86
 
79
87
  All args after the profile name are forwarded verbatim to claude.
80
88
 
@@ -90,98 +98,129 @@ Without a profile name: list every profile as a table.
90
98
  Columns:
91
99
  Profile - profile name
92
100
  LoggedIn - true if .credentials.json exists
93
- Email - account email from .claude.json; - if not yet logged in
94
- Shared - N/4 linked to ~/.claude-shared/ (projects, skills, agents, commands)
95
- 4/4 = fully shared, 0/4 = fully isolated
96
- (sessions/ is transient per-process state and is never shared)
97
- Endpoint - custom-endpoint host from .cc-env.json, or "subscription" if the
98
- profile uses Anthropic's subscription OAuth
101
+ Email - account email from .claude.json (masked by default; pass
102
+ --reveal to show the full value); - if not yet logged in
103
+ Shared - N/7 resources linked to ~/.claude/. 7/7 = fully shared (the
104
+ v0.4.1 default), 0/7 = fully private
105
+ Endpoint - custom-endpoint host from .cc-env.json, or "subscription" if
106
+ the profile uses Anthropic's subscription OAuth
99
107
 
100
108
  With a profile name: print that one profile's detail, including its custom
101
109
  endpoint configuration if it has one. The API token is masked by default;
102
- --reveal prints it in full — do not paste that output anywhere.
103
-
104
- cc list Table of all profiles
105
- cc list --json Same, as a JSON array
106
- cc list <profile> Detail for a single profile
107
- cc list <profile> --reveal Detail with the full API token shown
108
- cc list <profile> --json Detail as a JSON object (token always masked)
110
+ --reveal prints both the token and the full email — do not paste that output
111
+ anywhere.
112
+
113
+ cc list Table of all profiles (emails masked)
114
+ cc list --reveal Table with full emails shown
115
+ cc list --json Same, as a JSON array (always masked)
116
+ cc list <profile> Detail for a single profile
117
+ cc list <profile> --reveal Detail with the full token + email shown
118
+ cc list <profile> --json Detail as a JSON object (always masked)
109
119
  `,
110
- migrate: `cc migrate <src> <dest> [--force]
120
+ copy: `cc copy default <profile> [--force]
111
121
 
112
- Copy artifact directories from a source to a destination. This is a COPY — the
113
- source is left untouched. sessions/ is never migrated (transient per-process state).
122
+ Copy ~/.claude/ resources into a profile. Useful only when the profile has
123
+ 'unlinked' one or more resources (private content); for fresh v0.4.1
124
+ profiles every resource is symlinked, so 'cc copy' is a no-op.
114
125
 
115
- <src>: default | shared
116
- default = ~/.claude/ (Claude's built-in non-profile data)
117
- shared = ~/.claude-shared/ (the cc shared pool)
118
- <dest>: shared | <profile-name>
126
+ <src>: default (= ~/.claude/, Claude Code's own config dir)
127
+ <dest>: <profile-name>
119
128
 
120
- Directories copied in all cases:
121
- projects/, skills/, agents/, commands/
129
+ Resources considered (same 7 as everywhere):
130
+ --projects projects/
131
+ --tools skills/ agents/ commands/
132
+ --config mcp.json settings.json CLAUDE.md
122
133
 
123
- Extra files copied only when src=default and dest=<profile>:
124
- mcp.json, settings.json, CLAUDE.md
125
- (These carry per-profile config and are NOT copied to shared.)
134
+ For each resource:
135
+ - If the destination is shared via symlink/hard-link to the source → skipped
136
+ - Else, copy with skip-on-collision (entries already at the destination win)
126
137
 
127
- Never copied: .credentials.json, .claude.json, sessions/, plugins/
128
-
129
- Collision behavior (default): existing entries at the destination are silently
130
- skipped — the source entry is left alone.
131
-
132
- --force Overwrite existing entries instead of skipping.
133
- Warning: existing content at the destination will be cleared.
138
+ --force Overwrite collisions at the destination instead of skipping.
134
139
 
135
140
  Examples:
136
- cc migrate default work # copy ~/.claude/* to ~/.claude-profiles/work/
137
- cc migrate default shared # copy dirs to ~/.claude-shared/
138
- cc migrate shared work # copy shared dirs to ~/.claude-profiles/work/
139
- cc migrate default work --force # same, overwriting any existing items
141
+ cc copy default work # only acts on unlinked resources
142
+ cc copy default work --force # overwrite collisions
143
+
144
+ Removed in v0.4: 'shared' as src/dest. The shared pool IS ~/.claude/ in v0.4
145
+ and is reached via symlinks. Use 'cc link' / 'cc unlink' to manage sharing.
146
+ Note: 'cc migrate' is a deprecated alias for 'cc copy' — it still works but
147
+ will be removed in a future version.
140
148
  `,
141
- link: `cc link <profile> [<profile>...]
149
+ link: `cc link <profile> [<profile>...] [--projects|--tools|--config]
142
150
 
143
- Replace each profile's artifact directories with links to ~/.claude-shared/:
144
- projects/, skills/, agents/, commands/
151
+ (Re-)link a profile's resources into the shared pool at ~/.claude/. 'cc add'
152
+ already does this for new profiles; 'cc link' is for re-applying it after
153
+ 'cc unlink' or for older profiles that pre-date v0.4.1.
145
154
 
146
- Existing content in each directory is migrated into shared first; collisions
147
- are renamed with a __<profile> suffix (never overwritten). sessions/ (transient
148
- per-process state) is intentionally never linked — it stays per-profile.
155
+ No flag link all 7 resources
156
+ --projects projects/
157
+ --tools skills/, agents/, commands/
158
+ --config mcp.json, settings.json, CLAUDE.md
159
+ (flags can be combined: --tools --config)
149
160
 
150
- On Windows, link uses directory junctions (no admin/Developer Mode needed).
151
- On macOS/Linux, regular symlinks.
161
+ Refuses to overwrite private content: if the profile has a non-empty resource
162
+ that isn't already a symlink, that resource is reported as 'refused' and left
163
+ alone — move or remove it manually, then re-run 'cc link'. The other
164
+ resources are still linked.
152
165
 
153
- Multiple profiles can be linked at once: 'cc link a b c'. Failures are handled
154
- per directory and per profile: one directory failing does not stop the other
155
- three, and one profile failing does not stop the rest of the batch. A
156
- per-directory summary is printed; exit code is 1 if anything failed.
166
+ On Windows, dirs use junctions (no admin/Developer Mode needed). File
167
+ symlinks may require Developer Mode; without it, files fall back to hard
168
+ links so editing-in-place still propagates between profiles.
169
+
170
+ Examples:
171
+ cc link work # link all 7
172
+ cc link work --tools # only skills/, agents/, commands/
173
+ cc link work --config # only mcp.json, settings.json, CLAUDE.md
174
+ cc link a b c # link 3 profiles in one shot
157
175
  `,
158
- unlink: `cc unlink <profile>
176
+ unlink: `cc unlink <profile> [--projects|--tools|--config]
177
+
178
+ Break the symlinks and copy current ~/.claude/ content into the profile as
179
+ private resources. Non-destructive: same-name items already private in the
180
+ profile are kept, so re-running unlink is idempotent.
159
181
 
160
- Remove each linked artifact directory (projects/, skills/, agents/, commands/)
161
- and copy the shared content back into the profile as private dirs.
182
+ No flag unlink all 7 resources
183
+ --projects projects/
184
+ --tools skills/, agents/, commands/
185
+ --config mcp.json, settings.json, CLAUDE.md
186
+
187
+ After unlinking, the profile's resource diverges from ~/.claude/. Other
188
+ profiles still see the shared pool.
189
+
190
+ Examples:
191
+ cc unlink work # full private profile
192
+ cc unlink work --config # only mcp.json / settings.json / CLAUDE.md private
193
+ cc unlink work --tools --config # skills, agents, commands AND config private
162
194
  `,
163
- remove: `cc remove <profile>
195
+ remove: `cc remove <profile> [--force]
164
196
 
165
197
  Delete a profile and everything inside its directory: credentials, sessions,
166
- projects, plugins, and history. To confirm, you must type the exact profile
167
- name — y/n is intentionally not accepted because credential loss is unrecoverable.
198
+ and any private resources. To confirm, you must type the exact profile name —
199
+ y/n is intentionally not accepted because credential loss is unrecoverable.
168
200
 
169
- If the profile's projects/ is a link to ~/.claude-shared/, only the link is
170
- removed — the shared target is preserved.
201
+ If a profile's resources are symlinks to ~/.claude/, only the symlinks are
202
+ removed — the shared content in ~/.claude/ is preserved for other profiles.
203
+ If any resource is PRIVATE (unlinked), --force is required because its
204
+ content is unique to this profile and will be lost forever.
171
205
  `,
172
206
  doctor: `cc doctor [--fix [--force]] [--json]
173
207
 
174
208
  Run diagnostic checks across four groups:
175
209
 
176
- Environment Node >=18, claude on PATH, claude >=2.1.140, no conflicting env vars, no cc shadow
177
- Storage ~/.claude-profiles/ writable, .claude.json parseable, junctions/symlinks valid
178
- Platform Windows: OneDrive home, WindowsApps stub, stale ~/bin/cc; Git Bash: cygpath
210
+ Environment Node >=18, claude on PATH, claude version, no conflicting env vars
211
+ Storage ~/.claude-profiles/ writable, .claude.json parseable, every
212
+ profile's 7 resource links present and valid; legacy
213
+ ~/.claude-shared/ folded into ~/.claude/ if still present
214
+ Platform Windows: OneDrive home, WindowsApps stub, stale ~/bin/cc;
215
+ Git Bash: cygpath
179
216
  Sanity Test-spawn claude --version through the same code path cc use uses
180
217
 
181
218
  Exit codes: 0 = all ok, 1 = warnings, 2 = errors.
182
219
 
183
- --fix Auto-apply safe fixes: create missing dirs, recreate broken junctions
184
- --fix --force Also apply confirmation-required fixes (e.g. reset a corrupt .claude.json)
220
+ --fix Auto-apply safe fixes: create missing dirs, recreate broken
221
+ links, backfill missing resource links
222
+ --fix --force Also apply confirmation-required fixes (e.g. reset corrupt
223
+ .claude.json)
185
224
  --json Machine-readable output for CI / scripting
186
225
  `,
187
226
  help: `cc help [command]
@@ -199,10 +238,13 @@ function usageError(msg) {
199
238
 
200
239
  function printHelp(cmd) {
201
240
  if (cmd && Object.prototype.hasOwnProperty.call(HELP_BY_CMD, cmd)) {
241
+ // Per-command help: no banner, just the detail.
202
242
  process.stdout.write(HELP_BY_CMD[cmd]);
203
- } else {
204
- process.stdout.write(HELP);
243
+ return;
205
244
  }
245
+ // Top-level help: banner + summary.
246
+ process.stdout.write(renderBanner());
247
+ process.stdout.write(HELP);
206
248
  }
207
249
 
208
250
  function prompt(question) {
@@ -274,6 +316,16 @@ function parseFlagsStrict(args, flagNames, { allowPositional = false } = {}) {
274
316
  return { flags, positional };
275
317
  }
276
318
 
319
+ // Count how many of the 7 shared resources a profile currently has linked
320
+ // (symlink OR — on Windows file fallback — hard link).
321
+ function countSharedResources(profileDir) {
322
+ let n = 0;
323
+ for (const r of share.SHARED_RESOURCES) {
324
+ if (share.isSharedResource(profileDir, r)) n += 1;
325
+ }
326
+ return n;
327
+ }
328
+
277
329
  // Flag name -> .cc-env.json field, for `cc add --custom`.
278
330
  const ENDPOINT_VALUE_FLAGS = {
279
331
  '--base-url': 'base_url',
@@ -324,45 +376,40 @@ async function cmdAdd(rest) {
324
376
  );
325
377
  }
326
378
 
327
- // Plain subscription profile.
379
+ // Fold any legacy ~/.claude-shared/ pool into ~/.claude/ before any link work.
380
+ runLegacyPoolMigration();
381
+
328
382
  if (!custom) {
329
383
  const { dir } = profiles.addProfile(name);
330
384
  console.log(`Created profile "${name}" at ${dir}.`);
385
+ autoLinkArtifacts(name);
331
386
  console.log(`Next: cc use ${name}`);
332
387
  return 0;
333
388
  }
334
389
 
335
- // Custom endpoint profile. A fresh profile is created and configured; an
336
- // existing endpoint profile is edited in place. Either way the directory
337
- // must exist before runConfigure reads/writes .cc-env.json inside it.
390
+ // Custom endpoint profile.
338
391
  const dir = profiles.profileDir(name);
339
392
  const existed = fs.existsSync(dir);
340
- if (!existed) profiles.addProfile(name);
393
+ if (!existed) {
394
+ profiles.addProfile(name);
395
+ autoLinkArtifacts(name);
396
+ }
341
397
 
342
398
  let result;
343
399
  try {
344
400
  result = await envLib.runConfigure(name, dir, provided);
345
401
  } catch (err) {
346
- // A freshly-created profile that could not be configured leaves nothing
347
- // behind — `cc add --custom` is atomic.
348
402
  if (!existed) {
349
403
  try {
350
404
  removeRecursivelyPreservingLinks(dir);
351
- } catch {
352
- // best-effort cleanup
353
- }
405
+ } catch { /* best-effort */ }
354
406
  }
355
407
  throw err;
356
408
  }
357
409
 
358
410
  if (!result.written) {
359
- // Wizard cancelled.
360
411
  if (!existed) {
361
- try {
362
- removeRecursivelyPreservingLinks(dir);
363
- } catch {
364
- // best-effort cleanup
365
- }
412
+ try { removeRecursivelyPreservingLinks(dir); } catch { /* best-effort */ }
366
413
  console.log(`Cancelled. Profile "${name}" was not created.`);
367
414
  } else {
368
415
  console.log('Cancelled. No changes written.');
@@ -374,6 +421,66 @@ async function cmdAdd(rest) {
374
421
  return result.code;
375
422
  }
376
423
 
424
+ // One-time v0.3 → v0.4 migration. Folds ~/.claude-shared/ into ~/.claude/ and
425
+ // re-points existing profile symlinks. Quiet when there's no legacy pool.
426
+ function runLegacyPoolMigration() {
427
+ let result;
428
+ try {
429
+ result = share.migrateLegacyPool({ log: (line) => console.log(line) });
430
+ } catch (err) {
431
+ process.stderr.write(
432
+ color.stderr.yellow(` warning: legacy pool migration failed: ${err.message}`) + '\n',
433
+ );
434
+ return;
435
+ }
436
+ if (!result.migrated) return;
437
+ const moved = result.moves.length;
438
+ const relinked = result.relinked.length;
439
+ console.log(
440
+ `Migrated legacy ~/.claude-shared/ into ~/.claude/ ` +
441
+ `(${moved} item(s) moved, ${relinked} profile link(s) repointed).`,
442
+ );
443
+ for (const f of result.failures) {
444
+ process.stderr.write(
445
+ color.stderr.yellow(` warning: ${JSON.stringify(f)}`) + '\n',
446
+ );
447
+ }
448
+ }
449
+
450
+ // On profile creation, symlink all 7 resources into ~/.claude/. Failures emit
451
+ // a warning but do NOT abort cc add — the profile is still usable, and the
452
+ // user can re-run `cc link <name>` to retry.
453
+ function autoLinkArtifacts(name) {
454
+ let results;
455
+ try {
456
+ results = share.shareLink(name);
457
+ } catch (err) {
458
+ process.stderr.write(
459
+ color.stderr.yellow(
460
+ ` warning: could not link resources for "${name}": ${err.message}`,
461
+ ) + '\n',
462
+ );
463
+ return;
464
+ }
465
+ const failed = results.filter((r) => r.status === 'failed' || r.status === 'refused');
466
+ const linked = results.length - failed.length;
467
+ if (failed.length === 0) {
468
+ console.log(` Linked ${linked}/${results.length} resources to ~/.claude/.`);
469
+ return;
470
+ }
471
+ console.log(` Linked ${linked}/${results.length} resources to ~/.claude/.`);
472
+ for (const r of failed) {
473
+ process.stderr.write(
474
+ color.stderr.yellow(` warning: ${r.resource} not linked — ${r.message}`) + '\n',
475
+ );
476
+ }
477
+ process.stderr.write(
478
+ color.stderr.yellow(
479
+ ` Re-run \`cc link ${name}\` after resolving the above.`,
480
+ ) + '\n',
481
+ );
482
+ }
483
+
377
484
  async function cmdUse(rest) {
378
485
  const [name, ...passthrough] = rest;
379
486
  if (!name) throw usageError('Usage: cc use <profile> [...args]');
@@ -385,10 +492,8 @@ async function cmdUse(rest) {
385
492
  throw err;
386
493
  }
387
494
 
388
- // If the profile carries a custom-endpoint config, inject the corresponding
389
- // env vars so Claude Code routes through the third-party provider.
390
495
  let extraEnv = {};
391
- const envConfig = envLib.readEnvConfig(dir); // throws on a malformed file
496
+ const envConfig = envLib.readEnvConfig(dir);
392
497
  if (envConfig) {
393
498
  extraEnv = envLib.buildEnvVars(envConfig);
394
499
  const host = envConfig.base_url ? envLib.hostOf(envConfig.base_url) : '(unknown)';
@@ -413,12 +518,11 @@ function cmdListOne(name, { json, reveal }) {
413
518
  throw e;
414
519
  }
415
520
 
416
- const total = share.SHARED_SUBDIRS.length;
417
- const sharedCount = share.SHARED_SUBDIRS.filter((sub) =>
418
- isLinkLike(path.join(dir, sub)),
419
- ).length;
521
+ const total = share.SHARED_RESOURCES.length;
522
+ const sharedCount = countSharedResources(dir);
420
523
  const loggedIn = profiles.hasCredentials(dir);
421
524
  const email = profiles.readEmail(dir);
525
+ const displayEmail = reveal ? email : (email ? envLib.maskEmail(email) : email);
422
526
 
423
527
  let config = null;
424
528
  let configError = false;
@@ -433,14 +537,13 @@ function cmdListOne(name, { json, reveal }) {
433
537
  name,
434
538
  dir,
435
539
  loggedIn,
436
- email,
540
+ email: displayEmail,
437
541
  shared: sharedCount,
438
542
  endpoint: envLib.endpointSummary(dir),
439
543
  };
440
544
  if (config) {
441
545
  obj.endpointConfig = {
442
546
  base_url: config.base_url || null,
443
- // The token is always masked in JSON output — scripts never need it.
444
547
  auth_token: config.auth_token ? envLib.maskToken(config.auth_token) : null,
445
548
  model: config.model || null,
446
549
  opus: config.opus || config.model || null,
@@ -456,7 +559,7 @@ function cmdListOne(name, { json, reveal }) {
456
559
  console.log(`Profile: ${name}`);
457
560
  console.log(`Directory: ${dir}`);
458
561
  console.log(`LoggedIn: ${loggedIn ? 'true' : 'false'}`);
459
- console.log(`Email: ${email || '-'}`);
562
+ console.log(`Email: ${displayEmail || '-'}`);
460
563
  console.log(`Shared: ${sharedCount}/${total}`);
461
564
 
462
565
  if (configError) {
@@ -484,16 +587,18 @@ function cmdListOne(name, { json, reveal }) {
484
587
  console.log(` Subagent: ${config.subagent || config.model || '-'}`);
485
588
  if (reveal) {
486
589
  console.log('');
487
- console.log('WARNING: the full API key is printed above. Do not paste this output');
488
- console.log('into chats, issues, screenshots, or logs.');
590
+ console.log('WARNING: the full API key and email are printed above. Do not paste this');
591
+ console.log('output into chats, issues, screenshots, or logs.');
489
592
  }
490
593
  return 0;
491
594
  }
492
595
 
493
596
  function cmdList(rest) {
494
- const { flags, positional } = parseFlagsStrict(rest, ['--json', '--reveal'], {
495
- allowPositional: true,
496
- });
597
+ const { flags, positional } = parseFlagsStrict(
598
+ rest,
599
+ ['--json', '--reveal'],
600
+ { allowPositional: true },
601
+ );
497
602
  const json = flags.has('--json');
498
603
  const reveal = flags.has('--reveal');
499
604
  const [name, ...extra] = positional;
@@ -502,28 +607,26 @@ function cmdList(rest) {
502
607
  if (name) {
503
608
  return cmdListOne(name, { json, reveal });
504
609
  }
505
- if (reveal) {
506
- throw usageError('--reveal requires a profile name: cc list <profile> --reveal');
610
+ if (json && reveal) {
611
+ throw usageError('--reveal cannot be combined with --json.');
507
612
  }
508
613
 
509
614
  const list = profiles.listProfiles();
510
- const total = share.SHARED_SUBDIRS.length;
615
+ const total = share.SHARED_RESOURCES.length;
511
616
 
512
617
  const enriched = list.map((p) => {
513
- const sharedCount = share.SHARED_SUBDIRS.filter(sub =>
514
- isLinkLike(path.join(p.dir, sub))
515
- ).length;
618
+ const sharedCount = countSharedResources(p.dir);
516
619
  return {
517
620
  name: p.name,
518
621
  dir: p.dir,
519
622
  loggedIn: p.loggedIn,
520
- email: p.email,
623
+ email: reveal ? p.email : (p.email ? envLib.maskEmail(p.email) : p.email),
521
624
  shared: sharedCount,
522
625
  endpoint: envLib.endpointSummary(p.dir),
523
626
  };
524
627
  });
525
628
 
526
- if (flags.has('--json')) {
629
+ if (json) {
527
630
  process.stdout.write(JSON.stringify(enriched, null, 2) + '\n');
528
631
  return 0;
529
632
  }
@@ -548,9 +651,32 @@ function cmdList(rest) {
548
651
  return 0;
549
652
  }
550
653
 
654
+ // True if the profile has any private (non-shared, non-empty) resource that
655
+ // would be permanently lost on remove.
656
+ function hasPrivateContent(dir) {
657
+ for (const r of share.SHARED_RESOURCES) {
658
+ const p = path.join(dir, r);
659
+ if (!fs.existsSync(p)) continue;
660
+ if (share.isSharedResource(dir, r)) continue;
661
+ if (share.isFileResource(r)) {
662
+ try {
663
+ if (fs.statSync(p).size > 0) return true;
664
+ } catch { /* skip */ }
665
+ } else {
666
+ try {
667
+ if (fs.readdirSync(p).length > 0) return true;
668
+ } catch { /* skip */ }
669
+ }
670
+ }
671
+ return false;
672
+ }
673
+
551
674
  async function cmdRemove(rest) {
552
- const [name] = rest;
553
- if (!name) throw usageError('Usage: cc remove <profile>');
675
+ const { flags, positional } = parseFlagsStrict(rest, ['--force'], { allowPositional: true });
676
+ const [name, ...extra] = positional;
677
+ if (!name) throw usageError('Usage: cc remove <profile> [--force]');
678
+ if (extra.length > 0) throw usageError(`Unexpected argument: ${extra[0]}`);
679
+ const force = flags.has('--force');
554
680
  profiles.validateProfileName(name);
555
681
  const dir = profiles.profileDir(name);
556
682
  if (!fs.existsSync(dir)) {
@@ -558,13 +684,24 @@ async function cmdRemove(rest) {
558
684
  err.exitCode = 1;
559
685
  throw err;
560
686
  }
687
+
688
+ if (hasPrivateContent(dir) && !force) {
689
+ const err = new Error(
690
+ `Profile "${name}" has private (unlinked) resources whose content is not ` +
691
+ `in ~/.claude/. Removing the profile will delete that content permanently.\n` +
692
+ `Re-run with --force to confirm: cc remove ${name} --force`,
693
+ );
694
+ err.exitCode = 1;
695
+ throw err;
696
+ }
697
+
561
698
  console.log('This will permanently delete:');
562
699
  console.log(` ${dir}`);
563
- console.log('including credentials, sessions, projects, plugins, and history.');
564
- for (const sub of share.SHARED_SUBDIRS) {
700
+ console.log('including credentials, sessions, and any private resources.');
701
+ for (const sub of share.SHARED_RESOURCES) {
565
702
  const sub_p = path.join(dir, sub);
566
- if (isLinkLike(sub_p)) {
567
- console.log(` (${sub}/ is a link to shared storage — only the link will be removed)`);
703
+ if (share.isSharedResource(dir, sub)) {
704
+ console.log(` (${sub} is shared with ~/.claude/ — only the link will be removed)`);
568
705
  }
569
706
  }
570
707
  const answer = await prompt(`Type the profile name "${name}" to confirm: `);
@@ -582,34 +719,60 @@ async function cmdRemove(rest) {
582
719
  }
583
720
 
584
721
  async function cmdLink(rest) {
585
- if (rest.length === 0) throw usageError('Usage: cc link <profile> [<profile>...]');
586
- const multi = rest.length > 1;
722
+ const { flags, positional } = parseFlagsStrict(
723
+ rest,
724
+ ['--tools', '--projects', '--config'],
725
+ { allowPositional: true },
726
+ );
727
+ if (positional.length === 0) {
728
+ throw usageError(
729
+ 'Usage: cc link <profile> [<profile>...] [--projects|--tools|--config]',
730
+ );
731
+ }
732
+ const opts = {
733
+ tools: flags.has('--tools'),
734
+ projects: flags.has('--projects'),
735
+ config: flags.has('--config'),
736
+ };
737
+ const multi = positional.length > 1;
587
738
  const summary = [];
588
- for (const name of rest) {
739
+ for (const name of positional) {
589
740
  if (multi) console.log(`\n[${name}]`);
590
741
  let results;
591
742
  try {
592
- results = share.shareLink(name);
743
+ results = share.shareLink(name, opts);
593
744
  } catch (err) {
594
- // Profile-level failure (invalid name, profile missing) — nothing linked.
595
745
  process.stderr.write(color.stderr.red(` error: ${err.message}`) + '\n');
596
- summary.push({ name, linked: 0, total: share.SHARED_SUBDIRS.length, error: err.message });
746
+ summary.push({ name, linked: 0, total: 0, error: err.message });
597
747
  continue;
598
748
  }
599
749
  for (const r of results) {
750
+ const label = share.isFileResource(r.resource) ? r.resource : `${r.resource}/`;
600
751
  if (r.status === 'failed') {
601
- process.stderr.write(color.stderr.red(` ${r.sub}: failed — ${r.message}`) + '\n');
752
+ process.stderr.write(color.stderr.red(` ${label}: failed — ${r.message}`) + '\n');
753
+ } else if (r.status === 'refused') {
754
+ process.stderr.write(color.stderr.yellow(` ${label}: refused — ${r.message}`) + '\n');
602
755
  } else if (r.status === 'already-linked') {
603
- console.log(` ${r.sub}: already linked`);
756
+ console.log(` ${label}: already linked`);
757
+ } else if (r.relinkedFromLegacy) {
758
+ console.log(` ${label}: relinked (was pointing at legacy ~/.claude-shared/)`);
604
759
  } else {
605
- const note = r.moved ? ` (migrated ${r.moved} item(s) into shared)` : '';
606
- console.log(` ${r.sub}: linked${note}`);
760
+ const note = r.kind === 'hardlink' ? ' (hard link — enable Windows Developer Mode for a true symlink)' : '';
761
+ console.log(` ${label}: linked${note}`);
607
762
  }
608
763
  }
609
- const failed = results.filter((r) => r.status === 'failed');
764
+ const failed = results.filter((r) => r.status === 'failed' || r.status === 'refused');
610
765
  const linked = results.length - failed.length;
611
- console.log(` ${linked}/${results.length} directories linked${failed.length ? `, ${failed.length} failed` : ''}.`);
612
- summary.push({ name, linked, total: results.length, failed: failed.map((f) => f.sub) });
766
+ console.log(
767
+ ` ${linked}/${results.length} linked` +
768
+ (failed.length ? `, ${failed.length} not linked` : '') + '.',
769
+ );
770
+ summary.push({
771
+ name,
772
+ linked,
773
+ total: results.length,
774
+ failed: failed.map((f) => f.resource),
775
+ });
613
776
  }
614
777
 
615
778
  const anyFailure = summary.some((s) => s.error || (s.failed && s.failed.length));
@@ -619,7 +782,7 @@ async function cmdLink(rest) {
619
782
  if (s.error) {
620
783
  console.log(` ${s.name}: error — ${s.error}`);
621
784
  } else if (s.failed && s.failed.length) {
622
- console.log(` ${s.name}: ${s.linked}/${s.total} linked (failed: ${s.failed.join(', ')})`);
785
+ console.log(` ${s.name}: ${s.linked}/${s.total} linked (not linked: ${s.failed.join(', ')})`);
623
786
  } else {
624
787
  console.log(` ${s.name}: ${s.linked}/${s.total} linked`);
625
788
  }
@@ -629,18 +792,32 @@ async function cmdLink(rest) {
629
792
  }
630
793
 
631
794
  async function cmdUnlink(rest) {
632
- const [name] = rest;
633
- if (!name) throw usageError('Usage: cc unlink <profile>');
634
- const results = share.shareUnlink(name);
795
+ const { flags, positional } = parseFlagsStrict(
796
+ rest,
797
+ ['--tools', '--projects', '--config'],
798
+ { allowPositional: true },
799
+ );
800
+ const [name, ...extra] = positional;
801
+ if (!name) {
802
+ throw usageError('Usage: cc unlink <profile> [--projects|--tools|--config]');
803
+ }
804
+ if (extra.length > 0) throw usageError(`Unexpected argument: ${extra[0]}`);
805
+ const opts = {
806
+ tools: flags.has('--tools'),
807
+ projects: flags.has('--projects'),
808
+ config: flags.has('--config'),
809
+ };
810
+ const results = share.shareUnlink(name, opts);
635
811
  let anyFailure = false;
636
812
  for (const r of results) {
813
+ const label = share.isFileResource(r.resource) ? r.resource : `${r.resource}/`;
637
814
  if (r.status === 'failed') {
638
815
  anyFailure = true;
639
- process.stderr.write(color.stderr.red(` ${r.sub}: failed — ${r.message}`) + '\n');
816
+ process.stderr.write(color.stderr.red(` ${label}: failed — ${r.message}`) + '\n');
640
817
  } else if (r.status === 'not-linked') {
641
- console.log(` ${r.sub}: not linked, skipping`);
818
+ console.log(` ${label}: not linked, skipping`);
642
819
  } else {
643
- console.log(` ${r.sub}: unlinked, content copied back`);
820
+ console.log(` ${label}: unlinked, content copied back`);
644
821
  }
645
822
  }
646
823
  return anyFailure ? 1 : 0;
@@ -656,19 +833,31 @@ async function cmdDoctor(rest) {
656
833
  return doctor.runDoctor({ fix, force, json, prompt });
657
834
  }
658
835
 
659
- async function cmdMigrate(rest) {
836
+ async function cmdCopy(rest) {
660
837
  const { flags, positional } = parseFlagsStrict(rest, ['--force'], { allowPositional: true });
661
838
  const [src, dest, ...extra] = positional;
662
- if (!src || !dest) throw usageError('Usage: cc migrate <src> <dest> [--force]');
839
+ if (!src || !dest) throw usageError('Usage: cc copy <src> <dest> [--force]');
663
840
  if (extra.length > 0) throw usageError(`Unexpected extra arguments: ${extra.join(' ')}`);
664
841
  const force = flags.has('--force');
665
842
  if (force) {
666
- process.stdout.write('Warning: --force is set. Existing items at the destination will be overwritten and their current content will be cleared.\n');
843
+ process.stdout.write(
844
+ 'Warning: --force is set. Existing items at the destination will be overwritten.\n',
845
+ );
667
846
  }
668
- migrateLib.migrate(src, dest, { force });
847
+ copyLib.copy(src, dest, { force });
669
848
  return 0;
670
849
  }
671
850
 
851
+ async function cmdMigrate(rest) {
852
+ process.stderr.write(
853
+ color.stderr.yellow(
854
+ "[cchost] 'cc migrate' is deprecated — use 'cc copy' instead. " +
855
+ 'This alias will be removed in a future version.',
856
+ ) + '\n',
857
+ );
858
+ return cmdCopy(rest);
859
+ }
860
+
672
861
  async function run(argv) {
673
862
  const [action, ...rest] = argv;
674
863
  if (!action || action === '--help' || action === '-h') {
@@ -685,7 +874,6 @@ async function run(argv) {
685
874
  return 0;
686
875
  }
687
876
  if (action === 'env') {
688
- // `cc env` was removed in v0.3.1 — point upgraders at the new commands.
689
877
  process.stderr.write(
690
878
  color.stderr.red(
691
879
  "'cc env' was removed in v0.3.1. Configure a custom endpoint with " +
@@ -701,6 +889,8 @@ async function run(argv) {
701
889
  return await cmdUse(rest);
702
890
  case 'list':
703
891
  return cmdList(rest);
892
+ case 'copy':
893
+ return await cmdCopy(rest);
704
894
  case 'migrate':
705
895
  return await cmdMigrate(rest);
706
896
  case 'link':