cchost 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,111 @@
1
+ # Changelog
2
+
3
+ All notable changes to `cchost` are documented here.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/).
7
+
8
+ ## [0.3.1] - 2026-05-19
9
+
10
+ ### Changed
11
+
12
+ - **Custom API endpoints are now configured through `cc add`, not `cc env`.**
13
+ The standalone `cc env` command (shipped in 0.3.0) is removed; endpoint
14
+ configuration is folded into the profile-creation command:
15
+ - `cc add <profile> --custom` — create a custom-endpoint profile via an
16
+ interactive wizard; the token is never echoed.
17
+ - `cc add <profile> --custom --base-url=URL --token=TOKEN [--model/--opus/--sonnet/--haiku/--subagent=NAME]`
18
+ — non-interactive create.
19
+ - Re-running `cc add <profile> --custom` on an existing endpoint profile
20
+ edits it in place (wizard prefills current values; with flags, only the
21
+ flags you pass are changed).
22
+ - `cc add --custom` is atomic — cancelling the wizard leaves no profile
23
+ behind.
24
+ - **Endpoint inspection moved to `cc list`.** `cc env show` is replaced by
25
+ `cc list <profile>` — a per-profile detail view. `cc list <profile> --reveal`
26
+ unmasks the API token; `cc list <profile> --json` emits it as an object
27
+ (token always masked in JSON).
28
+
29
+ ### Removed
30
+
31
+ - `cc env` and `cc env show` — superseded by `cc add --custom` and
32
+ `cc list <profile>`.
33
+
34
+ ### Notes
35
+
36
+ - A profile is still born subscription or endpoint and stays that way:
37
+ `cc add --custom` refuses a profile that already has Anthropic OAuth
38
+ credentials. `.cc-env.json` is still never migrated and never linked, and is
39
+ still written with user-only file permissions.
40
+
41
+ ## [0.3.0] - 2026-05-19
42
+
43
+ ### Added
44
+
45
+ - **Per-profile custom API endpoints (`cc env`).** A profile can now route
46
+ through any Anthropic-compatible third-party provider (Moonshot/Kimi,
47
+ OpenRouter, Requesty, self-hosted vLLM, corporate proxies) instead of
48
+ Anthropic's subscription OAuth.
49
+ - `cc env <profile>` — interactive wizard; the token is never echoed.
50
+ - `cc env <profile> --base-url=URL --token=TOKEN [--model/--opus/--sonnet/--haiku/--subagent=NAME]`
51
+ — non-interactive create, or partial update on an existing endpoint profile.
52
+ - `cc env <profile> show [--reveal]` — print the config; the token is masked
53
+ by default, `--reveal` prints it in full.
54
+ - `cc use` injects the corresponding `ANTHROPIC_*` / `CLAUDE_CODE_*` env vars
55
+ from `.cc-env.json` and prints a one-line endpoint + billing banner to stderr.
56
+ - `cc list` gained an `Endpoint` column (the endpoint host, or `subscription`).
57
+ - README section "Custom endpoints (Kimi, OpenRouter, etc.)" with a billing
58
+ note and a worked Moonshot example.
59
+
60
+ ### Security
61
+
62
+ - `.cc-env.json` holds a third-party API key. It is treated like
63
+ `.credentials.json`: written with user-only file permissions, and never
64
+ copied or linked by `cc migrate` / `cc link`.
65
+ - A profile is born OAuth or endpoint and stays that way — `cc env` refuses to
66
+ run on a profile that already has Anthropic OAuth credentials.
67
+
68
+ ### Notes
69
+
70
+ - Endpoint profiles bill through the third-party provider, not your Anthropic
71
+ subscription.
72
+
73
+ ## [0.2.1] - 2026-05-18
74
+
75
+ ### Added
76
+
77
+ - `cc link` / `cc unlink` now operate on `projects/`, `skills/`, `agents/`,
78
+ and `commands/` (previously `projects/` only), with per-directory
79
+ partial-failure handling.
80
+ - `cc migrate` extended: `default → <profile>` also copies `mcp.json`,
81
+ `settings.json`, and `CLAUDE.md`; `--force` overwrites instead of
82
+ skip-on-collision.
83
+ - `cc list` reports shared state as `N/4`.
84
+ - README: migrate-vs-link artifact table.
85
+
86
+ ## [0.2.0] - 2026-05-16
87
+
88
+ ### Added
89
+
90
+ - `cc --version` / `cc -v` — prints the installed version.
91
+ - `cc doctor [--fix [--force]] [--json]` — environment, storage, and platform
92
+ diagnostics with safe and confirmation-gated fixes.
93
+
94
+ ### Removed
95
+
96
+ - The `cc add` alias-masking feature — Claude Code re-syncs account fields from
97
+ the server on every launch, so masking was not worth shipping.
98
+
99
+ ## [0.1.2] - 2026-05-15
100
+
101
+ ### Changed
102
+
103
+ - `sessions/` dropped from `share`, `migrate`, and `cc list` — it is transient
104
+ per-process state and must stay per-profile.
105
+ - Cross-platform claim narrowed to the verified scope (Windows and Linux).
106
+
107
+ [0.3.1]: https://github.com/allenhack638/cchost/releases/tag/v0.3.1
108
+ [0.3.0]: https://github.com/allenhack638/cchost/releases/tag/v0.3.0
109
+ [0.2.1]: https://github.com/allenhack638/cchost/releases/tag/v0.2.1
110
+ [0.2.0]: https://github.com/allenhack638/cchost/releases/tag/v0.2.0
111
+ [0.1.2]: https://github.com/allenhack638/cchost/releases/tag/v0.1.2
package/README.md CHANGED
@@ -16,9 +16,10 @@ Requires Node.js 18+ and Claude Code 2.1.140+ already installed. Get Claude Code
16
16
 
17
17
  | Command | What it does |
18
18
  | --- | --- |
19
- | `cc add <profile>` | Create a profile directory. |
19
+ | `cc add <profile>` | Create a subscription profile (logs in with Anthropic OAuth on first use). |
20
+ | `cc add <profile> --custom [...]` | Create — or, if it already exists, edit — a profile routed through a custom API endpoint. Opens a wizard, or takes `--base-url=URL --token=TOKEN [--model=…]`. See [Custom endpoints](#custom-endpoints-kimi-openrouter-etc). |
20
21
  | `cc use <profile> [...args]` | Launch Claude under that profile. Profile must already exist. Args after the name are forwarded verbatim to `claude`. |
21
- | `cc list [--json]` | Show every profile: login state, account email, and how many artifact dirs are linked to shared (N/4). |
22
+ | `cc list [<profile>] [--json] [--reveal]` | List every profile (login state, email, shared dirs N/4, endpoint), or show one profile in detail. `--reveal` unmasks a custom endpoint's API token. |
22
23
  | `cc migrate <src> <dest> [--force]` | Copy artifacts from `default`\|`shared` to `shared`\|`<profile>`. Skip-on-collision by default; `--force` overwrites. |
23
24
  | `cc link <profile> [<profile>...]` | Link a profile's artifact dirs to `~/.claude-shared/`. Migrates existing content; renames on collision. |
24
25
  | `cc unlink <profile>` | Restore a profile to its own private artifact dirs (copies shared content back). |
@@ -40,12 +41,36 @@ cc remove old-profile
40
41
  `cc list` output:
41
42
 
42
43
  ```
43
- Profile LoggedIn Email Shared
44
- personal true personal@example.com 4/4
45
- scratch false - 0/4
46
- work true work@example.com 0/4
44
+ Profile LoggedIn Email Shared Endpoint
45
+ kimi false - 0/4 api.moonshot.ai
46
+ personal true personal@example.com 4/4 subscription
47
+ scratch false - 0/4 subscription
48
+ work true work@example.com 0/4 subscription
47
49
  ```
48
50
 
51
+ The **Shared** column is a count: how many of the 4 shareable directories
52
+ (`projects`, `skills`, `agents`, `commands`) are linked to `~/.claude-shared/`.
53
+
54
+ - `0/4` — fully isolated; the profile keeps its own private copies.
55
+ - `4/4` — fully shared; all four point at the common pool.
56
+ - `1/4`, `2/4`, `3/4` — **partial**, and not a normal state. `cc link` links
57
+ all four at once and `cc unlink` unlinks all four at once, so you should
58
+ normally see only `0/4` or `4/4`.
59
+
60
+ A partial count has two causes:
61
+
62
+ 1. **A link partially failed** — one directory could not be linked (a
63
+ permission issue, or it was locked) while the others succeeded. `cc link`
64
+ reports per-directory failures so this is never silent.
65
+ 2. **An upgrade from an older cchost.** Versions before `0.2.1` linked only
66
+ `projects/` — the other three shareable directories did not exist yet. A
67
+ profile linked under an older version therefore shows `1/4` after you
68
+ upgrade.
69
+
70
+ In both cases the fix is the same — re-run `cc link <profile>`. Already-linked
71
+ directories are skipped and the missing ones are linked, bringing the profile
72
+ to `4/4`.
73
+
49
74
  ### What migrate copies and what link shares
50
75
 
51
76
  | Artifact | `cc migrate` copies it | `cc link` shares it | Notes |
@@ -61,6 +86,7 @@ work true work@example.com 0/4
61
86
  | `plugins/` | — | — | Carries auth tokens; deferred to a future opt-in flag |
62
87
  | `.credentials.json` | — | — | Never touched — each profile keeps its own auth |
63
88
  | `.claude.json` | — | — | Never touched — re-synced from server on every launch |
89
+ | `.cc-env.json` | — | — | Per-profile custom-endpoint config; holds an API key. Never migrated, never linked. |
64
90
 
65
91
  `mcp.json`, `settings.json`, and `CLAUDE.md` are **not** copied to `shared` and are **not** linked because they typically carry auth tokens or per-profile configuration that should not leak across accounts.
66
92
 
@@ -98,6 +124,66 @@ If two profiles share history and you launch them against the same project at th
98
124
 
99
125
  `cc remove` is safe with shared profiles — it deletes only the links, never recursing into the shared target.
100
126
 
127
+ ## Custom endpoints (Kimi, OpenRouter, etc.)
128
+
129
+ A profile can route through any Anthropic-compatible API endpoint instead of Anthropic's subscription OAuth — Moonshot/Kimi, OpenRouter, Requesty, a self-hosted vLLM, or a corporate proxy. You declare this when you create the profile, with `cc add <profile> --custom`.
130
+
131
+ > **Billing:** a custom-endpoint profile bills through the **third-party provider**, using the API key you supply — **not** your Anthropic subscription. You are responsible for that provider's usage charges. A subscription profile is unaffected.
132
+
133
+ A custom-endpoint profile and a subscription profile are mutually exclusive: a profile is born one or the other and stays that way. `cc add --custom` refuses to run on a profile that already has Anthropic OAuth credentials — create custom-endpoint profiles fresh.
134
+
135
+ ### Worked example — Moonshot / Kimi
136
+
137
+ ```bash
138
+ cc add kimi --custom --base-url=https://api.moonshot.ai/anthropic \
139
+ --token=sk-your-moonshot-key \
140
+ --model=kimi-k2.5
141
+ cc use kimi
142
+ ```
143
+
144
+ Moonshot's endpoint URL ends in `/anthropic` — Claude Code appends `/v1/messages` itself, so do **not** add `/v1`. Most third-party providers serve a single model name; pass it as `--model` and it is applied to every tier (opus/sonnet/haiku/subagent). To override a tier, pass `--opus=`, `--sonnet=`, `--haiku=`, or `--subagent=`.
145
+
146
+ Run `cc add <profile> --custom` with no value flags for an interactive wizard instead — it prompts for every field and never echoes the token. `cc add --custom` is atomic: if you cancel the wizard, no profile is left behind.
147
+
148
+ ### Editing a custom-endpoint profile
149
+
150
+ Re-run `cc add <profile> --custom` on a profile that already has a custom endpoint and it switches to **edit mode**: the wizard prefills current values, and with flags only the flags you pass are changed.
151
+
152
+ ```bash
153
+ cc add kimi --custom --model=kimi-k3 # change just the model
154
+ cc add kimi --custom --token=sk-rotated # rotate the API key
155
+ ```
156
+
157
+ Inspect a config with `cc list <profile>` — the API token is masked:
158
+
159
+ ```
160
+ Profile: kimi
161
+ Directory: /home/you/.claude-profiles/kimi
162
+ LoggedIn: false
163
+ Email: -
164
+ Shared: 0/4
165
+ Endpoint: api.moonshot.ai
166
+ Base URL: https://api.moonshot.ai/anthropic
167
+ Token: sk-you...key9 (32 chars)
168
+ Model: kimi-k2.5
169
+ Opus: kimi-k2.5
170
+ Sonnet: kimi-k2.5
171
+ Haiku: kimi-k2.5
172
+ Subagent: kimi-k2.5
173
+ ```
174
+
175
+ `cc list <profile> --reveal` prints the full API key instead — do not paste that output anywhere.
176
+
177
+ There is no way to convert a custom-endpoint profile back to a subscription profile. To stop using an endpoint, `cc remove` the profile and recreate it.
178
+
179
+ When you launch a custom-endpoint profile, `cc use` prints a one-line reminder to stderr:
180
+
181
+ ```
182
+ [cchost] Profile 'kimi' → api.moonshot.ai (custom endpoint billing applies)
183
+ ```
184
+
185
+ The config lives at `~/.claude-profiles/<profile>/.cc-env.json` with user-only file permissions. It contains your API key — see [Sensitive data](#sensitive-data).
186
+
101
187
  ## How it works
102
188
 
103
189
  `cc` is a Node CLI with **zero runtime dependencies**. For `cc use` it:
@@ -113,6 +199,8 @@ The env var is set on the child process only — that's all `claude` needs.
113
199
 
114
200
  `~/.claude-profiles/<profile>/` holds real OAuth credentials. Treat it like SSH keys: don't commit it, don't share it, don't sync it to public cloud storage. The `.gitignore` shipped with this project excludes profile directories from anywhere they might land. `cc migrate` and `cc link` never copy `.credentials.json` or `.claude.json` across profiles.
115
201
 
202
+ `.cc-env.json` (custom-endpoint config) contains a third-party **API key** — treat it exactly like `.credentials.json`. It is written with user-only file permissions, and `cc migrate`/`cc link` never copy or link it across profiles. `cc list <profile>` masks the token; only `cc list <profile> --reveal` prints it in full.
203
+
116
204
  ## Troubleshooting
117
205
 
118
206
  **`Claude Code does not appear to be installed`**
package/lib/cli.js CHANGED
@@ -7,6 +7,7 @@ const readline = require('readline');
7
7
  const profiles = require('./profiles');
8
8
  const share = require('./share');
9
9
  const migrateLib = require('./migrate');
10
+ const envLib = require('./env');
10
11
  const { spawnClaude } = require('./spawn');
11
12
  const { renderTable } = require('./format');
12
13
  const color = require('./color');
@@ -14,9 +15,9 @@ const color = require('./color');
14
15
  const HELP = `cc - Multi-profile launcher for Claude Code
15
16
 
16
17
  Usage:
17
- cc add <profile> Create a profile directory
18
+ cc add <profile> [--custom [...]] Create a profile (--custom: route through a custom API endpoint)
18
19
  cc use <profile> [...args] Launch Claude under a named profile
19
- cc list [--json] Show all profiles: login state, email, shared-dir count (N/4)
20
+ cc list [<profile>] [--json] [--reveal] List all profiles, or show one profile in detail
20
21
  cc migrate <src> <dest> [--force] Copy artifact dirs (and config files) between profiles
21
22
  cc link <p>... Link each profile's artifact dirs to ~/.claude-shared/
22
23
  cc unlink <profile> Restore a profile's private artifact dirs
@@ -30,13 +31,43 @@ for that launch. Args after the profile name are forwarded verbatim to claude.
30
31
  `;
31
32
 
32
33
  const HELP_BY_CMD = {
33
- add: `cc add <profile>
34
+ add: `cc add <profile> [--custom [--base-url=URL --token=TOKEN ...]]
34
35
 
35
36
  Create a new profile directory without launching Claude.
36
37
 
38
+ cc add <profile>
39
+ Create a regular profile. It logs in with your Anthropic
40
+ subscription (OAuth) on the first 'cc use'.
41
+
42
+ cc add <profile> --custom
43
+ Create a profile routed through a custom, Anthropic-compatible API
44
+ endpoint — Moonshot/Kimi, OpenRouter, a self-hosted vLLM, a corporate
45
+ proxy, and so on. Opens an interactive wizard; the API token is never
46
+ echoed to the screen.
47
+
48
+ cc add <profile> --custom --base-url=URL --token=TOKEN
49
+ [--model=NAME] [--opus=NAME] [--sonnet=NAME]
50
+ [--haiku=NAME] [--subagent=NAME]
51
+ Same, non-interactive. --base-url and --token are required.
52
+
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>'.
57
+
58
+ Billing: a custom-endpoint profile bills through the third-party provider,
59
+ 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.
62
+
63
+ The endpoint config lives at ~/.claude-profiles/<profile>/.cc-env.json with
64
+ user-only permissions. It is never migrated and never linked.
65
+
37
66
  Examples:
38
67
  cc add work
39
- cc add personal
68
+ cc add kimi --custom
69
+ 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
40
71
  `,
41
72
  use: `cc use <profile> [...args]
42
73
 
@@ -52,7 +83,9 @@ Examples:
52
83
  cc use work --resume
53
84
  cc use personal -p "summarize this codebase"
54
85
  `,
55
- list: `cc list [--json]
86
+ list: `cc list [<profile>] [--json] [--reveal]
87
+
88
+ Without a profile name: list every profile as a table.
56
89
 
57
90
  Columns:
58
91
  Profile - profile name
@@ -61,8 +94,18 @@ Columns:
61
94
  Shared - N/4 linked to ~/.claude-shared/ (projects, skills, agents, commands)
62
95
  4/4 = fully shared, 0/4 = fully isolated
63
96
  (sessions/ is transient per-process state and is never shared)
64
-
65
- With --json, emits an array of {name, dir, loggedIn, email, shared}.
97
+ Endpoint - custom-endpoint host from .cc-env.json, or "subscription" if the
98
+ profile uses Anthropic's subscription OAuth
99
+
100
+ With a profile name: print that one profile's detail, including its custom
101
+ 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)
66
109
  `,
67
110
  migrate: `cc migrate <src> <dest> [--force]
68
111
 
@@ -231,14 +274,104 @@ function parseFlagsStrict(args, flagNames, { allowPositional = false } = {}) {
231
274
  return { flags, positional };
232
275
  }
233
276
 
277
+ // Flag name -> .cc-env.json field, for `cc add --custom`.
278
+ const ENDPOINT_VALUE_FLAGS = {
279
+ '--base-url': 'base_url',
280
+ '--token': 'auth_token',
281
+ '--model': 'model',
282
+ '--opus': 'opus',
283
+ '--sonnet': 'sonnet',
284
+ '--haiku': 'haiku',
285
+ '--subagent': 'subagent',
286
+ };
287
+
234
288
  async function cmdAdd(rest) {
235
- const [name, ...extra] = rest;
236
- if (!name) throw usageError('Usage: cc add <profile>');
289
+ const positional = [];
290
+ const provided = {};
291
+ let custom = false;
292
+
293
+ for (const a of rest) {
294
+ if (a.startsWith('-')) {
295
+ const eq = a.indexOf('=');
296
+ const flagName = eq === -1 ? a : a.slice(0, eq);
297
+ if (flagName === '--custom') {
298
+ custom = true;
299
+ continue;
300
+ }
301
+ if (Object.prototype.hasOwnProperty.call(ENDPOINT_VALUE_FLAGS, flagName)) {
302
+ if (eq === -1) {
303
+ throw usageError(`Flag ${flagName} requires a value: ${flagName}=VALUE`);
304
+ }
305
+ provided[ENDPOINT_VALUE_FLAGS[flagName]] = a.slice(eq + 1);
306
+ continue;
307
+ }
308
+ throw usageError(`Unknown flag: ${flagName}`);
309
+ }
310
+ positional.push(a);
311
+ }
312
+
313
+ const [name, ...extra] = positional;
314
+ if (!name) {
315
+ throw usageError('Usage: cc add <profile> [--custom [--base-url=URL --token=TOKEN ...]]');
316
+ }
237
317
  if (extra.length > 0) throw usageError(`cc add takes no extra arguments: ${extra[0]}`);
238
- const { dir } = profiles.addProfile(name);
239
- console.log(`Created profile "${name}" at ${dir}.`);
240
- console.log(`Next: cc use ${name}`);
241
- return 0;
318
+ profiles.validateProfileName(name);
319
+
320
+ if (Object.keys(provided).length > 0 && !custom) {
321
+ throw usageError(
322
+ 'Endpoint flags (--base-url, --token, --model, ...) require --custom: ' +
323
+ `cc add ${name} --custom ...`,
324
+ );
325
+ }
326
+
327
+ // Plain subscription profile.
328
+ if (!custom) {
329
+ const { dir } = profiles.addProfile(name);
330
+ console.log(`Created profile "${name}" at ${dir}.`);
331
+ console.log(`Next: cc use ${name}`);
332
+ return 0;
333
+ }
334
+
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.
338
+ const dir = profiles.profileDir(name);
339
+ const existed = fs.existsSync(dir);
340
+ if (!existed) profiles.addProfile(name);
341
+
342
+ let result;
343
+ try {
344
+ result = await envLib.runConfigure(name, dir, provided);
345
+ } catch (err) {
346
+ // A freshly-created profile that could not be configured leaves nothing
347
+ // behind — `cc add --custom` is atomic.
348
+ if (!existed) {
349
+ try {
350
+ removeRecursivelyPreservingLinks(dir);
351
+ } catch {
352
+ // best-effort cleanup
353
+ }
354
+ }
355
+ throw err;
356
+ }
357
+
358
+ if (!result.written) {
359
+ // Wizard cancelled.
360
+ if (!existed) {
361
+ try {
362
+ removeRecursivelyPreservingLinks(dir);
363
+ } catch {
364
+ // best-effort cleanup
365
+ }
366
+ console.log(`Cancelled. Profile "${name}" was not created.`);
367
+ } else {
368
+ console.log('Cancelled. No changes written.');
369
+ }
370
+ return result.code;
371
+ }
372
+
373
+ if (!existed) console.log(`Next: cc use ${name}`);
374
+ return result.code;
242
375
  }
243
376
 
244
377
  async function cmdUse(rest) {
@@ -251,11 +384,128 @@ async function cmdUse(rest) {
251
384
  err.exitCode = 1;
252
385
  throw err;
253
386
  }
254
- return spawnClaude({ args: passthrough, profileDir: dir });
387
+
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
+ let extraEnv = {};
391
+ const envConfig = envLib.readEnvConfig(dir); // throws on a malformed file
392
+ if (envConfig) {
393
+ extraEnv = envLib.buildEnvVars(envConfig);
394
+ const host = envConfig.base_url ? envLib.hostOf(envConfig.base_url) : '(unknown)';
395
+ process.stderr.write(
396
+ `[cchost] Profile '${name}' → ${host} (custom endpoint billing applies)\n`,
397
+ );
398
+ }
399
+ return spawnClaude({ args: passthrough, profileDir: dir, extraEnv });
400
+ }
401
+
402
+ // `cc list <profile>` — detail for a single profile, including its custom
403
+ // endpoint configuration if it has one.
404
+ function cmdListOne(name, { json, reveal }) {
405
+ if (json && reveal) {
406
+ throw usageError('--reveal cannot be combined with --json.');
407
+ }
408
+ profiles.validateProfileName(name);
409
+ const dir = profiles.profileDir(name);
410
+ if (!fs.existsSync(dir)) {
411
+ const e = new Error(`Profile "${name}" does not exist. Create it with: cc add ${name}`);
412
+ e.exitCode = 1;
413
+ throw e;
414
+ }
415
+
416
+ const total = share.SHARED_SUBDIRS.length;
417
+ const sharedCount = share.SHARED_SUBDIRS.filter((sub) =>
418
+ isLinkLike(path.join(dir, sub)),
419
+ ).length;
420
+ const loggedIn = profiles.hasCredentials(dir);
421
+ const email = profiles.readEmail(dir);
422
+
423
+ let config = null;
424
+ let configError = false;
425
+ try {
426
+ config = envLib.readEnvConfig(dir);
427
+ } catch {
428
+ configError = true;
429
+ }
430
+
431
+ if (json) {
432
+ const obj = {
433
+ name,
434
+ dir,
435
+ loggedIn,
436
+ email,
437
+ shared: sharedCount,
438
+ endpoint: envLib.endpointSummary(dir),
439
+ };
440
+ if (config) {
441
+ obj.endpointConfig = {
442
+ base_url: config.base_url || null,
443
+ // The token is always masked in JSON output — scripts never need it.
444
+ auth_token: config.auth_token ? envLib.maskToken(config.auth_token) : null,
445
+ model: config.model || null,
446
+ opus: config.opus || config.model || null,
447
+ sonnet: config.sonnet || config.model || null,
448
+ haiku: config.haiku || config.model || null,
449
+ subagent: config.subagent || config.model || null,
450
+ };
451
+ }
452
+ process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
453
+ return 0;
454
+ }
455
+
456
+ console.log(`Profile: ${name}`);
457
+ console.log(`Directory: ${dir}`);
458
+ console.log(`LoggedIn: ${loggedIn ? 'true' : 'false'}`);
459
+ console.log(`Email: ${email || '-'}`);
460
+ console.log(`Shared: ${sharedCount}/${total}`);
461
+
462
+ if (configError) {
463
+ console.log('Endpoint: (invalid config — .cc-env.json is malformed)');
464
+ return 0;
465
+ }
466
+ if (!config) {
467
+ console.log('Endpoint: subscription');
468
+ return 0;
469
+ }
470
+
471
+ console.log(
472
+ `Endpoint: ${config.base_url ? envLib.hostOf(config.base_url) : '(invalid config)'}`,
473
+ );
474
+ console.log(` Base URL: ${config.base_url || '-'}`);
475
+ if (reveal) {
476
+ console.log(` Token: ${config.auth_token || '-'}`);
477
+ } else {
478
+ console.log(` Token: ${config.auth_token ? envLib.maskToken(config.auth_token) : '-'}`);
479
+ }
480
+ console.log(` Model: ${config.model || '-'}`);
481
+ console.log(` Opus: ${config.opus || config.model || '-'}`);
482
+ console.log(` Sonnet: ${config.sonnet || config.model || '-'}`);
483
+ console.log(` Haiku: ${config.haiku || config.model || '-'}`);
484
+ console.log(` Subagent: ${config.subagent || config.model || '-'}`);
485
+ if (reveal) {
486
+ 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.');
489
+ }
490
+ return 0;
255
491
  }
256
492
 
257
493
  function cmdList(rest) {
258
- const { flags } = parseFlagsStrict(rest, ['--json']);
494
+ const { flags, positional } = parseFlagsStrict(rest, ['--json', '--reveal'], {
495
+ allowPositional: true,
496
+ });
497
+ const json = flags.has('--json');
498
+ const reveal = flags.has('--reveal');
499
+ const [name, ...extra] = positional;
500
+ if (extra.length > 0) throw usageError(`Unexpected argument: ${extra[0]}`);
501
+
502
+ if (name) {
503
+ return cmdListOne(name, { json, reveal });
504
+ }
505
+ if (reveal) {
506
+ throw usageError('--reveal requires a profile name: cc list <profile> --reveal');
507
+ }
508
+
259
509
  const list = profiles.listProfiles();
260
510
  const total = share.SHARED_SUBDIRS.length;
261
511
 
@@ -269,6 +519,7 @@ function cmdList(rest) {
269
519
  loggedIn: p.loggedIn,
270
520
  email: p.email,
271
521
  shared: sharedCount,
522
+ endpoint: envLib.endpointSummary(p.dir),
272
523
  };
273
524
  });
274
525
 
@@ -283,13 +534,14 @@ function cmdList(rest) {
283
534
  return 0;
284
535
  }
285
536
 
286
- const rows = [['Profile', 'LoggedIn', 'Email', 'Shared']];
537
+ const rows = [['Profile', 'LoggedIn', 'Email', 'Shared', 'Endpoint']];
287
538
  for (const p of enriched) {
288
539
  rows.push([
289
540
  p.name,
290
541
  p.loggedIn ? 'true' : 'false',
291
542
  p.email ? p.email : '-',
292
543
  `${p.shared}/${total}`,
544
+ p.endpoint,
293
545
  ]);
294
546
  }
295
547
  console.log(renderTable(rows));
@@ -432,6 +684,16 @@ async function run(argv) {
432
684
  printHelp(rest[0]);
433
685
  return 0;
434
686
  }
687
+ if (action === 'env') {
688
+ // `cc env` was removed in v0.3.1 — point upgraders at the new commands.
689
+ process.stderr.write(
690
+ color.stderr.red(
691
+ "'cc env' was removed in v0.3.1. Configure a custom endpoint with " +
692
+ "'cc add <profile> --custom', and view it with 'cc list <profile>'.",
693
+ ) + '\n',
694
+ );
695
+ return 2;
696
+ }
435
697
  switch (action) {
436
698
  case 'add':
437
699
  return await cmdAdd(rest);
package/lib/env.js ADDED
@@ -0,0 +1,455 @@
1
+ 'use strict';
2
+
3
+ // Per-profile custom API endpoints.
4
+ //
5
+ // A profile may carry a `.cc-env.json` file that routes Claude Code through an
6
+ // Anthropic-compatible third-party provider (Moonshot/Kimi, OpenRouter, vLLM,
7
+ // corporate proxies) instead of Anthropic's subscription OAuth. The file holds
8
+ // an API key and is treated with the same care as `.credentials.json`: it is
9
+ // never migrated, never linked, and written with user-only file permissions.
10
+ //
11
+ // Endpoint profiles are created and edited through `cc add <profile> --custom`;
12
+ // this module supplies the configuration logic that command delegates to.
13
+
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+ const readline = require('readline');
17
+ const { spawnSync } = require('child_process');
18
+
19
+ const ENV_FILE = '.cc-env.json';
20
+
21
+ // Persisted fields, in canonical serialization order.
22
+ const FIELDS = ['base_url', 'auth_token', 'model', 'opus', 'sonnet', 'haiku', 'subagent'];
23
+ const TIER_FIELDS = ['opus', 'sonnet', 'haiku', 'subagent'];
24
+
25
+ // File field -> env var(s) injected into the spawned child.
26
+ const TIER_ENV = {
27
+ opus: 'ANTHROPIC_DEFAULT_OPUS_MODEL',
28
+ sonnet: 'ANTHROPIC_DEFAULT_SONNET_MODEL',
29
+ haiku: 'ANTHROPIC_DEFAULT_HAIKU_MODEL',
30
+ subagent: 'CLAUDE_CODE_SUBAGENT_MODEL',
31
+ };
32
+
33
+ // Wizard layout: the index here is the review-screen number (1-based).
34
+ const WIZARD_FIELDS = [
35
+ { key: 'base_url', label: 'Base URL', hidden: false, required: true },
36
+ { key: 'auth_token', label: 'Auth token', hidden: true, required: true },
37
+ { key: 'model', label: 'Main model', hidden: false, required: false },
38
+ { key: 'opus', label: 'Opus tier', hidden: false, required: false, tier: true },
39
+ { key: 'sonnet', label: 'Sonnet tier', hidden: false, required: false, tier: true },
40
+ { key: 'haiku', label: 'Haiku tier', hidden: false, required: false, tier: true },
41
+ { key: 'subagent', label: 'Subagent model', hidden: false, required: false, tier: true },
42
+ ];
43
+
44
+ function usageError(msg) {
45
+ const e = new Error(msg);
46
+ e.exitCode = 2;
47
+ return e;
48
+ }
49
+
50
+ function stateError(msg) {
51
+ const e = new Error(msg);
52
+ e.exitCode = 1;
53
+ return e;
54
+ }
55
+
56
+ function envFilePath(profileDir) {
57
+ return path.join(profileDir, ENV_FILE);
58
+ }
59
+
60
+ function hasEnvConfig(profileDir) {
61
+ return fs.existsSync(envFilePath(profileDir));
62
+ }
63
+
64
+ // Reads and parses `.cc-env.json`. Returns the parsed object, or null if the
65
+ // file does not exist. Throws (exitCode 1, `.malformed` set) if the file is
66
+ // present but is not a JSON object.
67
+ function readEnvConfig(profileDir) {
68
+ const p = envFilePath(profileDir);
69
+ if (!fs.existsSync(p)) return null;
70
+ let data;
71
+ try {
72
+ data = JSON.parse(fs.readFileSync(p, 'utf8'));
73
+ } catch (err) {
74
+ const e = stateError(`${ENV_FILE} is malformed JSON: ${err.message}`);
75
+ e.malformed = true;
76
+ throw e;
77
+ }
78
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
79
+ const e = stateError(`${ENV_FILE} is malformed: expected a JSON object.`);
80
+ e.malformed = true;
81
+ throw e;
82
+ }
83
+ return data;
84
+ }
85
+
86
+ // True if the profile shows any prior OAuth (subscription) state — either a
87
+ // `.credentials.json` file, or a `.claude.json` with a non-null `oauthAccount`.
88
+ // Such a profile can never be converted to an endpoint profile (Rule 1).
89
+ function hasOAuthState(profileDir) {
90
+ if (fs.existsSync(path.join(profileDir, '.credentials.json'))) return true;
91
+ try {
92
+ const raw = fs.readFileSync(path.join(profileDir, '.claude.json'), 'utf8');
93
+ if (raw.trim()) {
94
+ const data = JSON.parse(raw);
95
+ if (data && data.oauthAccount != null) return true;
96
+ }
97
+ } catch {
98
+ // Missing or unreadable .claude.json is not OAuth state.
99
+ }
100
+ return false;
101
+ }
102
+
103
+ // Locks a file down to the current user only. Mirrors how Claude Code itself
104
+ // protects `.credentials.json`.
105
+ function secureFile(filePath) {
106
+ if (process.platform === 'win32') {
107
+ const user = process.env.USERNAME;
108
+ if (!user) return;
109
+ // Qualify the principal as DOMAIN\user. A bare username that collides with
110
+ // the computer name (common on workgroup machines) resolves to a broken
111
+ // principal, which would lock the file's own owner out — verified.
112
+ const domain = process.env.USERDOMAIN;
113
+ const principal = domain ? `${domain}\\${user}` : user;
114
+ spawnSync('icacls', [filePath, '/inheritance:r', '/grant:r', `${principal}:F`], {
115
+ stdio: 'ignore',
116
+ });
117
+ } else {
118
+ fs.chmodSync(filePath, 0o600);
119
+ }
120
+ }
121
+
122
+ // Writes `.cc-env.json` atomically (temp file + rename) so a cancel or crash
123
+ // never leaves a partial file. Only defined, non-empty fields are persisted.
124
+ function writeEnvConfig(profileDir, config) {
125
+ const out = {};
126
+ for (const f of FIELDS) {
127
+ if (config[f] != null && config[f] !== '') out[f] = config[f];
128
+ }
129
+ const target = envFilePath(profileDir);
130
+ const tmp = path.join(profileDir, `${ENV_FILE}.tmp-${process.pid}`);
131
+ // Write to a temp file then rename, so a crash never leaves a partial file.
132
+ // Permissions are locked down after the rename: restricting the temp file's
133
+ // ACL first makes the subsequent rename fail with EPERM on Windows.
134
+ fs.writeFileSync(tmp, JSON.stringify(out, null, 2) + '\n');
135
+ try {
136
+ fs.renameSync(tmp, target);
137
+ } catch (err) {
138
+ try {
139
+ fs.unlinkSync(tmp);
140
+ } catch {
141
+ // best effort cleanup
142
+ }
143
+ throw err;
144
+ }
145
+ secureFile(target);
146
+ return out;
147
+ }
148
+
149
+ // Maps a config object to the ANTHROPIC_*/CLAUDE_CODE_* env vars injected into
150
+ // the spawned child. Per-tier model vars fall back to `model`; if `model` is
151
+ // also missing, the var is omitted so Claude Code uses its built-in defaults.
152
+ function buildEnvVars(config) {
153
+ const env = {};
154
+ if (config.base_url) env.ANTHROPIC_BASE_URL = config.base_url;
155
+ if (config.auth_token) {
156
+ // Different providers read different vars — set both.
157
+ env.ANTHROPIC_AUTH_TOKEN = config.auth_token;
158
+ env.ANTHROPIC_API_KEY = config.auth_token;
159
+ }
160
+ if (config.model) env.ANTHROPIC_MODEL = config.model;
161
+ for (const tier of TIER_FIELDS) {
162
+ const val = config[tier] || config.model;
163
+ if (val) env[TIER_ENV[tier]] = val;
164
+ }
165
+ return env;
166
+ }
167
+
168
+ function hostOf(url) {
169
+ try {
170
+ return new URL(url).host;
171
+ } catch {
172
+ return url;
173
+ }
174
+ }
175
+
176
+ // Endpoint summary for `cc list`. Returns the base-url host, 'subscription'
177
+ // (no endpoint config), or '(invalid config)' for a malformed file — never
178
+ // throws, so a single bad profile cannot break the whole table.
179
+ function endpointSummary(profileDir) {
180
+ let config;
181
+ try {
182
+ config = readEnvConfig(profileDir);
183
+ } catch {
184
+ return '(invalid config)';
185
+ }
186
+ if (!config) return 'subscription';
187
+ if (!config.base_url) return '(invalid config)';
188
+ return hostOf(config.base_url);
189
+ }
190
+
191
+ function maskToken(token) {
192
+ const n = token.length;
193
+ if (n <= 10) return `${'*'.repeat(n)} (${n} chars)`;
194
+ return `${token.slice(0, 6)}...${token.slice(-4)} (${n} chars)`;
195
+ }
196
+
197
+ function validateBaseUrl(url, warn) {
198
+ if (!/^https?:\/\/.+/.test(url)) {
199
+ throw usageError(`Invalid base_url "${url}": must start with http:// or https://`);
200
+ }
201
+ let u = null;
202
+ try {
203
+ u = new URL(url);
204
+ } catch {
205
+ throw usageError(`Invalid base_url "${url}": not a parseable URL.`);
206
+ }
207
+ if (u.pathname.replace(/\/+$/, '').endsWith('/v1')) {
208
+ warn('cchost: base_url ending in /v1 is unusual; most Anthropic-compatible endpoints end in / or /anthropic');
209
+ }
210
+ if (u.protocol === 'http:' && u.hostname !== 'localhost' && u.hostname !== '127.0.0.1') {
211
+ warn('cchost: base_url uses http:// to a non-local host; the auth token will be sent unencrypted');
212
+ }
213
+ }
214
+
215
+ function validateToken(token) {
216
+ if (typeof token !== 'string' || token.length === 0) {
217
+ throw usageError('Auth token must not be empty.');
218
+ }
219
+ if (token !== token.trim()) {
220
+ throw usageError('Auth token must not have leading or trailing whitespace.');
221
+ }
222
+ if (/[\r\n]/.test(token)) {
223
+ throw usageError('Auth token must not contain newlines.');
224
+ }
225
+ }
226
+
227
+ function validateModelField(label, val) {
228
+ if (typeof val !== 'string' || val.length === 0) {
229
+ throw usageError(`${label} must not be empty if provided.`);
230
+ }
231
+ }
232
+
233
+ // Validates one provided field by key. base_url warnings go through `warn`.
234
+ function validateField(key, value, warn) {
235
+ if (key === 'base_url') validateBaseUrl(value, warn);
236
+ else if (key === 'auth_token') validateToken(value);
237
+ else validateModelField(key, value);
238
+ }
239
+
240
+ // ---------------------------------------------------------------------------
241
+ // Wizard
242
+ // ---------------------------------------------------------------------------
243
+
244
+ // Default terminal IO for the wizard. `readHidden` writes the prompt, then
245
+ // suppresses echo of typed characters so the token never appears on screen.
246
+ function defaultWizardIo() {
247
+ return {
248
+ write: (s) => process.stdout.write(s),
249
+ readLine: (q) =>
250
+ new Promise((resolve) => {
251
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
252
+ rl.question(q, (a) => {
253
+ rl.close();
254
+ resolve(a);
255
+ });
256
+ }),
257
+ readHidden: (q) =>
258
+ new Promise((resolve) => {
259
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
260
+ let muted = false;
261
+ rl._writeToOutput = (s) => {
262
+ if (!muted) rl.output.write(s);
263
+ };
264
+ rl.question(q, (a) => {
265
+ rl.close();
266
+ process.stdout.write('\n');
267
+ resolve(a);
268
+ });
269
+ // The prompt was written above (still unmuted); from here, suppress
270
+ // the echo of whatever the user types.
271
+ muted = true;
272
+ }),
273
+ };
274
+ }
275
+
276
+ function reviewValue(field, config) {
277
+ if (field.key === 'auth_token') {
278
+ return config.auth_token ? maskToken(config.auth_token) : '-';
279
+ }
280
+ if (field.tier) {
281
+ return config[field.key] || config.model || '-';
282
+ }
283
+ return config[field.key] || '-';
284
+ }
285
+
286
+ function renderReview(config, io) {
287
+ io.write('\nReview:\n');
288
+ WIZARD_FIELDS.forEach((field, i) => {
289
+ const label = `${field.label}:`.padEnd(16);
290
+ io.write(` ${i + 1}. ${label}${reviewValue(field, config)}\n`);
291
+ });
292
+ }
293
+
294
+ // Prompts for one field, looping until the input is valid. An empty input
295
+ // keeps the current value; a required field with no current value re-prompts.
296
+ async function promptOne(field, config, io, isEdit) {
297
+ for (;;) {
298
+ let placeholder = '';
299
+ let promptText;
300
+ if (field.hidden) {
301
+ promptText = `${field.label}${isEdit && config[field.key] ? ' [keep current]' : ''}: `;
302
+ } else {
303
+ if (field.tier) placeholder = config[field.key] || config.model || '';
304
+ else placeholder = config[field.key] || '';
305
+ promptText = `${field.label} [${placeholder}]: `;
306
+ }
307
+
308
+ const raw = field.hidden
309
+ ? await io.readHidden(promptText)
310
+ : await io.readLine(promptText);
311
+ const value = raw.trim();
312
+
313
+ if (value === '') {
314
+ // Keep the current value. base_url/auth_token must end up set.
315
+ if (field.required && !config[field.key]) {
316
+ io.write(`${field.label} is required.\n`);
317
+ continue;
318
+ }
319
+ return;
320
+ }
321
+
322
+ try {
323
+ validateField(field.key, value, (m) => io.write(m + '\n'));
324
+ } catch (err) {
325
+ io.write(err.message + '\n');
326
+ continue;
327
+ }
328
+ config[field.key] = value;
329
+ return;
330
+ }
331
+ }
332
+
333
+ // Runs the interactive wizard. Returns the collected config on submit, or
334
+ // null if the user cancelled. Never writes the file itself.
335
+ async function runWizard(name, existing, io) {
336
+ const isEdit = !!existing;
337
+ const config = {};
338
+ if (existing) {
339
+ for (const f of FIELDS) {
340
+ if (existing[f] != null) config[f] = existing[f];
341
+ }
342
+ }
343
+
344
+ io.write(`\ncchost endpoint configuration for profile '${name}'${isEdit ? ' (editing)' : ''}\n\n`);
345
+ for (const field of WIZARD_FIELDS) {
346
+ await promptOne(field, config, io, isEdit);
347
+ }
348
+
349
+ for (;;) {
350
+ renderReview(config, io);
351
+ const ans = (await io.readLine('\n[s]ubmit, [e]dit N, [c]ancel: ')).trim().toLowerCase();
352
+ if (ans === 's') return config;
353
+ if (ans === 'c') return null;
354
+ const m = /^e\s+([1-7])$/.exec(ans);
355
+ if (m) {
356
+ await promptOne(WIZARD_FIELDS[Number(m[1]) - 1], config, io, isEdit);
357
+ }
358
+ // Anything else: silently re-display the review.
359
+ }
360
+ }
361
+
362
+ // ---------------------------------------------------------------------------
363
+ // Command orchestration (called from cli.js)
364
+ // ---------------------------------------------------------------------------
365
+
366
+ // Configures a profile's custom endpoint. Called by `cc add <profile> --custom`
367
+ // (both for first-time creation and for editing an existing endpoint profile).
368
+ // `provided` is a partial config of fields supplied via flags.
369
+ // `opts.io` injects wizard IO (tests); `opts.isTTY` overrides TTY detection.
370
+ //
371
+ // Returns `{ code, written }`: `written` is false only when the interactive
372
+ // wizard was cancelled, so the caller can clean up a freshly-created profile.
373
+ async function runConfigure(name, profileDir, provided, opts = {}) {
374
+ if (!fs.existsSync(profileDir)) {
375
+ throw stateError(`Profile "${name}" does not exist. Create it with: cc add ${name}`);
376
+ }
377
+ if (hasOAuthState(profileDir)) {
378
+ throw stateError(
379
+ `Profile '${name}' has OAuth credentials and cannot be converted to a custom endpoint.\n` +
380
+ 'A profile is born subscription or endpoint and stays that way.\n' +
381
+ 'Create a fresh endpoint profile instead:\n' +
382
+ ' cc add <new-name> --custom',
383
+ );
384
+ }
385
+
386
+ const existing = readEnvConfig(profileDir); // throws on malformed file
387
+ const editing = existing != null;
388
+ const nonInteractive = Object.keys(provided).length > 0;
389
+
390
+ if (nonInteractive) {
391
+ const warn = (m) => process.stderr.write(m + '\n');
392
+ // Creating: base_url and auth_token are mandatory.
393
+ if (!editing) {
394
+ const merged = { ...provided };
395
+ if (!merged.base_url || !merged.auth_token) {
396
+ throw usageError(
397
+ 'Creating an endpoint profile requires both --base-url and --token.',
398
+ );
399
+ }
400
+ }
401
+ for (const [key, value] of Object.entries(provided)) {
402
+ validateField(key, value, warn);
403
+ }
404
+ const merged = { ...(existing || {}), ...provided };
405
+ writeEnvConfig(profileDir, merged);
406
+ console.log(
407
+ `${editing ? 'Updated' : 'Created'} endpoint config for profile '${name}' → ${hostOf(merged.base_url)}.`,
408
+ );
409
+ return { code: 0, written: true };
410
+ }
411
+
412
+ // Interactive wizard.
413
+ let io = opts.io;
414
+ if (!io) {
415
+ const isTTY = opts.isTTY != null ? opts.isTTY : process.stdin.isTTY;
416
+ if (!isTTY) {
417
+ throw usageError(
418
+ 'cc add --custom requires either endpoint flags or an interactive terminal.',
419
+ );
420
+ }
421
+ io = defaultWizardIo();
422
+ }
423
+
424
+ const result = await runWizard(name, existing, io);
425
+ if (result == null) {
426
+ return { code: 0, written: false };
427
+ }
428
+ writeEnvConfig(profileDir, result);
429
+ console.log(
430
+ `${editing ? 'Updated' : 'Created'} endpoint config for profile '${name}' → ${hostOf(result.base_url)}.`,
431
+ );
432
+ return { code: 0, written: true };
433
+ }
434
+
435
+ module.exports = {
436
+ ENV_FILE,
437
+ FIELDS,
438
+ TIER_FIELDS,
439
+ envFilePath,
440
+ hasEnvConfig,
441
+ readEnvConfig,
442
+ hasOAuthState,
443
+ secureFile,
444
+ writeEnvConfig,
445
+ buildEnvVars,
446
+ hostOf,
447
+ endpointSummary,
448
+ maskToken,
449
+ validateBaseUrl,
450
+ validateToken,
451
+ validateField,
452
+ runWizard,
453
+ runConfigure,
454
+ defaultWizardIo,
455
+ };
package/lib/migrate.js CHANGED
@@ -14,7 +14,11 @@ const SUBDIRS = ['projects', 'skills', 'agents', 'commands'];
14
14
  // Not copied to shared: these carry per-profile config / auth tokens.
15
15
  const SINGLE_FILES = ['mcp.json', 'settings.json', 'CLAUDE.md'];
16
16
 
17
- const EXCLUDED_FILES = new Set(['.credentials.json', '.claude.json']);
17
+ // .cc-env.json carries a third-party API key — same security category as
18
+ // .credentials.json. It must NEVER be copied to a shared pool or another
19
+ // profile, so it is excluded explicitly here (it also lives at the profile
20
+ // root, outside SUBDIRS, but this guards against any future copy path).
21
+ const EXCLUDED_FILES = new Set(['.credentials.json', '.claude.json', '.cc-env.json']);
18
22
 
19
23
  function defaultClaudeDir() {
20
24
  return path.join(os.homedir(), '.claude');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cchost",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "description": "Host multiple isolated Claude Code accounts on one machine via the `cc` command.",
5
5
  "keywords": [
6
6
  "claude",
@@ -32,6 +32,7 @@
32
32
  "bin",
33
33
  "lib",
34
34
  "README.md",
35
+ "CHANGELOG.md",
35
36
  "LICENSE"
36
37
  ],
37
38
  "main": "lib/cli.js",