cchost 0.4.4 → 0.6.0

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
@@ -13,19 +13,26 @@ const { renderTable } = require('./format');
13
13
  const color = require('./color');
14
14
  const { renderBanner } = require('./banner');
15
15
 
16
- const HELP = `cc - Multi-profile launcher for Claude Code
16
+ const HELP = `cchost - Multi-profile launcher for Claude Code
17
+
18
+ Installed as both 'cchost' (preferred) and 'cc' (legacy alias). 'cc' is also
19
+ the C compiler on macOS and Linux: when 'cc' is called with anything other than
20
+ a cchost command (e.g. 'cc -c foo.c', 'cc --version'), it runs the real
21
+ compiler further down PATH. Use 'cchost' in scripts.
17
22
 
18
23
  Usage:
19
- cc add <profile> [--custom [...]] Create a profile (auto-links all 7 resources to ~/.claude/)
20
- cc use <profile> [...args] Launch Claude under a named profile
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
- cc doctor [--fix [--force]] [--json] Diagnose environment, storage, and platform issues
27
- cc help [command] Show usage (optionally for a single command)
28
- cc --version Print the installed cchost version
24
+ cchost add <profile> [--private] [--custom [...]] Create a profile (shares all 7 resources, or --private ones)
25
+ cchost use <profile> [...args] Launch Claude under a named profile
26
+ cchost list [<profile>] [--json] [--reveal] List profiles, or show one in detail
27
+ cchost copy default <profile> [--force] [--dry-run] Copy ~/.claude/ resources into a profile (only when unlinked)
28
+ cchost link <p>... [--projects|--tools|--config] (Re-)link a profile's resources to ~/.claude/
29
+ cchost unlink <profile> [--projects|--tools|--config] Restore a profile's private resources (non-destructive)
30
+ cchost remove <profile> [--force] Delete a profile and all its data
31
+ cchost doctor [--fix [--force]] [--json] Diagnose environment, storage, and platform issues
32
+ cchost ui [--port=N] [--no-open] [--session=8h] Open the web view (prints a sign-in password, like rclone)
33
+ cchost help [command] Show usage (optionally for a single command)
34
+ cchost help sharing Task guide: what is shared, and how to change it
35
+ cchost --version Print the installed cchost version
29
36
 
30
37
  Shared resources (linked into ~/.claude/ by default):
31
38
  --projects projects/ (session history)
@@ -35,35 +42,46 @@ Shared resources (linked into ~/.claude/ by default):
35
42
  Never shared (stay per-profile):
36
43
  .credentials.json .claude.json .cc-env.json (identity / auth)
37
44
 
45
+ link, unlink, and copy accept --dry-run to preview changes without making them.
46
+
38
47
  Profiles live in ~/.claude-profiles/<profile>/ and become CLAUDE_CONFIG_DIR
39
48
  for that launch. Args after the profile name are forwarded verbatim to claude.
40
49
  `;
41
50
 
42
51
  const HELP_BY_CMD = {
43
- add: `cc add <profile> [--custom [--base-url=URL --token=TOKEN ...]]
52
+ add: `cchost add <profile> [--private[=projects,tools,config]] [--custom [--base-url=URL --token=TOKEN ...]]
44
53
 
45
54
  Create a new profile directory without launching Claude. All 7 shared
46
55
  resources (projects/, skills/, agents/, commands/, mcp.json, settings.json,
47
56
  CLAUDE.md) are auto-linked into ~/.claude/ so they're visible to every
48
57
  profile (and to bare \`claude\`) from the moment the profile is created.
49
58
 
50
- cc add <profile>
59
+ cchost add <profile>
51
60
  Create a regular profile. It logs in with your Anthropic
52
- subscription (OAuth) on the first 'cc use'.
53
-
54
- cc add <profile> --custom
61
+ subscription (OAuth) on the first 'cchost use'.
62
+
63
+ cchost add <profile> --private
64
+ cchost add <profile> --private=projects,config
65
+ Start some or all resources private and empty instead of shared:
66
+ nothing from ~/.claude/ is copied in, and nothing this profile writes
67
+ there is seen by other profiles. Groups: projects (session history),
68
+ tools (skills/agents/commands), config (mcp.json/settings.json/CLAUDE.md).
69
+ Combine with --custom for an isolated endpoint profile. Share a group
70
+ later with 'cchost link <profile> --<group>'.
71
+
72
+ cchost add <profile> --custom
55
73
  Create a profile routed through a custom, Anthropic-compatible API
56
74
  endpoint — Moonshot/Kimi, OpenRouter, a self-hosted vLLM, a corporate
57
75
  proxy, and so on. Opens an interactive wizard; the API token is never
58
76
  echoed to the screen.
59
77
 
60
- cc add <profile> --custom --base-url=URL --token=TOKEN
78
+ cchost add <profile> --custom --base-url=URL --token=TOKEN
61
79
  [--model=NAME] [--opus=NAME] [--sonnet=NAME]
62
80
  [--haiku=NAME] [--subagent=NAME]
63
81
  Same, non-interactive. --base-url and --token are required.
64
82
 
65
- Re-running 'cc add <profile> --custom' on an existing endpoint profile EDITS
66
- its configuration. View any profile with 'cc list <profile>'.
83
+ Re-running 'cchost add <profile> --custom' on an existing endpoint profile EDITS
84
+ its configuration. View any profile with 'cchost list <profile>'.
67
85
 
68
86
  Billing: a custom-endpoint profile bills through the third-party provider,
69
87
  NOT your Anthropic subscription. A profile is born subscription or endpoint
@@ -73,31 +91,33 @@ The endpoint config lives at ~/.claude-profiles/<profile>/.cc-env.json with
73
91
  user-only permissions. It is never linked and never copied.
74
92
 
75
93
  Examples:
76
- cc add work
77
- cc add kimi --custom
78
- cc add kimi --custom --base-url=https://api.moonshot.ai/anthropic --token=sk-... --model=kimi-k2.5
94
+ cchost add work
95
+ cchost add client --private=projects,config
96
+ cchost add kimi --custom
97
+ cchost add kimi --custom --base-url=https://api.moonshot.ai/anthropic --token=sk-... --model=kimi-k2.5
79
98
  `,
80
- use: `cc use <profile> [...args]
99
+ use: `cchost use <profile> [...args]
81
100
 
82
101
  Launch Claude under a named profile. The profile must already exist (create
83
- it with 'cc add <profile>'). Its directory under ~/.claude-profiles/<profile>/
102
+ it with 'cchost add <profile>'). Its directory under ~/.claude-profiles/<profile>/
84
103
  becomes CLAUDE_CONFIG_DIR for the spawned claude — credentials and per-
85
104
  profile config are isolated; the 7 shared resources are visible via symlinks.
86
105
 
87
106
  All args after the profile name are forwarded verbatim to claude.
88
107
 
89
108
  Examples:
90
- cc use work
91
- cc use work --resume
92
- cc use personal -p "summarize this codebase"
109
+ cchost use work
110
+ cchost use work --resume
111
+ cchost use personal -p "summarize this codebase"
93
112
  `,
94
- list: `cc list [<profile>] [--json] [--reveal]
113
+ list: `cchost list [<profile>] [--json] [--reveal]
95
114
 
96
115
  Without a profile name: list every profile as a table.
97
116
 
98
117
  Columns:
99
118
  Profile - profile name
100
- LoggedIn - true if .credentials.json exists
119
+ LoggedIn - true if credentials are present. On macOS the token lives in the
120
+ login Keychain and the login is inferred from .claude.json.
101
121
  Email - account email from .claude.json (masked by default; pass
102
122
  --reveal to show the full value); - if not yet logged in
103
123
  Shared - N/7 resources linked to ~/.claude/. 7/7 = fully shared (the
@@ -105,23 +125,24 @@ Columns:
105
125
  Endpoint - custom-endpoint host from .cc-env.json, or "subscription" if
106
126
  the profile uses Anthropic's subscription OAuth
107
127
 
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;
128
+ With a profile name: print that one profile's detail: the state of each of
129
+ the 7 resources (shared, private, empty, missing, or foreign-link) and its
130
+ custom endpoint configuration if it has one. The API token is masked by default;
110
131
  --reveal prints both the token and the full email — do not paste that output
111
132
  anywhere.
112
133
 
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)
134
+ cchost list Table of all profiles (emails masked)
135
+ cchost list --reveal Table with full emails shown
136
+ cchost list --json Same, as a JSON array (always masked)
137
+ cchost list <profile> Detail for a single profile
138
+ cchost list <profile> --reveal Detail with the full token + email shown
139
+ cchost list <profile> --json Detail as a JSON object (always masked)
119
140
  `,
120
- copy: `cc copy default <profile> [--force]
141
+ copy: `cchost copy default <profile> [--force] [--dry-run]
121
142
 
122
143
  Copy ~/.claude/ resources into a profile. Useful only when the profile has
123
144
  '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.
145
+ profiles every resource is symlinked, so 'cchost copy' is a no-op.
125
146
 
126
147
  <src>: default (= ~/.claude/, Claude Code's own config dir)
127
148
  <dest>: <profile-name>
@@ -135,32 +156,44 @@ For each resource:
135
156
  - If the destination is shared via symlink/hard-link to the source → skipped
136
157
  - Else, copy with skip-on-collision (entries already at the destination win)
137
158
 
138
- --force Overwrite collisions at the destination instead of skipping.
159
+ --force Overwrite collisions at the destination instead of skipping.
160
+ --dry-run Print what would be copied, skipped, or overwritten; change nothing.
139
161
 
140
162
  Examples:
141
- cc copy default work # only acts on unlinked resources
142
- cc copy default work --force # overwrite collisions
163
+ cchost copy default work --dry-run # preview first
164
+ cchost copy default work # only acts on unlinked resources
165
+ cchost copy default work --force # overwrite collisions
143
166
 
144
167
  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
168
+ and is reached via symlinks. Use 'cchost link' / 'cchost unlink' to manage sharing.
169
+ Note: 'cchost migrate' is a deprecated alias for 'cchost copy' — it still works but
147
170
  will be removed in a future version.
148
171
  `,
149
- link: `cc link <profile> [<profile>...] [--projects|--tools|--config]
172
+ migrate: `cchost migrate <src> <dest> [--force]
150
173
 
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.
174
+ Deprecated alias for 'cchost copy'. It prints a deprecation warning and
175
+ forwards its arguments. Use 'cchost copy' instead; this alias will be removed
176
+ in a future version.
177
+
178
+ Examples:
179
+ cchost migrate default work
180
+ `,
181
+ link: `cchost link <profile> [<profile>...] [--projects|--tools|--config] [--dry-run]
182
+
183
+ (Re-)link a profile's resources into the shared pool at ~/.claude/. 'cchost add'
184
+ already does this for new profiles; 'cchost link' is for re-applying it after
185
+ 'cchost unlink' or for older profiles that pre-date v0.4.1.
154
186
 
155
187
  No flag link all 7 resources
156
188
  --projects projects/
157
189
  --tools skills/, agents/, commands/
158
190
  --config mcp.json, settings.json, CLAUDE.md
159
191
  (flags can be combined: --tools --config)
192
+ --dry-run print what would be linked or refused; change nothing
160
193
 
161
194
  Refuses to overwrite private content: if the profile has a non-empty resource
162
195
  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
196
+ alone — move or remove it manually, then re-run 'cchost link'. The other
164
197
  resources are still linked.
165
198
 
166
199
  On Windows, dirs use junctions (no admin/Developer Mode needed). File
@@ -168,12 +201,13 @@ symlinks may require Developer Mode; without it, files fall back to hard
168
201
  links so editing-in-place still propagates between profiles.
169
202
 
170
203
  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
204
+ cchost link work # link all 7
205
+ cchost link work --tools # only skills/, agents/, commands/
206
+ cchost link work --config # only mcp.json, settings.json, CLAUDE.md
207
+ cchost link a b c # link 3 profiles in one shot
208
+ cchost link work --dry-run # preview
175
209
  `,
176
- unlink: `cc unlink <profile> [--projects|--tools|--config]
210
+ unlink: `cchost unlink <profile> [--projects|--tools|--config] [--dry-run]
177
211
 
178
212
  Break the symlinks and copy current ~/.claude/ content into the profile as
179
213
  private resources. Non-destructive: same-name items already private in the
@@ -183,16 +217,54 @@ No flag unlink all 7 resources
183
217
  --projects projects/
184
218
  --tools skills/, agents/, commands/
185
219
  --config mcp.json, settings.json, CLAUDE.md
220
+ --dry-run print what would be unlinked and copied; change nothing
186
221
 
187
222
  After unlinking, the profile's resource diverges from ~/.claude/. Other
188
223
  profiles still see the shared pool.
189
224
 
190
225
  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
226
+ cchost unlink work # full private profile
227
+ cchost unlink work --config # only mcp.json / settings.json / CLAUDE.md private
228
+ cchost unlink work --tools --config # skills, agents, commands AND config private
229
+ cchost unlink work --dry-run # preview
230
+ `,
231
+ sharing: `cchost help sharing — what profiles share, and how to change it
232
+
233
+ Every profile has 7 resources. By default each one is a link into ~/.claude/,
234
+ so all profiles (and bare \`claude\`) see the same content:
235
+
236
+ --projects projects/ session history
237
+ --tools skills/ agents/ commands/ user tooling
238
+ --config mcp.json settings.json CLAUDE.md MCP servers, settings, memory
239
+
240
+ Logins (.credentials.json, .claude.json, .cc-env.json) are never shared.
241
+
242
+ See where a profile stands:
243
+ cchost list Shared column: 7/7 = all shared
244
+ cchost list work state of each resource
245
+
246
+ Start a new profile private (empty, nothing copied in):
247
+ cchost add client --private all 7 private
248
+ cchost add client --private=projects own session history, shared tools/config
249
+
250
+ Make something private to an existing profile:
251
+ cchost unlink work --config own mcp.json / settings.json / CLAUDE.md
252
+ cchost unlink work all 7 private
253
+ The current shared content is copied in, so nothing disappears; from then
254
+ on, changes in that profile stay in that profile.
255
+
256
+ Share it again:
257
+ cchost link work --config back to the shared ~/.claude/ copy
258
+ link refuses to replace private content. Move or delete the private file or
259
+ folder first; it is not merged into ~/.claude/ automatically.
260
+
261
+ Copy shared content into a private profile (top-up, no overwrite):
262
+ cchost copy default work add what ~/.claude/ has and work lacks
263
+ cchost copy default work --force overwrite work's copies instead
264
+
265
+ Preview any of these first with --dry-run.
194
266
  `,
195
- remove: `cc remove <profile> [--force]
267
+ remove: `cchost remove <profile> [--force]
196
268
 
197
269
  Delete a profile and everything inside its directory: credentials, sessions,
198
270
  and any private resources. To confirm, you must type the exact profile name —
@@ -203,7 +275,7 @@ removed — the shared content in ~/.claude/ is preserved for other profiles.
203
275
  If any resource is PRIVATE (unlinked), --force is required because its
204
276
  content is unique to this profile and will be lost forever.
205
277
  `,
206
- doctor: `cc doctor [--fix [--force]] [--json]
278
+ doctor: `cchost doctor [--fix [--force]] [--json]
207
279
 
208
280
  Run diagnostic checks across four groups:
209
281
 
@@ -213,7 +285,7 @@ Run diagnostic checks across four groups:
213
285
  ~/.claude-shared/ folded into ~/.claude/ if still present
214
286
  Platform Windows: OneDrive home, WindowsApps stub, stale ~/bin/cc;
215
287
  Git Bash: cygpath
216
- Sanity Test-spawn claude --version through the same code path cc use uses
288
+ Sanity Test-spawn claude --version through the same code path cchost use uses
217
289
 
218
290
  Exit codes: 0 = all ok, 1 = warnings, 2 = errors.
219
291
 
@@ -223,7 +295,27 @@ Exit codes: 0 = all ok, 1 = warnings, 2 = errors.
223
295
  .claude.json)
224
296
  --json Machine-readable output for CI / scripting
225
297
  `,
226
- help: `cc help [command]
298
+ ui: `cchost ui [--port=N] [--no-open] [--user=NAME] [--pass=PASSWORD] [--session=8h]
299
+
300
+ Open a local web page that shows every profile, what each one shares with
301
+ ~/.claude/, logins, endpoints and doctor results, and lets you add and remove
302
+ profiles, share or unshare resources, copy from ~/.claude/ and apply doctor
303
+ fixes. Every change shows a preview first and lists each step it took.
304
+
305
+ Sign-in works like rclone's web GUI: the terminal prints a username and a
306
+ random password that is new every run, and your browser opens with a
307
+ one-time sign-in link (valid 2 minutes). The browser then keeps a session
308
+ that ends after --session or when cchost ui stops. The page is served on
309
+ 127.0.0.1 only; endpoint tokens are never sent to it. Ctrl+C stops it.
310
+
311
+ --port=N Listen on port N (default: a free port)
312
+ --no-open Print the one-time link instead of opening a browser
313
+ --user=NAME Sign-in username (default: cchost)
314
+ --pass=PASSWORD Use this password instead of a random one (8+ characters;
315
+ visible in your shell history — prefer the random one)
316
+ --session=8h How long a sign-in lasts: 30m, 8h, 2d (default: 8h)
317
+ `,
318
+ help: `cchost help [command]
227
319
 
228
320
  Without an argument: print the summary of all commands.
229
321
  With a command name: print detail for just that command.
@@ -247,11 +339,11 @@ function printHelp(cmd) {
247
339
  process.stdout.write(HELP);
248
340
  }
249
341
 
250
- function prompt(question) {
342
+ function prompt(question, output = process.stdout) {
251
343
  return new Promise((resolve) => {
252
344
  const rl = readline.createInterface({
253
345
  input: process.stdin,
254
- output: process.stdout,
346
+ output,
255
347
  });
256
348
  rl.question(question, (answer) => {
257
349
  rl.close();
@@ -316,6 +408,18 @@ function parseFlagsStrict(args, flagNames, { allowPositional = false } = {}) {
316
408
  return { flags, positional };
317
409
  }
318
410
 
411
+ function resourceLabel(resource) {
412
+ return share.isFileResource(resource) ? resource : `${resource}/`;
413
+ }
414
+
415
+ // One-line explanation per resource state for `cchost list <profile>`.
416
+ const RESOURCE_STATE_NOTES = {
417
+ private: 'only this profile sees it',
418
+ empty: 'private, but empty',
419
+ missing: 'not present; cchost link would share it',
420
+ 'foreign-link': 'points outside ~/.claude/; cchost leaves it alone',
421
+ };
422
+
319
423
  // Count how many of the 7 shared resources a profile currently has linked
320
424
  // (symlink OR — on Windows file fallback — hard link).
321
425
  function countSharedResources(profileDir) {
@@ -326,7 +430,7 @@ function countSharedResources(profileDir) {
326
430
  return n;
327
431
  }
328
432
 
329
- // Flag name -> .cc-env.json field, for `cc add --custom`.
433
+ // Flag name -> .cc-env.json field, for `cchost add --custom`.
330
434
  const ENDPOINT_VALUE_FLAGS = {
331
435
  '--base-url': 'base_url',
332
436
  '--token': 'auth_token',
@@ -337,19 +441,45 @@ const ENDPOINT_VALUE_FLAGS = {
337
441
  '--subagent': 'subagent',
338
442
  };
339
443
 
444
+ // `--private` → all groups; `--private=projects,tools,config` → those groups.
445
+ // Returns the share.resolveSubset() options for the private groups.
446
+ const PRIVATE_GROUPS = ['projects', 'tools', 'config'];
447
+
448
+ function parsePrivateFlag(value) {
449
+ if (value === null) return { projects: true, tools: true, config: true };
450
+ const groups = value.split(',').map((g) => g.trim()).filter(Boolean);
451
+ if (groups.length === 0) {
452
+ throw usageError(`--private= needs at least one of: ${PRIVATE_GROUPS.join(', ')}`);
453
+ }
454
+ const opts = {};
455
+ for (const g of groups) {
456
+ if (!PRIVATE_GROUPS.includes(g)) {
457
+ throw usageError(`Unknown --private group "${g}". Use: ${PRIVATE_GROUPS.join(', ')}`);
458
+ }
459
+ opts[g] = true;
460
+ }
461
+ return opts;
462
+ }
463
+
340
464
  async function cmdAdd(rest) {
341
465
  const positional = [];
342
466
  const provided = {};
343
467
  let custom = false;
468
+ let privateOpts = null;
344
469
 
345
470
  for (const a of rest) {
346
471
  if (a.startsWith('-')) {
347
472
  const eq = a.indexOf('=');
348
473
  const flagName = eq === -1 ? a : a.slice(0, eq);
349
474
  if (flagName === '--custom') {
475
+ if (eq !== -1) throw usageError('--custom takes no value');
350
476
  custom = true;
351
477
  continue;
352
478
  }
479
+ if (flagName === '--private') {
480
+ privateOpts = parsePrivateFlag(eq === -1 ? null : a.slice(eq + 1));
481
+ continue;
482
+ }
353
483
  if (Object.prototype.hasOwnProperty.call(ENDPOINT_VALUE_FLAGS, flagName)) {
354
484
  if (eq === -1) {
355
485
  throw usageError(`Flag ${flagName} requires a value: ${flagName}=VALUE`);
@@ -364,15 +494,18 @@ async function cmdAdd(rest) {
364
494
 
365
495
  const [name, ...extra] = positional;
366
496
  if (!name) {
367
- throw usageError('Usage: cc add <profile> [--custom [--base-url=URL --token=TOKEN ...]]');
497
+ throw usageError(
498
+ 'Usage: cchost add <profile> [--private[=projects,tools,config]] ' +
499
+ '[--custom [--base-url=URL --token=TOKEN ...]]',
500
+ );
368
501
  }
369
- if (extra.length > 0) throw usageError(`cc add takes no extra arguments: ${extra[0]}`);
502
+ if (extra.length > 0) throw usageError(`cchost add takes no extra arguments: ${extra[0]}`);
370
503
  profiles.validateProfileName(name);
371
504
 
372
505
  if (Object.keys(provided).length > 0 && !custom) {
373
506
  throw usageError(
374
507
  'Endpoint flags (--base-url, --token, --model, ...) require --custom: ' +
375
- `cc add ${name} --custom ...`,
508
+ `cchost add ${name} --custom ...`,
376
509
  );
377
510
  }
378
511
 
@@ -382,17 +515,23 @@ async function cmdAdd(rest) {
382
515
  if (!custom) {
383
516
  const { dir } = profiles.addProfile(name);
384
517
  console.log(`Created profile "${name}" at ${dir}.`);
385
- autoLinkArtifacts(name);
386
- console.log(`Next: cc use ${name}`);
518
+ setUpResources(name, dir, privateOpts);
519
+ console.log(`Next: cchost use ${name}`);
387
520
  return 0;
388
521
  }
389
522
 
390
523
  // Custom endpoint profile.
391
524
  const dir = profiles.profileDir(name);
392
525
  const existed = fs.existsSync(dir);
526
+ if (existed && privateOpts) {
527
+ throw usageError(
528
+ `--private only applies when creating a profile; "${name}" already exists. ` +
529
+ `Use: cchost unlink ${name} [--projects|--tools|--config]`,
530
+ );
531
+ }
393
532
  if (!existed) {
394
533
  profiles.addProfile(name);
395
- autoLinkArtifacts(name);
534
+ setUpResources(name, dir, privateOpts);
396
535
  }
397
536
 
398
537
  let result;
@@ -417,7 +556,7 @@ async function cmdAdd(rest) {
417
556
  return result.code;
418
557
  }
419
558
 
420
- if (!existed) console.log(`Next: cc use ${name}`);
559
+ if (!existed) console.log(`Next: cchost use ${name}`);
421
560
  return result.code;
422
561
  }
423
562
 
@@ -447,13 +586,32 @@ function runLegacyPoolMigration() {
447
586
  }
448
587
  }
449
588
 
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) {
589
+ // On profile creation, link every resource that isn't private into
590
+ // ~/.claude/, and start each private dir empty. Private config files are left
591
+ // absent so Claude Code creates them on first use.
592
+ function setUpResources(name, dir, privateOpts) {
593
+ if (!privateOpts) {
594
+ autoLinkArtifacts(name);
595
+ return;
596
+ }
597
+ const privateSet = share.resolveSubset(privateOpts);
598
+ const sharedGroups = {};
599
+ for (const g of PRIVATE_GROUPS) if (!privateOpts[g]) sharedGroups[g] = true;
600
+ if (Object.keys(sharedGroups).length > 0) autoLinkArtifacts(name, sharedGroups);
601
+ for (const r of privateSet) {
602
+ if (!share.isFileResource(r)) fs.mkdirSync(path.join(dir, r), { recursive: true });
603
+ }
604
+ share.markPrivate(dir, privateSet);
605
+ console.log(` Private (not shared with other profiles): ${privateSet.map(resourceLabel).join(', ')}`);
606
+ }
607
+
608
+ // Symlink resources into ~/.claude/ (all 7, or the groups in `opts`). Failures
609
+ // emit a warning but do NOT abort cchost add — the profile is still usable,
610
+ // and the user can re-run `cchost link <name>` to retry.
611
+ function autoLinkArtifacts(name, opts = {}) {
454
612
  let results;
455
613
  try {
456
- results = share.shareLink(name);
614
+ results = share.shareLink(name, opts);
457
615
  } catch (err) {
458
616
  process.stderr.write(
459
617
  color.stderr.yellow(
@@ -462,7 +620,9 @@ function autoLinkArtifacts(name) {
462
620
  );
463
621
  return;
464
622
  }
465
- const failed = results.filter((r) => r.status === 'failed' || r.status === 'refused');
623
+ const failed = results.filter(
624
+ (r) => r.status === 'failed' || r.status === 'refused' || r.status === 'foreign-link',
625
+ );
466
626
  const linked = results.length - failed.length;
467
627
  if (failed.length === 0) {
468
628
  console.log(` Linked ${linked}/${results.length} resources to ~/.claude/.`);
@@ -476,35 +636,51 @@ function autoLinkArtifacts(name) {
476
636
  }
477
637
  process.stderr.write(
478
638
  color.stderr.yellow(
479
- ` Re-run \`cc link ${name}\` after resolving the above.`,
639
+ ` Re-run \`cchost link ${name}\` after resolving the above.`,
480
640
  ) + '\n',
481
641
  );
482
642
  }
483
643
 
484
644
  async function cmdUse(rest) {
485
645
  const [name, ...passthrough] = rest;
486
- if (!name) throw usageError('Usage: cc use <profile> [...args]');
646
+ if (!name) throw usageError('Usage: cchost use <profile> [...args]');
487
647
  profiles.validateProfileName(name);
488
648
  const dir = profiles.profileDir(name);
489
649
  if (!fs.existsSync(dir)) {
490
- const err = new Error(`Profile "${name}" does not exist. Create it with: cc add ${name}`);
650
+ const err = new Error(`Profile "${name}" does not exist. Create it with: cchost add ${name}`);
491
651
  err.exitCode = 1;
492
652
  throw err;
493
653
  }
494
654
 
495
655
  let extraEnv = {};
656
+ let unsetEnv = [];
496
657
  const envConfig = envLib.readEnvConfig(dir);
497
658
  if (envConfig) {
498
659
  extraEnv = envLib.buildEnvVars(envConfig);
660
+ unsetEnv = envLib.endpointEnvToStrip(extraEnv);
499
661
  const host = envConfig.base_url ? envLib.hostOf(envConfig.base_url) : '(unknown)';
500
662
  process.stderr.write(
501
663
  `[cchost] Profile '${name}' → ${host} (custom endpoint billing applies)\n`,
502
664
  );
665
+ if (unsetEnv.length > 0) {
666
+ process.stderr.write(
667
+ `[cchost] notice: removed inherited ${unsetEnv.join(', ')} for endpoint profile '${name}'.\n`,
668
+ );
669
+ }
670
+ } else {
671
+ const conflicts = envLib.authEnvConflicts();
672
+ if (conflicts.length > 0) {
673
+ process.stderr.write(
674
+ `[cchost] warning: ${conflicts.join(', ')} set in the environment; ` +
675
+ `this may override the subscription login for '${name}'. ` +
676
+ `Unset ${conflicts.length > 1 ? 'them' : 'it'} to use the profile's login.\n`,
677
+ );
678
+ }
503
679
  }
504
- return spawnClaude({ args: passthrough, profileDir: dir, extraEnv });
680
+ return spawnClaude({ args: passthrough, profileDir: dir, extraEnv, unsetEnv });
505
681
  }
506
682
 
507
- // `cc list <profile>` — detail for a single profile, including its custom
683
+ // `cchost list <profile>` — detail for a single profile, including its custom
508
684
  // endpoint configuration if it has one.
509
685
  function cmdListOne(name, { json, reveal }) {
510
686
  if (json && reveal) {
@@ -513,15 +689,16 @@ function cmdListOne(name, { json, reveal }) {
513
689
  profiles.validateProfileName(name);
514
690
  const dir = profiles.profileDir(name);
515
691
  if (!fs.existsSync(dir)) {
516
- const e = new Error(`Profile "${name}" does not exist. Create it with: cc add ${name}`);
692
+ const e = new Error(`Profile "${name}" does not exist. Create it with: cchost add ${name}`);
517
693
  e.exitCode = 1;
518
694
  throw e;
519
695
  }
520
696
 
521
697
  const total = share.SHARED_RESOURCES.length;
522
- const sharedCount = countSharedResources(dir);
523
- const loggedIn = profiles.hasCredentials(dir);
524
- const email = profiles.readEmail(dir);
698
+ const resources = {};
699
+ for (const r of share.SHARED_RESOURCES) resources[r] = share.resourceState(dir, r);
700
+ const sharedCount = Object.values(resources).filter((st) => st === 'shared').length;
701
+ const { credentialSource, loggedIn, email } = profiles.readProfile(dir);
525
702
  const displayEmail = reveal ? email : (email ? envLib.maskEmail(email) : email);
526
703
 
527
704
  let config = null;
@@ -537,8 +714,10 @@ function cmdListOne(name, { json, reveal }) {
537
714
  name,
538
715
  dir,
539
716
  loggedIn,
717
+ credentialSource,
540
718
  email: displayEmail,
541
719
  shared: sharedCount,
720
+ resources,
542
721
  endpoint: envLib.endpointSummary(dir),
543
722
  };
544
723
  if (config) {
@@ -558,9 +737,18 @@ function cmdListOne(name, { json, reveal }) {
558
737
 
559
738
  console.log(`Profile: ${name}`);
560
739
  console.log(`Directory: ${dir}`);
561
- console.log(`LoggedIn: ${loggedIn ? 'true' : 'false'}`);
740
+ console.log(
741
+ `LoggedIn: ${loggedIn ? 'true' : 'false'}` +
742
+ (credentialSource === 'keychain' ? ' (macOS Keychain)' : ''),
743
+ );
562
744
  console.log(`Email: ${displayEmail || '-'}`);
563
745
  console.log(`Shared: ${sharedCount}/${total}`);
746
+ const width = Math.max(...share.SHARED_RESOURCES.map((r) => resourceLabel(r).length));
747
+ for (const r of share.SHARED_RESOURCES) {
748
+ const st = resources[r];
749
+ const note = st === 'shared' ? `→ ~/.claude/${r}` : (RESOURCE_STATE_NOTES[st] || '');
750
+ console.log(` ${resourceLabel(r).padEnd(width)} ${st.padEnd(12)} ${note}`.trimEnd());
751
+ }
564
752
 
565
753
  if (configError) {
566
754
  console.log('Endpoint: (invalid config — .cc-env.json is malformed)');
@@ -620,6 +808,7 @@ function cmdList(rest) {
620
808
  name: p.name,
621
809
  dir: p.dir,
622
810
  loggedIn: p.loggedIn,
811
+ credentialSource: p.credentialSource,
623
812
  email: reveal ? p.email : (p.email ? envLib.maskEmail(p.email) : p.email),
624
813
  shared: sharedCount,
625
814
  endpoint: envLib.endpointSummary(p.dir),
@@ -633,7 +822,7 @@ function cmdList(rest) {
633
822
 
634
823
  if (enriched.length === 0) {
635
824
  console.log(`No profiles yet in ${profiles.baseDir()}.`);
636
- console.log('Create one with: cc add <name>');
825
+ console.log('Create one with: cchost add <name>');
637
826
  return 0;
638
827
  }
639
828
 
@@ -674,7 +863,7 @@ function hasPrivateContent(dir) {
674
863
  async function cmdRemove(rest) {
675
864
  const { flags, positional } = parseFlagsStrict(rest, ['--force'], { allowPositional: true });
676
865
  const [name, ...extra] = positional;
677
- if (!name) throw usageError('Usage: cc remove <profile> [--force]');
866
+ if (!name) throw usageError('Usage: cchost remove <profile> [--force]');
678
867
  if (extra.length > 0) throw usageError(`Unexpected argument: ${extra[0]}`);
679
868
  const force = flags.has('--force');
680
869
  profiles.validateProfileName(name);
@@ -689,7 +878,7 @@ async function cmdRemove(rest) {
689
878
  const err = new Error(
690
879
  `Profile "${name}" has private (unlinked) resources whose content is not ` +
691
880
  `in ~/.claude/. Removing the profile will delete that content permanently.\n` +
692
- `Re-run with --force to confirm: cc remove ${name} --force`,
881
+ `Re-run with --force to confirm: cchost remove ${name} --force`,
693
882
  );
694
883
  err.exitCode = 1;
695
884
  throw err;
@@ -721,19 +910,22 @@ async function cmdRemove(rest) {
721
910
  async function cmdLink(rest) {
722
911
  const { flags, positional } = parseFlagsStrict(
723
912
  rest,
724
- ['--tools', '--projects', '--config'],
913
+ ['--tools', '--projects', '--config', '--dry-run'],
725
914
  { allowPositional: true },
726
915
  );
727
916
  if (positional.length === 0) {
728
917
  throw usageError(
729
- 'Usage: cc link <profile> [<profile>...] [--projects|--tools|--config]',
918
+ 'Usage: cchost link <profile> [<profile>...] [--projects|--tools|--config] [--dry-run]',
730
919
  );
731
920
  }
921
+ const dryRun = flags.has('--dry-run');
732
922
  const opts = {
733
923
  tools: flags.has('--tools'),
734
924
  projects: flags.has('--projects'),
735
925
  config: flags.has('--config'),
926
+ dryRun,
736
927
  };
928
+ if (dryRun) console.log('Dry run — nothing will be changed.');
737
929
  const multi = positional.length > 1;
738
930
  const summary = [];
739
931
  for (const name of positional) {
@@ -752,19 +944,27 @@ async function cmdLink(rest) {
752
944
  process.stderr.write(color.stderr.red(` ${label}: failed — ${r.message}`) + '\n');
753
945
  } else if (r.status === 'refused') {
754
946
  process.stderr.write(color.stderr.yellow(` ${label}: refused — ${r.message}`) + '\n');
947
+ } else if (r.status === 'foreign-link') {
948
+ process.stderr.write(color.stderr.yellow(` ${label}: foreign link — ${r.message}`) + '\n');
755
949
  } else if (r.status === 'already-linked') {
756
950
  console.log(` ${label}: already linked`);
757
951
  } else if (r.relinkedFromLegacy) {
758
- console.log(` ${label}: relinked (was pointing at legacy ~/.claude-shared/)`);
952
+ console.log(
953
+ ` ${label}: ${dryRun ? 'would relink' : 'relinked'} (was pointing at legacy ~/.claude-shared/)`,
954
+ );
955
+ } else if (dryRun) {
956
+ console.log(` ${label}: would link → ~/.claude/${r.resource}`);
759
957
  } else {
760
958
  const note = r.kind === 'hardlink' ? ' (hard link — enable Windows Developer Mode for a true symlink)' : '';
761
959
  console.log(` ${label}: linked${note}`);
762
960
  }
763
961
  }
764
- const failed = results.filter((r) => r.status === 'failed' || r.status === 'refused');
962
+ const failed = results.filter(
963
+ (r) => r.status === 'failed' || r.status === 'refused' || r.status === 'foreign-link',
964
+ );
765
965
  const linked = results.length - failed.length;
766
966
  console.log(
767
- ` ${linked}/${results.length} linked` +
967
+ ` ${dryRun ? 'would end with ' : ''}${linked}/${results.length} linked` +
768
968
  (failed.length ? `, ${failed.length} not linked` : '') + '.',
769
969
  );
770
970
  summary.push({
@@ -794,19 +994,22 @@ async function cmdLink(rest) {
794
994
  async function cmdUnlink(rest) {
795
995
  const { flags, positional } = parseFlagsStrict(
796
996
  rest,
797
- ['--tools', '--projects', '--config'],
997
+ ['--tools', '--projects', '--config', '--dry-run'],
798
998
  { allowPositional: true },
799
999
  );
800
1000
  const [name, ...extra] = positional;
801
1001
  if (!name) {
802
- throw usageError('Usage: cc unlink <profile> [--projects|--tools|--config]');
1002
+ throw usageError('Usage: cchost unlink <profile> [--projects|--tools|--config] [--dry-run]');
803
1003
  }
804
1004
  if (extra.length > 0) throw usageError(`Unexpected argument: ${extra[0]}`);
1005
+ const dryRun = flags.has('--dry-run');
805
1006
  const opts = {
806
1007
  tools: flags.has('--tools'),
807
1008
  projects: flags.has('--projects'),
808
1009
  config: flags.has('--config'),
1010
+ dryRun,
809
1011
  };
1012
+ if (dryRun) console.log('Dry run — nothing will be changed.');
810
1013
  const results = share.shareUnlink(name, opts);
811
1014
  let anyFailure = false;
812
1015
  for (const r of results) {
@@ -816,6 +1019,9 @@ async function cmdUnlink(rest) {
816
1019
  process.stderr.write(color.stderr.red(` ${label}: failed — ${r.message}`) + '\n');
817
1020
  } else if (r.status === 'not-linked') {
818
1021
  console.log(` ${label}: not linked, skipping`);
1022
+ } else if (dryRun) {
1023
+ const what = r.items === undefined ? 'its content' : `${r.items} item(s)`;
1024
+ console.log(` ${label}: would unlink and copy ${what} from ~/.claude/ into the profile`);
819
1025
  } else {
820
1026
  console.log(` ${label}: unlinked, content copied back`);
821
1027
  }
@@ -823,6 +1029,51 @@ async function cmdUnlink(rest) {
823
1029
  return anyFailure ? 1 : 0;
824
1030
  }
825
1031
 
1032
+ // `cchost ui` runs until Ctrl+C, so it never resolves.
1033
+ // '30m', '8h', '2d' -> milliseconds.
1034
+ function parseDuration(text) {
1035
+ const m = /^(\d+)(m|h|d)$/.exec(text);
1036
+ if (!m || Number(m[1]) === 0) return null;
1037
+ return Number(m[1]) * { m: 60e3, h: 3600e3, d: 86400e3 }[m[2]];
1038
+ }
1039
+
1040
+ async function cmdUi(rest) {
1041
+ let port = 0;
1042
+ let open = true;
1043
+ let user = 'cchost';
1044
+ let pass = null;
1045
+ let sessionTtlMs;
1046
+ for (const a of rest) {
1047
+ if (a === '--no-open') { open = false; continue; }
1048
+ const eq = a.indexOf('=');
1049
+ const flag = eq === -1 ? a : a.slice(0, eq);
1050
+ const value = eq === -1 ? null : a.slice(eq + 1);
1051
+ if (['--port', '--user', '--pass', '--session'].includes(flag) && !value) {
1052
+ throw usageError(`Flag ${flag} requires a value: ${flag}=VALUE`);
1053
+ }
1054
+ if (flag === '--port') {
1055
+ port = Number(value);
1056
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw usageError(`Invalid port: ${a}`);
1057
+ continue;
1058
+ }
1059
+ if (flag === '--user') { user = value; continue; }
1060
+ if (flag === '--pass') {
1061
+ if (value.length < 8) throw usageError('--pass must be at least 8 characters.');
1062
+ pass = value;
1063
+ continue;
1064
+ }
1065
+ if (flag === '--session') {
1066
+ sessionTtlMs = parseDuration(value);
1067
+ if (!sessionTtlMs) throw usageError(`Invalid --session "${value}". Use e.g. 30m, 8h or 2d.`);
1068
+ continue;
1069
+ }
1070
+ throw usageError(a.startsWith('-') ? `Unknown flag: ${flag}` : `Unexpected argument: ${a}`);
1071
+ }
1072
+ const { startUi } = require('./ui/server');
1073
+ await startUi({ port, open, user, pass, sessionTtlMs });
1074
+ return new Promise(() => {});
1075
+ }
1076
+
826
1077
  async function cmdDoctor(rest) {
827
1078
  const { flags } = parseFlagsStrict(rest, ['--fix', '--force', '--json']);
828
1079
  const fix = flags.has('--fix');
@@ -834,24 +1085,30 @@ async function cmdDoctor(rest) {
834
1085
  }
835
1086
 
836
1087
  async function cmdCopy(rest) {
837
- const { flags, positional } = parseFlagsStrict(rest, ['--force'], { allowPositional: true });
1088
+ const { flags, positional } = parseFlagsStrict(
1089
+ rest,
1090
+ ['--force', '--dry-run'],
1091
+ { allowPositional: true },
1092
+ );
838
1093
  const [src, dest, ...extra] = positional;
839
- if (!src || !dest) throw usageError('Usage: cc copy <src> <dest> [--force]');
1094
+ if (!src || !dest) throw usageError('Usage: cchost copy <src> <dest> [--force] [--dry-run]');
840
1095
  if (extra.length > 0) throw usageError(`Unexpected extra arguments: ${extra.join(' ')}`);
841
1096
  const force = flags.has('--force');
1097
+ const dryRun = flags.has('--dry-run');
1098
+ if (dryRun) process.stdout.write('Dry run — nothing will be changed.\n');
842
1099
  if (force) {
843
1100
  process.stdout.write(
844
- 'Warning: --force is set. Existing items at the destination will be overwritten.\n',
1101
+ `Warning: --force is set. Existing items at the destination ${dryRun ? 'would' : 'will'} be overwritten.\n`,
845
1102
  );
846
1103
  }
847
- copyLib.copy(src, dest, { force });
1104
+ copyLib.copy(src, dest, { force, dryRun });
848
1105
  return 0;
849
1106
  }
850
1107
 
851
1108
  async function cmdMigrate(rest) {
852
1109
  process.stderr.write(
853
1110
  color.stderr.yellow(
854
- "[cchost] 'cc migrate' is deprecated — use 'cc copy' instead. " +
1111
+ "[cchost] 'cchost migrate' is deprecated — use 'cchost copy' instead. " +
855
1112
  'This alias will be removed in a future version.',
856
1113
  ) + '\n',
857
1114
  );
@@ -876,8 +1133,8 @@ async function run(argv) {
876
1133
  if (action === 'env') {
877
1134
  process.stderr.write(
878
1135
  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>'.",
1136
+ "'cchost env' was removed in v0.3.1. Configure a custom endpoint with " +
1137
+ "'cchost add <profile> --custom', and view it with 'cchost list <profile>'.",
881
1138
  ) + '\n',
882
1139
  );
883
1140
  return 2;
@@ -901,6 +1158,8 @@ async function run(argv) {
901
1158
  return await cmdRemove(rest);
902
1159
  case 'doctor':
903
1160
  return await cmdDoctor(rest);
1161
+ case 'ui':
1162
+ return await cmdUi(rest);
904
1163
  default: {
905
1164
  process.stderr.write(color.stderr.red(`Unknown command: ${action}`) + '\n\n');
906
1165
  printHelp();