cchost 0.3.0 → 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/cli.js CHANGED
@@ -6,46 +6,83 @@ 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> Create a profile directory
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 [--json] Show all profiles: login state, email, shared-dir count (N/4), endpoint
21
- cc env <profile> [show] [...] Configure a custom API endpoint for a profile (wizard or flags)
22
- cc migrate <src> <dest> [--force] Copy artifact dirs (and config files) between profiles
23
- cc link <p>... Link each profile's artifact dirs to ~/.claude-shared/
24
- cc unlink <profile> Restore a profile's private artifact dirs
25
- 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
26
26
  cc doctor [--fix [--force]] [--json] Diagnose environment, storage, and platform issues
27
27
  cc help [command] Show usage (optionally for a single command)
28
28
  cc --version Print the installed cchost version
29
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
+
30
38
  Profiles live in ~/.claude-profiles/<profile>/ and become CLAUDE_CONFIG_DIR
31
39
  for that launch. Args after the profile name are forwarded verbatim to claude.
32
40
  `;
33
41
 
34
42
  const HELP_BY_CMD = {
35
- add: `cc add <profile>
43
+ add: `cc add <profile> [--custom [--base-url=URL --token=TOKEN ...]]
44
+
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.
49
+
50
+ cc add <profile>
51
+ Create a regular profile. It logs in with your Anthropic
52
+ subscription (OAuth) on the first 'cc use'.
53
+
54
+ cc add <profile> --custom
55
+ Create a profile routed through a custom, Anthropic-compatible API
56
+ endpoint — Moonshot/Kimi, OpenRouter, a self-hosted vLLM, a corporate
57
+ proxy, and so on. Opens an interactive wizard; the API token is never
58
+ echoed to the screen.
59
+
60
+ cc add <profile> --custom --base-url=URL --token=TOKEN
61
+ [--model=NAME] [--opus=NAME] [--sonnet=NAME]
62
+ [--haiku=NAME] [--subagent=NAME]
63
+ Same, non-interactive. --base-url and --token are required.
64
+
65
+ Re-running 'cc add <profile> --custom' on an existing endpoint profile EDITS
66
+ its configuration. View any profile with 'cc list <profile>'.
36
67
 
37
- Create a new profile directory without launching Claude.
68
+ Billing: a custom-endpoint profile bills through the third-party provider,
69
+ NOT your Anthropic subscription. A profile is born subscription or endpoint
70
+ and stays that way.
71
+
72
+ The endpoint config lives at ~/.claude-profiles/<profile>/.cc-env.json with
73
+ user-only permissions. It is never linked and never copied.
38
74
 
39
75
  Examples:
40
76
  cc add work
41
- cc add personal
77
+ cc add kimi --custom
78
+ cc add kimi --custom --base-url=https://api.moonshot.ai/anthropic --token=sk-... --model=kimi-k2.5
42
79
  `,
43
80
  use: `cc use <profile> [...args]
44
81
 
45
82
  Launch Claude under a named profile. The profile must already exist (create
46
83
  it with 'cc add <profile>'). Its directory under ~/.claude-profiles/<profile>/
47
- becomes CLAUDE_CONFIG_DIR for the spawned claude — credentials, sessions,
48
- 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.
49
86
 
50
87
  All args after the profile name are forwarded verbatim to claude.
51
88
 
@@ -54,133 +91,136 @@ Examples:
54
91
  cc use work --resume
55
92
  cc use personal -p "summarize this codebase"
56
93
  `,
57
- list: `cc list [--json]
94
+ list: `cc list [<profile>] [--json] [--reveal]
95
+
96
+ Without a profile name: list every profile as a table.
58
97
 
59
98
  Columns:
60
99
  Profile - profile name
61
100
  LoggedIn - true if .credentials.json exists
62
- Email - account email from .claude.json; - if not yet logged in
63
- Shared - N/4 linked to ~/.claude-shared/ (projects, skills, agents, commands)
64
- 4/4 = fully shared, 0/4 = fully isolated
65
- (sessions/ is transient per-process state and is never shared)
66
- Endpoint - custom-endpoint host from .cc-env.json, or "subscription" if the
67
- profile uses Anthropic's subscription OAuth
68
-
69
- With --json, emits an array of {name, dir, loggedIn, email, shared, endpoint}.
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
107
+
108
+ With a profile name: print that one profile's detail, including its custom
109
+ endpoint configuration if it has one. The API token is masked by default;
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)
70
119
  `,
71
- env: `cc env <profile> [show] [...]
72
-
73
- Configure a custom, Anthropic-compatible API endpoint for a profile. This
74
- routes the profile through a third-party provider (Moonshot/Kimi, OpenRouter,
75
- vLLM, a corporate proxy, ...) instead of Anthropic's subscription OAuth.
120
+ copy: `cc copy default <profile> [--force]
76
121
 
77
- Billing: an endpoint profile bills through the third-party provider, NOT your
78
- Anthropic subscription.
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.
79
125
 
80
- Forms:
81
- cc env <profile>
82
- Interactive wizard. Prompts for every field; the token is not echoed.
83
- On an existing endpoint profile, the wizard runs in edit mode and
84
- prefills current values.
126
+ <src>: default (= ~/.claude/, Claude Code's own config dir)
127
+ <dest>: <profile-name>
85
128
 
86
- cc env <profile> --base-url=URL --token=TOKEN
87
- [--model=NAME] [--opus=NAME] [--sonnet=NAME]
88
- [--haiku=NAME] [--subagent=NAME]
89
- Non-interactive. --base-url and --token are required when creating;
90
- when editing, only the flags you pass are changed.
129
+ Resources considered (same 7 as everywhere):
130
+ --projects projects/
131
+ --tools skills/ agents/ commands/
132
+ --config mcp.json settings.json CLAUDE.md
91
133
 
92
- cc env <profile> show [--reveal]
93
- Print the endpoint configuration. The token is masked by default.
94
- --reveal prints the FULL API key — do not paste that output anywhere.
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)
95
137
 
96
- A profile is born either OAuth or endpoint and stays that way. cc env refuses
97
- to run on a profile that already has OAuth credentials. To stop using an
98
- endpoint, run 'cc remove <profile>' and recreate the profile.
99
-
100
- The config lives at ~/.claude-profiles/<profile>/.cc-env.json with user-only
101
- permissions. It is never migrated and never linked.
138
+ --force Overwrite collisions at the destination instead of skipping.
102
139
 
103
140
  Examples:
104
- cc add kimi
105
- cc env kimi --base-url=https://api.moonshot.ai/anthropic --token=sk-... --model=kimi-k2.5
106
- cc env kimi show
107
- cc use kimi
108
- `,
109
- migrate: `cc migrate <src> <dest> [--force]
110
-
111
- Copy artifact directories from a source to a destination. This is a COPY — the
112
- source is left untouched. sessions/ is never migrated (transient per-process state).
141
+ cc copy default work # only acts on unlinked resources
142
+ cc copy default work --force # overwrite collisions
113
143
 
114
- <src>: default | shared
115
- default = ~/.claude/ (Claude's built-in non-profile data)
116
- shared = ~/.claude-shared/ (the cc shared pool)
117
- <dest>: shared | <profile-name>
118
-
119
- Directories copied in all cases:
120
- projects/, skills/, agents/, commands/
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.
148
+ `,
149
+ link: `cc link <profile> [<profile>...] [--projects|--tools|--config]
121
150
 
122
- Extra files copied only when src=default and dest=<profile>:
123
- mcp.json, settings.json, CLAUDE.md
124
- (These carry per-profile config and are NOT copied to shared.)
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.
125
154
 
126
- Never copied: .credentials.json, .claude.json, sessions/, plugins/
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)
127
160
 
128
- Collision behavior (default): existing entries at the destination are silently
129
- skipped — the source entry is left alone.
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.
130
165
 
131
- --force Overwrite existing entries instead of skipping.
132
- Warning: existing content at the destination will be cleared.
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.
133
169
 
134
170
  Examples:
135
- cc migrate default work # copy ~/.claude/* to ~/.claude-profiles/work/
136
- cc migrate default shared # copy dirs to ~/.claude-shared/
137
- cc migrate shared work # copy shared dirs to ~/.claude-profiles/work/
138
- cc migrate default work --force # same, overwriting any existing items
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
139
175
  `,
140
- link: `cc link <profile> [<profile>...]
141
-
142
- Replace each profile's artifact directories with links to ~/.claude-shared/:
143
- projects/, skills/, agents/, commands/
176
+ unlink: `cc unlink <profile> [--projects|--tools|--config]
144
177
 
145
- Existing content in each directory is migrated into shared first; collisions
146
- are renamed with a __<profile> suffix (never overwritten). sessions/ (transient
147
- per-process state) is intentionally never linked — it stays per-profile.
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.
148
181
 
149
- On Windows, link uses directory junctions (no admin/Developer Mode needed).
150
- On macOS/Linux, regular symlinks.
182
+ No flag unlink all 7 resources
183
+ --projects projects/
184
+ --tools skills/, agents/, commands/
185
+ --config mcp.json, settings.json, CLAUDE.md
151
186
 
152
- Multiple profiles can be linked at once: 'cc link a b c'. Failures are handled
153
- per directory and per profile: one directory failing does not stop the other
154
- three, and one profile failing does not stop the rest of the batch. A
155
- per-directory summary is printed; exit code is 1 if anything failed.
156
- `,
157
- unlink: `cc unlink <profile>
187
+ After unlinking, the profile's resource diverges from ~/.claude/. Other
188
+ profiles still see the shared pool.
158
189
 
159
- Remove each linked artifact directory (projects/, skills/, agents/, commands/)
160
- and copy the shared content back into the profile as private dirs.
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
161
194
  `,
162
- remove: `cc remove <profile>
195
+ remove: `cc remove <profile> [--force]
163
196
 
164
197
  Delete a profile and everything inside its directory: credentials, sessions,
165
- projects, plugins, and history. To confirm, you must type the exact profile
166
- 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.
167
200
 
168
- If the profile's projects/ is a link to ~/.claude-shared/, only the link is
169
- 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.
170
205
  `,
171
206
  doctor: `cc doctor [--fix [--force]] [--json]
172
207
 
173
208
  Run diagnostic checks across four groups:
174
209
 
175
- Environment Node >=18, claude on PATH, claude >=2.1.140, no conflicting env vars, no cc shadow
176
- Storage ~/.claude-profiles/ writable, .claude.json parseable, junctions/symlinks valid
177
- 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
178
216
  Sanity Test-spawn claude --version through the same code path cc use uses
179
217
 
180
218
  Exit codes: 0 = all ok, 1 = warnings, 2 = errors.
181
219
 
182
- --fix Auto-apply safe fixes: create missing dirs, recreate broken junctions
183
- --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)
184
224
  --json Machine-readable output for CI / scripting
185
225
  `,
186
226
  help: `cc help [command]
@@ -198,10 +238,13 @@ function usageError(msg) {
198
238
 
199
239
  function printHelp(cmd) {
200
240
  if (cmd && Object.prototype.hasOwnProperty.call(HELP_BY_CMD, cmd)) {
241
+ // Per-command help: no banner, just the detail.
201
242
  process.stdout.write(HELP_BY_CMD[cmd]);
202
- } else {
203
- process.stdout.write(HELP);
243
+ return;
204
244
  }
245
+ // Top-level help: banner + summary.
246
+ process.stdout.write(renderBanner());
247
+ process.stdout.write(HELP);
205
248
  }
206
249
 
207
250
  function prompt(question) {
@@ -273,14 +316,169 @@ function parseFlagsStrict(args, flagNames, { allowPositional = false } = {}) {
273
316
  return { flags, positional };
274
317
  }
275
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
+
329
+ // Flag name -> .cc-env.json field, for `cc add --custom`.
330
+ const ENDPOINT_VALUE_FLAGS = {
331
+ '--base-url': 'base_url',
332
+ '--token': 'auth_token',
333
+ '--model': 'model',
334
+ '--opus': 'opus',
335
+ '--sonnet': 'sonnet',
336
+ '--haiku': 'haiku',
337
+ '--subagent': 'subagent',
338
+ };
339
+
276
340
  async function cmdAdd(rest) {
277
- const [name, ...extra] = rest;
278
- if (!name) throw usageError('Usage: cc add <profile>');
341
+ const positional = [];
342
+ const provided = {};
343
+ let custom = false;
344
+
345
+ for (const a of rest) {
346
+ if (a.startsWith('-')) {
347
+ const eq = a.indexOf('=');
348
+ const flagName = eq === -1 ? a : a.slice(0, eq);
349
+ if (flagName === '--custom') {
350
+ custom = true;
351
+ continue;
352
+ }
353
+ if (Object.prototype.hasOwnProperty.call(ENDPOINT_VALUE_FLAGS, flagName)) {
354
+ if (eq === -1) {
355
+ throw usageError(`Flag ${flagName} requires a value: ${flagName}=VALUE`);
356
+ }
357
+ provided[ENDPOINT_VALUE_FLAGS[flagName]] = a.slice(eq + 1);
358
+ continue;
359
+ }
360
+ throw usageError(`Unknown flag: ${flagName}`);
361
+ }
362
+ positional.push(a);
363
+ }
364
+
365
+ const [name, ...extra] = positional;
366
+ if (!name) {
367
+ throw usageError('Usage: cc add <profile> [--custom [--base-url=URL --token=TOKEN ...]]');
368
+ }
279
369
  if (extra.length > 0) throw usageError(`cc add takes no extra arguments: ${extra[0]}`);
280
- const { dir } = profiles.addProfile(name);
281
- console.log(`Created profile "${name}" at ${dir}.`);
282
- console.log(`Next: cc use ${name}`);
283
- return 0;
370
+ profiles.validateProfileName(name);
371
+
372
+ if (Object.keys(provided).length > 0 && !custom) {
373
+ throw usageError(
374
+ 'Endpoint flags (--base-url, --token, --model, ...) require --custom: ' +
375
+ `cc add ${name} --custom ...`,
376
+ );
377
+ }
378
+
379
+ // Fold any legacy ~/.claude-shared/ pool into ~/.claude/ before any link work.
380
+ runLegacyPoolMigration();
381
+
382
+ if (!custom) {
383
+ const { dir } = profiles.addProfile(name);
384
+ console.log(`Created profile "${name}" at ${dir}.`);
385
+ autoLinkArtifacts(name);
386
+ console.log(`Next: cc use ${name}`);
387
+ return 0;
388
+ }
389
+
390
+ // Custom endpoint profile.
391
+ const dir = profiles.profileDir(name);
392
+ const existed = fs.existsSync(dir);
393
+ if (!existed) {
394
+ profiles.addProfile(name);
395
+ autoLinkArtifacts(name);
396
+ }
397
+
398
+ let result;
399
+ try {
400
+ result = await envLib.runConfigure(name, dir, provided);
401
+ } catch (err) {
402
+ if (!existed) {
403
+ try {
404
+ removeRecursivelyPreservingLinks(dir);
405
+ } catch { /* best-effort */ }
406
+ }
407
+ throw err;
408
+ }
409
+
410
+ if (!result.written) {
411
+ if (!existed) {
412
+ try { removeRecursivelyPreservingLinks(dir); } catch { /* best-effort */ }
413
+ console.log(`Cancelled. Profile "${name}" was not created.`);
414
+ } else {
415
+ console.log('Cancelled. No changes written.');
416
+ }
417
+ return result.code;
418
+ }
419
+
420
+ if (!existed) console.log(`Next: cc use ${name}`);
421
+ return result.code;
422
+ }
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
+ );
284
482
  }
285
483
 
286
484
  async function cmdUse(rest) {
@@ -294,10 +492,8 @@ async function cmdUse(rest) {
294
492
  throw err;
295
493
  }
296
494
 
297
- // If the profile carries a custom-endpoint config, inject the corresponding
298
- // env vars so Claude Code routes through the third-party provider.
299
495
  let extraEnv = {};
300
- const envConfig = envLib.readEnvConfig(dir); // throws on a malformed file
496
+ const envConfig = envLib.readEnvConfig(dir);
301
497
  if (envConfig) {
302
498
  extraEnv = envLib.buildEnvVars(envConfig);
303
499
  const host = envConfig.base_url ? envLib.hostOf(envConfig.base_url) : '(unknown)';
@@ -308,26 +504,129 @@ async function cmdUse(rest) {
308
504
  return spawnClaude({ args: passthrough, profileDir: dir, extraEnv });
309
505
  }
310
506
 
507
+ // `cc list <profile>` — detail for a single profile, including its custom
508
+ // endpoint configuration if it has one.
509
+ function cmdListOne(name, { json, reveal }) {
510
+ if (json && reveal) {
511
+ throw usageError('--reveal cannot be combined with --json.');
512
+ }
513
+ profiles.validateProfileName(name);
514
+ const dir = profiles.profileDir(name);
515
+ if (!fs.existsSync(dir)) {
516
+ const e = new Error(`Profile "${name}" does not exist. Create it with: cc add ${name}`);
517
+ e.exitCode = 1;
518
+ throw e;
519
+ }
520
+
521
+ const total = share.SHARED_RESOURCES.length;
522
+ const sharedCount = countSharedResources(dir);
523
+ const loggedIn = profiles.hasCredentials(dir);
524
+ const email = profiles.readEmail(dir);
525
+ const displayEmail = reveal ? email : (email ? envLib.maskEmail(email) : email);
526
+
527
+ let config = null;
528
+ let configError = false;
529
+ try {
530
+ config = envLib.readEnvConfig(dir);
531
+ } catch {
532
+ configError = true;
533
+ }
534
+
535
+ if (json) {
536
+ const obj = {
537
+ name,
538
+ dir,
539
+ loggedIn,
540
+ email: displayEmail,
541
+ shared: sharedCount,
542
+ endpoint: envLib.endpointSummary(dir),
543
+ };
544
+ if (config) {
545
+ obj.endpointConfig = {
546
+ base_url: config.base_url || null,
547
+ auth_token: config.auth_token ? envLib.maskToken(config.auth_token) : null,
548
+ model: config.model || null,
549
+ opus: config.opus || config.model || null,
550
+ sonnet: config.sonnet || config.model || null,
551
+ haiku: config.haiku || config.model || null,
552
+ subagent: config.subagent || config.model || null,
553
+ };
554
+ }
555
+ process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
556
+ return 0;
557
+ }
558
+
559
+ console.log(`Profile: ${name}`);
560
+ console.log(`Directory: ${dir}`);
561
+ console.log(`LoggedIn: ${loggedIn ? 'true' : 'false'}`);
562
+ console.log(`Email: ${displayEmail || '-'}`);
563
+ console.log(`Shared: ${sharedCount}/${total}`);
564
+
565
+ if (configError) {
566
+ console.log('Endpoint: (invalid config — .cc-env.json is malformed)');
567
+ return 0;
568
+ }
569
+ if (!config) {
570
+ console.log('Endpoint: subscription');
571
+ return 0;
572
+ }
573
+
574
+ console.log(
575
+ `Endpoint: ${config.base_url ? envLib.hostOf(config.base_url) : '(invalid config)'}`,
576
+ );
577
+ console.log(` Base URL: ${config.base_url || '-'}`);
578
+ if (reveal) {
579
+ console.log(` Token: ${config.auth_token || '-'}`);
580
+ } else {
581
+ console.log(` Token: ${config.auth_token ? envLib.maskToken(config.auth_token) : '-'}`);
582
+ }
583
+ console.log(` Model: ${config.model || '-'}`);
584
+ console.log(` Opus: ${config.opus || config.model || '-'}`);
585
+ console.log(` Sonnet: ${config.sonnet || config.model || '-'}`);
586
+ console.log(` Haiku: ${config.haiku || config.model || '-'}`);
587
+ console.log(` Subagent: ${config.subagent || config.model || '-'}`);
588
+ if (reveal) {
589
+ console.log('');
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.');
592
+ }
593
+ return 0;
594
+ }
595
+
311
596
  function cmdList(rest) {
312
- const { flags } = parseFlagsStrict(rest, ['--json']);
597
+ const { flags, positional } = parseFlagsStrict(
598
+ rest,
599
+ ['--json', '--reveal'],
600
+ { allowPositional: true },
601
+ );
602
+ const json = flags.has('--json');
603
+ const reveal = flags.has('--reveal');
604
+ const [name, ...extra] = positional;
605
+ if (extra.length > 0) throw usageError(`Unexpected argument: ${extra[0]}`);
606
+
607
+ if (name) {
608
+ return cmdListOne(name, { json, reveal });
609
+ }
610
+ if (json && reveal) {
611
+ throw usageError('--reveal cannot be combined with --json.');
612
+ }
613
+
313
614
  const list = profiles.listProfiles();
314
- const total = share.SHARED_SUBDIRS.length;
615
+ const total = share.SHARED_RESOURCES.length;
315
616
 
316
617
  const enriched = list.map((p) => {
317
- const sharedCount = share.SHARED_SUBDIRS.filter(sub =>
318
- isLinkLike(path.join(p.dir, sub))
319
- ).length;
618
+ const sharedCount = countSharedResources(p.dir);
320
619
  return {
321
620
  name: p.name,
322
621
  dir: p.dir,
323
622
  loggedIn: p.loggedIn,
324
- email: p.email,
623
+ email: reveal ? p.email : (p.email ? envLib.maskEmail(p.email) : p.email),
325
624
  shared: sharedCount,
326
625
  endpoint: envLib.endpointSummary(p.dir),
327
626
  };
328
627
  });
329
628
 
330
- if (flags.has('--json')) {
629
+ if (json) {
331
630
  process.stdout.write(JSON.stringify(enriched, null, 2) + '\n');
332
631
  return 0;
333
632
  }
@@ -352,9 +651,32 @@ function cmdList(rest) {
352
651
  return 0;
353
652
  }
354
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
+
355
674
  async function cmdRemove(rest) {
356
- const [name] = rest;
357
- 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');
358
680
  profiles.validateProfileName(name);
359
681
  const dir = profiles.profileDir(name);
360
682
  if (!fs.existsSync(dir)) {
@@ -362,13 +684,24 @@ async function cmdRemove(rest) {
362
684
  err.exitCode = 1;
363
685
  throw err;
364
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
+
365
698
  console.log('This will permanently delete:');
366
699
  console.log(` ${dir}`);
367
- console.log('including credentials, sessions, projects, plugins, and history.');
368
- 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) {
369
702
  const sub_p = path.join(dir, sub);
370
- if (isLinkLike(sub_p)) {
371
- 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)`);
372
705
  }
373
706
  }
374
707
  const answer = await prompt(`Type the profile name "${name}" to confirm: `);
@@ -386,34 +719,60 @@ async function cmdRemove(rest) {
386
719
  }
387
720
 
388
721
  async function cmdLink(rest) {
389
- if (rest.length === 0) throw usageError('Usage: cc link <profile> [<profile>...]');
390
- 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;
391
738
  const summary = [];
392
- for (const name of rest) {
739
+ for (const name of positional) {
393
740
  if (multi) console.log(`\n[${name}]`);
394
741
  let results;
395
742
  try {
396
- results = share.shareLink(name);
743
+ results = share.shareLink(name, opts);
397
744
  } catch (err) {
398
- // Profile-level failure (invalid name, profile missing) — nothing linked.
399
745
  process.stderr.write(color.stderr.red(` error: ${err.message}`) + '\n');
400
- summary.push({ name, linked: 0, total: share.SHARED_SUBDIRS.length, error: err.message });
746
+ summary.push({ name, linked: 0, total: 0, error: err.message });
401
747
  continue;
402
748
  }
403
749
  for (const r of results) {
750
+ const label = share.isFileResource(r.resource) ? r.resource : `${r.resource}/`;
404
751
  if (r.status === 'failed') {
405
- 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');
406
755
  } else if (r.status === 'already-linked') {
407
- 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/)`);
408
759
  } else {
409
- const note = r.moved ? ` (migrated ${r.moved} item(s) into shared)` : '';
410
- 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}`);
411
762
  }
412
763
  }
413
- const failed = results.filter((r) => r.status === 'failed');
764
+ const failed = results.filter((r) => r.status === 'failed' || r.status === 'refused');
414
765
  const linked = results.length - failed.length;
415
- console.log(` ${linked}/${results.length} directories linked${failed.length ? `, ${failed.length} failed` : ''}.`);
416
- 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
+ });
417
776
  }
418
777
 
419
778
  const anyFailure = summary.some((s) => s.error || (s.failed && s.failed.length));
@@ -423,7 +782,7 @@ async function cmdLink(rest) {
423
782
  if (s.error) {
424
783
  console.log(` ${s.name}: error — ${s.error}`);
425
784
  } else if (s.failed && s.failed.length) {
426
- 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(', ')})`);
427
786
  } else {
428
787
  console.log(` ${s.name}: ${s.linked}/${s.total} linked`);
429
788
  }
@@ -433,18 +792,32 @@ async function cmdLink(rest) {
433
792
  }
434
793
 
435
794
  async function cmdUnlink(rest) {
436
- const [name] = rest;
437
- if (!name) throw usageError('Usage: cc unlink <profile>');
438
- 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);
439
811
  let anyFailure = false;
440
812
  for (const r of results) {
813
+ const label = share.isFileResource(r.resource) ? r.resource : `${r.resource}/`;
441
814
  if (r.status === 'failed') {
442
815
  anyFailure = true;
443
- process.stderr.write(color.stderr.red(` ${r.sub}: failed — ${r.message}`) + '\n');
816
+ process.stderr.write(color.stderr.red(` ${label}: failed — ${r.message}`) + '\n');
444
817
  } else if (r.status === 'not-linked') {
445
- console.log(` ${r.sub}: not linked, skipping`);
818
+ console.log(` ${label}: not linked, skipping`);
446
819
  } else {
447
- console.log(` ${r.sub}: unlinked, content copied back`);
820
+ console.log(` ${label}: unlinked, content copied back`);
448
821
  }
449
822
  }
450
823
  return anyFailure ? 1 : 0;
@@ -460,73 +833,29 @@ async function cmdDoctor(rest) {
460
833
  return doctor.runDoctor({ fix, force, json, prompt });
461
834
  }
462
835
 
463
- async function cmdMigrate(rest) {
836
+ async function cmdCopy(rest) {
464
837
  const { flags, positional } = parseFlagsStrict(rest, ['--force'], { allowPositional: true });
465
838
  const [src, dest, ...extra] = positional;
466
- if (!src || !dest) throw usageError('Usage: cc migrate <src> <dest> [--force]');
839
+ if (!src || !dest) throw usageError('Usage: cc copy <src> <dest> [--force]');
467
840
  if (extra.length > 0) throw usageError(`Unexpected extra arguments: ${extra.join(' ')}`);
468
841
  const force = flags.has('--force');
469
842
  if (force) {
470
- 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
+ );
471
846
  }
472
- migrateLib.migrate(src, dest, { force });
847
+ copyLib.copy(src, dest, { force });
473
848
  return 0;
474
849
  }
475
850
 
476
- // Flag name -> config field for `cc env`.
477
- const ENV_VALUE_FLAGS = {
478
- '--base-url': 'base_url',
479
- '--token': 'auth_token',
480
- '--model': 'model',
481
- '--opus': 'opus',
482
- '--sonnet': 'sonnet',
483
- '--haiku': 'haiku',
484
- '--subagent': 'subagent',
485
- };
486
-
487
- async function cmdEnv(rest) {
488
- const positional = [];
489
- const provided = {};
490
- let reveal = false;
491
-
492
- for (const a of rest) {
493
- if (a.startsWith('-')) {
494
- const eq = a.indexOf('=');
495
- const flagName = eq === -1 ? a : a.slice(0, eq);
496
- if (flagName === '--reveal') {
497
- reveal = true;
498
- continue;
499
- }
500
- if (Object.prototype.hasOwnProperty.call(ENV_VALUE_FLAGS, flagName)) {
501
- if (eq === -1) {
502
- throw usageError(`Flag ${flagName} requires a value: ${flagName}=VALUE`);
503
- }
504
- provided[ENV_VALUE_FLAGS[flagName]] = a.slice(eq + 1);
505
- continue;
506
- }
507
- throw usageError(`Unknown flag: ${flagName}`);
508
- }
509
- positional.push(a);
510
- }
511
-
512
- const [name, sub, ...extra] = positional;
513
- if (!name) throw usageError('Usage: cc env <profile> [show] [--base-url=URL --token=TOKEN ...]');
514
- if (extra.length > 0) throw usageError(`Unexpected argument: ${extra[0]}`);
515
- profiles.validateProfileName(name);
516
- const dir = profiles.profileDir(name);
517
-
518
- if (sub === 'show') {
519
- if (Object.keys(provided).length > 0) {
520
- throw usageError('cc env <profile> show does not take configuration flags.');
521
- }
522
- return envLib.runShow(name, dir, { reveal });
523
- }
524
- if (sub) throw usageError(`Unknown subcommand for cc env: ${sub}`);
525
- if (reveal) {
526
- throw usageError('Unknown flag: --reveal (only valid with: cc env <profile> show)');
527
- }
528
-
529
- return envLib.runConfigure(name, dir, provided);
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);
530
859
  }
531
860
 
532
861
  async function run(argv) {
@@ -544,6 +873,15 @@ async function run(argv) {
544
873
  printHelp(rest[0]);
545
874
  return 0;
546
875
  }
876
+ if (action === 'env') {
877
+ process.stderr.write(
878
+ color.stderr.red(
879
+ "'cc env' was removed in v0.3.1. Configure a custom endpoint with " +
880
+ "'cc add <profile> --custom', and view it with 'cc list <profile>'.",
881
+ ) + '\n',
882
+ );
883
+ return 2;
884
+ }
547
885
  switch (action) {
548
886
  case 'add':
549
887
  return await cmdAdd(rest);
@@ -551,8 +889,8 @@ async function run(argv) {
551
889
  return await cmdUse(rest);
552
890
  case 'list':
553
891
  return cmdList(rest);
554
- case 'env':
555
- return await cmdEnv(rest);
892
+ case 'copy':
893
+ return await cmdCopy(rest);
556
894
  case 'migrate':
557
895
  return await cmdMigrate(rest);
558
896
  case 'link':