cchost 0.2.1 → 0.3.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/CHANGELOG.md +77 -0
- package/README.md +45 -5
- package/lib/cli.js +118 -4
- package/lib/env.js +480 -0
- package/lib/migrate.js +5 -1
- package/package.json +2 -1
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
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.0] - 2026-05-19
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **Per-profile custom API endpoints (`cc env`).** A profile can now route
|
|
13
|
+
through any Anthropic-compatible third-party provider (Moonshot/Kimi,
|
|
14
|
+
OpenRouter, Requesty, self-hosted vLLM, corporate proxies) instead of
|
|
15
|
+
Anthropic's subscription OAuth.
|
|
16
|
+
- `cc env <profile>` — interactive wizard; the token is never echoed.
|
|
17
|
+
- `cc env <profile> --base-url=URL --token=TOKEN [--model/--opus/--sonnet/--haiku/--subagent=NAME]`
|
|
18
|
+
— non-interactive create, or partial update on an existing endpoint profile.
|
|
19
|
+
- `cc env <profile> show [--reveal]` — print the config; the token is masked
|
|
20
|
+
by default, `--reveal` prints it in full.
|
|
21
|
+
- `cc use` injects the corresponding `ANTHROPIC_*` / `CLAUDE_CODE_*` env vars
|
|
22
|
+
from `.cc-env.json` and prints a one-line endpoint + billing banner to stderr.
|
|
23
|
+
- `cc list` gained an `Endpoint` column (the endpoint host, or `subscription`).
|
|
24
|
+
- README section "Custom endpoints (Kimi, OpenRouter, etc.)" with a billing
|
|
25
|
+
note and a worked Moonshot example.
|
|
26
|
+
|
|
27
|
+
### Security
|
|
28
|
+
|
|
29
|
+
- `.cc-env.json` holds a third-party API key. It is treated like
|
|
30
|
+
`.credentials.json`: written with user-only file permissions, and never
|
|
31
|
+
copied or linked by `cc migrate` / `cc link`.
|
|
32
|
+
- A profile is born OAuth or endpoint and stays that way — `cc env` refuses to
|
|
33
|
+
run on a profile that already has Anthropic OAuth credentials.
|
|
34
|
+
|
|
35
|
+
### Notes
|
|
36
|
+
|
|
37
|
+
- Endpoint profiles bill through the third-party provider, not your Anthropic
|
|
38
|
+
subscription.
|
|
39
|
+
|
|
40
|
+
## [0.2.1] - 2026-05-18
|
|
41
|
+
|
|
42
|
+
### Added
|
|
43
|
+
|
|
44
|
+
- `cc link` / `cc unlink` now operate on `projects/`, `skills/`, `agents/`,
|
|
45
|
+
and `commands/` (previously `projects/` only), with per-directory
|
|
46
|
+
partial-failure handling.
|
|
47
|
+
- `cc migrate` extended: `default → <profile>` also copies `mcp.json`,
|
|
48
|
+
`settings.json`, and `CLAUDE.md`; `--force` overwrites instead of
|
|
49
|
+
skip-on-collision.
|
|
50
|
+
- `cc list` reports shared state as `N/4`.
|
|
51
|
+
- README: migrate-vs-link artifact table.
|
|
52
|
+
|
|
53
|
+
## [0.2.0] - 2026-05-16
|
|
54
|
+
|
|
55
|
+
### Added
|
|
56
|
+
|
|
57
|
+
- `cc --version` / `cc -v` — prints the installed version.
|
|
58
|
+
- `cc doctor [--fix [--force]] [--json]` — environment, storage, and platform
|
|
59
|
+
diagnostics with safe and confirmation-gated fixes.
|
|
60
|
+
|
|
61
|
+
### Removed
|
|
62
|
+
|
|
63
|
+
- The `cc add` alias-masking feature — Claude Code re-syncs account fields from
|
|
64
|
+
the server on every launch, so masking was not worth shipping.
|
|
65
|
+
|
|
66
|
+
## [0.1.2] - 2026-05-15
|
|
67
|
+
|
|
68
|
+
### Changed
|
|
69
|
+
|
|
70
|
+
- `sessions/` dropped from `share`, `migrate`, and `cc list` — it is transient
|
|
71
|
+
per-process state and must stay per-profile.
|
|
72
|
+
- Cross-platform claim narrowed to the verified scope (Windows and Linux).
|
|
73
|
+
|
|
74
|
+
[0.3.0]: https://github.com/allenhack638/cchost/releases/tag/v0.3.0
|
|
75
|
+
[0.2.1]: https://github.com/allenhack638/cchost/releases/tag/v0.2.1
|
|
76
|
+
[0.2.0]: https://github.com/allenhack638/cchost/releases/tag/v0.2.0
|
|
77
|
+
[0.1.2]: https://github.com/allenhack638/cchost/releases/tag/v0.1.2
|
package/README.md
CHANGED
|
@@ -18,7 +18,9 @@ Requires Node.js 18+ and Claude Code 2.1.140+ already installed. Get Claude Code
|
|
|
18
18
|
| --- | --- |
|
|
19
19
|
| `cc add <profile>` | Create a profile directory. |
|
|
20
20
|
| `cc use <profile> [...args]` | Launch Claude under that profile. Profile must already exist. Args after the name are forwarded verbatim to `claude`. |
|
|
21
|
-
| `cc list [--json]` | Show every profile: login state, account email,
|
|
21
|
+
| `cc list [--json]` | Show every profile: login state, account email, shared dirs (N/4), and endpoint. |
|
|
22
|
+
| `cc env <profile> [...]` | Configure a custom API endpoint for a profile (interactive wizard or flags). See [Custom endpoints](#custom-endpoints-kimi-openrouter-etc). |
|
|
23
|
+
| `cc env <profile> show [--reveal]` | Print a profile's endpoint config. Token masked by default; `--reveal` prints it in full. |
|
|
22
24
|
| `cc migrate <src> <dest> [--force]` | Copy artifacts from `default`\|`shared` to `shared`\|`<profile>`. Skip-on-collision by default; `--force` overwrites. |
|
|
23
25
|
| `cc link <profile> [<profile>...]` | Link a profile's artifact dirs to `~/.claude-shared/`. Migrates existing content; renames on collision. |
|
|
24
26
|
| `cc unlink <profile>` | Restore a profile to its own private artifact dirs (copies shared content back). |
|
|
@@ -40,10 +42,11 @@ cc remove old-profile
|
|
|
40
42
|
`cc list` output:
|
|
41
43
|
|
|
42
44
|
```
|
|
43
|
-
Profile LoggedIn Email Shared
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
Profile LoggedIn Email Shared Endpoint
|
|
46
|
+
kimi false - 0/4 api.moonshot.ai
|
|
47
|
+
personal true personal@example.com 4/4 subscription
|
|
48
|
+
scratch false - 0/4 subscription
|
|
49
|
+
work true work@example.com 0/4 subscription
|
|
47
50
|
```
|
|
48
51
|
|
|
49
52
|
### What migrate copies and what link shares
|
|
@@ -61,6 +64,7 @@ work true work@example.com 0/4
|
|
|
61
64
|
| `plugins/` | — | — | Carries auth tokens; deferred to a future opt-in flag |
|
|
62
65
|
| `.credentials.json` | — | — | Never touched — each profile keeps its own auth |
|
|
63
66
|
| `.claude.json` | — | — | Never touched — re-synced from server on every launch |
|
|
67
|
+
| `.cc-env.json` | — | — | Per-profile custom-endpoint config; holds an API key. Never migrated, never linked. |
|
|
64
68
|
|
|
65
69
|
`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
70
|
|
|
@@ -98,6 +102,40 @@ If two profiles share history and you launch them against the same project at th
|
|
|
98
102
|
|
|
99
103
|
`cc remove` is safe with shared profiles — it deletes only the links, never recursing into the shared target.
|
|
100
104
|
|
|
105
|
+
## Custom endpoints (Kimi, OpenRouter, etc.)
|
|
106
|
+
|
|
107
|
+
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. This is configured per profile with `cc env`.
|
|
108
|
+
|
|
109
|
+
> **Billing:** an 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.
|
|
110
|
+
|
|
111
|
+
An endpoint profile and a subscription profile are mutually exclusive: a profile is born one or the other and stays that way. `cc env` refuses to run on a profile that already has Anthropic OAuth credentials — create endpoint profiles fresh.
|
|
112
|
+
|
|
113
|
+
### Worked example — Moonshot / Kimi
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
cc add kimi
|
|
117
|
+
cc env kimi --base-url=https://api.moonshot.ai/anthropic \
|
|
118
|
+
--token=sk-your-moonshot-key \
|
|
119
|
+
--model=kimi-k2.5
|
|
120
|
+
cc use kimi
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
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=`.
|
|
124
|
+
|
|
125
|
+
Run `cc env <profile>` with no flags for an interactive wizard instead — it prompts for every field and never echoes the token. On an existing endpoint profile the wizard runs in edit mode with current values prefilled; non-interactively, only the flags you pass are changed.
|
|
126
|
+
|
|
127
|
+
Inspect a config with `cc env <profile> show` (token masked). `cc env <profile> show --reveal` prints the full API key — do not paste that output anywhere.
|
|
128
|
+
|
|
129
|
+
There is no `cc env clear`. To stop using an endpoint, `cc remove` the profile and recreate it.
|
|
130
|
+
|
|
131
|
+
When you launch an endpoint profile, `cc use` prints a one-line reminder to stderr:
|
|
132
|
+
|
|
133
|
+
```
|
|
134
|
+
[cchost] Profile 'kimi' → api.moonshot.ai (custom endpoint billing applies)
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
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).
|
|
138
|
+
|
|
101
139
|
## How it works
|
|
102
140
|
|
|
103
141
|
`cc` is a Node CLI with **zero runtime dependencies**. For `cc use` it:
|
|
@@ -113,6 +151,8 @@ The env var is set on the child process only — that's all `claude` needs.
|
|
|
113
151
|
|
|
114
152
|
`~/.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
153
|
|
|
154
|
+
`.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 env show` masks the token; only `cc env show --reveal` prints it in full.
|
|
155
|
+
|
|
116
156
|
## Troubleshooting
|
|
117
157
|
|
|
118
158
|
**`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');
|
|
@@ -16,7 +17,8 @@ const HELP = `cc - Multi-profile launcher for Claude Code
|
|
|
16
17
|
Usage:
|
|
17
18
|
cc add <profile> Create a profile directory
|
|
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 [--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)
|
|
20
22
|
cc migrate <src> <dest> [--force] Copy artifact dirs (and config files) between profiles
|
|
21
23
|
cc link <p>... Link each profile's artifact dirs to ~/.claude-shared/
|
|
22
24
|
cc unlink <profile> Restore a profile's private artifact dirs
|
|
@@ -61,8 +63,48 @@ Columns:
|
|
|
61
63
|
Shared - N/4 linked to ~/.claude-shared/ (projects, skills, agents, commands)
|
|
62
64
|
4/4 = fully shared, 0/4 = fully isolated
|
|
63
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
|
|
64
68
|
|
|
65
|
-
With --json, emits an array of {name, dir, loggedIn, email, shared}.
|
|
69
|
+
With --json, emits an array of {name, dir, loggedIn, email, shared, endpoint}.
|
|
70
|
+
`,
|
|
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.
|
|
76
|
+
|
|
77
|
+
Billing: an endpoint profile bills through the third-party provider, NOT your
|
|
78
|
+
Anthropic subscription.
|
|
79
|
+
|
|
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.
|
|
85
|
+
|
|
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.
|
|
91
|
+
|
|
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.
|
|
95
|
+
|
|
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.
|
|
102
|
+
|
|
103
|
+
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
|
|
66
108
|
`,
|
|
67
109
|
migrate: `cc migrate <src> <dest> [--force]
|
|
68
110
|
|
|
@@ -251,7 +293,19 @@ async function cmdUse(rest) {
|
|
|
251
293
|
err.exitCode = 1;
|
|
252
294
|
throw err;
|
|
253
295
|
}
|
|
254
|
-
|
|
296
|
+
|
|
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
|
+
let extraEnv = {};
|
|
300
|
+
const envConfig = envLib.readEnvConfig(dir); // throws on a malformed file
|
|
301
|
+
if (envConfig) {
|
|
302
|
+
extraEnv = envLib.buildEnvVars(envConfig);
|
|
303
|
+
const host = envConfig.base_url ? envLib.hostOf(envConfig.base_url) : '(unknown)';
|
|
304
|
+
process.stderr.write(
|
|
305
|
+
`[cchost] Profile '${name}' → ${host} (custom endpoint billing applies)\n`,
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
return spawnClaude({ args: passthrough, profileDir: dir, extraEnv });
|
|
255
309
|
}
|
|
256
310
|
|
|
257
311
|
function cmdList(rest) {
|
|
@@ -269,6 +323,7 @@ function cmdList(rest) {
|
|
|
269
323
|
loggedIn: p.loggedIn,
|
|
270
324
|
email: p.email,
|
|
271
325
|
shared: sharedCount,
|
|
326
|
+
endpoint: envLib.endpointSummary(p.dir),
|
|
272
327
|
};
|
|
273
328
|
});
|
|
274
329
|
|
|
@@ -283,13 +338,14 @@ function cmdList(rest) {
|
|
|
283
338
|
return 0;
|
|
284
339
|
}
|
|
285
340
|
|
|
286
|
-
const rows = [['Profile', 'LoggedIn', 'Email', 'Shared']];
|
|
341
|
+
const rows = [['Profile', 'LoggedIn', 'Email', 'Shared', 'Endpoint']];
|
|
287
342
|
for (const p of enriched) {
|
|
288
343
|
rows.push([
|
|
289
344
|
p.name,
|
|
290
345
|
p.loggedIn ? 'true' : 'false',
|
|
291
346
|
p.email ? p.email : '-',
|
|
292
347
|
`${p.shared}/${total}`,
|
|
348
|
+
p.endpoint,
|
|
293
349
|
]);
|
|
294
350
|
}
|
|
295
351
|
console.log(renderTable(rows));
|
|
@@ -417,6 +473,62 @@ async function cmdMigrate(rest) {
|
|
|
417
473
|
return 0;
|
|
418
474
|
}
|
|
419
475
|
|
|
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);
|
|
530
|
+
}
|
|
531
|
+
|
|
420
532
|
async function run(argv) {
|
|
421
533
|
const [action, ...rest] = argv;
|
|
422
534
|
if (!action || action === '--help' || action === '-h') {
|
|
@@ -439,6 +551,8 @@ async function run(argv) {
|
|
|
439
551
|
return await cmdUse(rest);
|
|
440
552
|
case 'list':
|
|
441
553
|
return cmdList(rest);
|
|
554
|
+
case 'env':
|
|
555
|
+
return await cmdEnv(rest);
|
|
442
556
|
case 'migrate':
|
|
443
557
|
return await cmdMigrate(rest);
|
|
444
558
|
case 'link':
|
package/lib/env.js
ADDED
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Per-profile custom API endpoints (v0.3).
|
|
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
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const readline = require('readline');
|
|
14
|
+
const { spawnSync } = require('child_process');
|
|
15
|
+
|
|
16
|
+
const ENV_FILE = '.cc-env.json';
|
|
17
|
+
|
|
18
|
+
// Persisted fields, in canonical serialization order.
|
|
19
|
+
const FIELDS = ['base_url', 'auth_token', 'model', 'opus', 'sonnet', 'haiku', 'subagent'];
|
|
20
|
+
const TIER_FIELDS = ['opus', 'sonnet', 'haiku', 'subagent'];
|
|
21
|
+
|
|
22
|
+
// File field -> env var(s) injected into the spawned child.
|
|
23
|
+
const TIER_ENV = {
|
|
24
|
+
opus: 'ANTHROPIC_DEFAULT_OPUS_MODEL',
|
|
25
|
+
sonnet: 'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
|
26
|
+
haiku: 'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
|
27
|
+
subagent: 'CLAUDE_CODE_SUBAGENT_MODEL',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// Wizard layout: the index here is the review-screen number (1-based).
|
|
31
|
+
const WIZARD_FIELDS = [
|
|
32
|
+
{ key: 'base_url', label: 'Base URL', hidden: false, required: true },
|
|
33
|
+
{ key: 'auth_token', label: 'Auth token', hidden: true, required: true },
|
|
34
|
+
{ key: 'model', label: 'Main model', hidden: false, required: false },
|
|
35
|
+
{ key: 'opus', label: 'Opus tier', hidden: false, required: false, tier: true },
|
|
36
|
+
{ key: 'sonnet', label: 'Sonnet tier', hidden: false, required: false, tier: true },
|
|
37
|
+
{ key: 'haiku', label: 'Haiku tier', hidden: false, required: false, tier: true },
|
|
38
|
+
{ key: 'subagent', label: 'Subagent model', hidden: false, required: false, tier: true },
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
function usageError(msg) {
|
|
42
|
+
const e = new Error(msg);
|
|
43
|
+
e.exitCode = 2;
|
|
44
|
+
return e;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function stateError(msg) {
|
|
48
|
+
const e = new Error(msg);
|
|
49
|
+
e.exitCode = 1;
|
|
50
|
+
return e;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function envFilePath(profileDir) {
|
|
54
|
+
return path.join(profileDir, ENV_FILE);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function hasEnvConfig(profileDir) {
|
|
58
|
+
return fs.existsSync(envFilePath(profileDir));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Reads and parses `.cc-env.json`. Returns the parsed object, or null if the
|
|
62
|
+
// file does not exist. Throws (exitCode 1, `.malformed` set) if the file is
|
|
63
|
+
// present but is not a JSON object.
|
|
64
|
+
function readEnvConfig(profileDir) {
|
|
65
|
+
const p = envFilePath(profileDir);
|
|
66
|
+
if (!fs.existsSync(p)) return null;
|
|
67
|
+
let data;
|
|
68
|
+
try {
|
|
69
|
+
data = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
70
|
+
} catch (err) {
|
|
71
|
+
const e = stateError(`${ENV_FILE} is malformed JSON: ${err.message}`);
|
|
72
|
+
e.malformed = true;
|
|
73
|
+
throw e;
|
|
74
|
+
}
|
|
75
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
76
|
+
const e = stateError(`${ENV_FILE} is malformed: expected a JSON object.`);
|
|
77
|
+
e.malformed = true;
|
|
78
|
+
throw e;
|
|
79
|
+
}
|
|
80
|
+
return data;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// True if the profile shows any prior OAuth (subscription) state — either a
|
|
84
|
+
// `.credentials.json` file, or a `.claude.json` with a non-null `oauthAccount`.
|
|
85
|
+
// Such a profile can never be converted to an endpoint profile (Rule 1).
|
|
86
|
+
function hasOAuthState(profileDir) {
|
|
87
|
+
if (fs.existsSync(path.join(profileDir, '.credentials.json'))) return true;
|
|
88
|
+
try {
|
|
89
|
+
const raw = fs.readFileSync(path.join(profileDir, '.claude.json'), 'utf8');
|
|
90
|
+
if (raw.trim()) {
|
|
91
|
+
const data = JSON.parse(raw);
|
|
92
|
+
if (data && data.oauthAccount != null) return true;
|
|
93
|
+
}
|
|
94
|
+
} catch {
|
|
95
|
+
// Missing or unreadable .claude.json is not OAuth state.
|
|
96
|
+
}
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Locks a file down to the current user only. Mirrors how Claude Code itself
|
|
101
|
+
// protects `.credentials.json`.
|
|
102
|
+
function secureFile(filePath) {
|
|
103
|
+
if (process.platform === 'win32') {
|
|
104
|
+
const user = process.env.USERNAME;
|
|
105
|
+
if (!user) return;
|
|
106
|
+
// Qualify the principal as DOMAIN\user. A bare username that collides with
|
|
107
|
+
// the computer name (common on workgroup machines) resolves to a broken
|
|
108
|
+
// principal, which would lock the file's own owner out — verified.
|
|
109
|
+
const domain = process.env.USERDOMAIN;
|
|
110
|
+
const principal = domain ? `${domain}\\${user}` : user;
|
|
111
|
+
spawnSync('icacls', [filePath, '/inheritance:r', '/grant:r', `${principal}:F`], {
|
|
112
|
+
stdio: 'ignore',
|
|
113
|
+
});
|
|
114
|
+
} else {
|
|
115
|
+
fs.chmodSync(filePath, 0o600);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Writes `.cc-env.json` atomically (temp file + rename) so a cancel or crash
|
|
120
|
+
// never leaves a partial file. Only defined, non-empty fields are persisted.
|
|
121
|
+
function writeEnvConfig(profileDir, config) {
|
|
122
|
+
const out = {};
|
|
123
|
+
for (const f of FIELDS) {
|
|
124
|
+
if (config[f] != null && config[f] !== '') out[f] = config[f];
|
|
125
|
+
}
|
|
126
|
+
const target = envFilePath(profileDir);
|
|
127
|
+
const tmp = path.join(profileDir, `${ENV_FILE}.tmp-${process.pid}`);
|
|
128
|
+
// Write to a temp file then rename, so a crash never leaves a partial file.
|
|
129
|
+
// Permissions are locked down after the rename: restricting the temp file's
|
|
130
|
+
// ACL first makes the subsequent rename fail with EPERM on Windows.
|
|
131
|
+
fs.writeFileSync(tmp, JSON.stringify(out, null, 2) + '\n');
|
|
132
|
+
try {
|
|
133
|
+
fs.renameSync(tmp, target);
|
|
134
|
+
} catch (err) {
|
|
135
|
+
try {
|
|
136
|
+
fs.unlinkSync(tmp);
|
|
137
|
+
} catch {
|
|
138
|
+
// best effort cleanup
|
|
139
|
+
}
|
|
140
|
+
throw err;
|
|
141
|
+
}
|
|
142
|
+
secureFile(target);
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Maps a config object to the ANTHROPIC_*/CLAUDE_CODE_* env vars injected into
|
|
147
|
+
// the spawned child. Per-tier model vars fall back to `model`; if `model` is
|
|
148
|
+
// also missing, the var is omitted so Claude Code uses its built-in defaults.
|
|
149
|
+
function buildEnvVars(config) {
|
|
150
|
+
const env = {};
|
|
151
|
+
if (config.base_url) env.ANTHROPIC_BASE_URL = config.base_url;
|
|
152
|
+
if (config.auth_token) {
|
|
153
|
+
// Different providers read different vars — set both.
|
|
154
|
+
env.ANTHROPIC_AUTH_TOKEN = config.auth_token;
|
|
155
|
+
env.ANTHROPIC_API_KEY = config.auth_token;
|
|
156
|
+
}
|
|
157
|
+
if (config.model) env.ANTHROPIC_MODEL = config.model;
|
|
158
|
+
for (const tier of TIER_FIELDS) {
|
|
159
|
+
const val = config[tier] || config.model;
|
|
160
|
+
if (val) env[TIER_ENV[tier]] = val;
|
|
161
|
+
}
|
|
162
|
+
return env;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function hostOf(url) {
|
|
166
|
+
try {
|
|
167
|
+
return new URL(url).host;
|
|
168
|
+
} catch {
|
|
169
|
+
return url;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Endpoint summary for `cc list`. Returns the base-url host, 'subscription'
|
|
174
|
+
// (no endpoint config), or '(invalid config)' for a malformed file — never
|
|
175
|
+
// throws, so a single bad profile cannot break the whole table.
|
|
176
|
+
function endpointSummary(profileDir) {
|
|
177
|
+
let config;
|
|
178
|
+
try {
|
|
179
|
+
config = readEnvConfig(profileDir);
|
|
180
|
+
} catch {
|
|
181
|
+
return '(invalid config)';
|
|
182
|
+
}
|
|
183
|
+
if (!config) return 'subscription';
|
|
184
|
+
if (!config.base_url) return '(invalid config)';
|
|
185
|
+
return hostOf(config.base_url);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function maskToken(token) {
|
|
189
|
+
const n = token.length;
|
|
190
|
+
if (n <= 10) return `${'*'.repeat(n)} (${n} chars)`;
|
|
191
|
+
return `${token.slice(0, 6)}...${token.slice(-4)} (${n} chars)`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function validateBaseUrl(url, warn) {
|
|
195
|
+
if (!/^https?:\/\/.+/.test(url)) {
|
|
196
|
+
throw usageError(`Invalid base_url "${url}": must start with http:// or https://`);
|
|
197
|
+
}
|
|
198
|
+
let u = null;
|
|
199
|
+
try {
|
|
200
|
+
u = new URL(url);
|
|
201
|
+
} catch {
|
|
202
|
+
throw usageError(`Invalid base_url "${url}": not a parseable URL.`);
|
|
203
|
+
}
|
|
204
|
+
if (u.pathname.replace(/\/+$/, '').endsWith('/v1')) {
|
|
205
|
+
warn('cchost: base_url ending in /v1 is unusual; most Anthropic-compatible endpoints end in / or /anthropic');
|
|
206
|
+
}
|
|
207
|
+
if (u.protocol === 'http:' && u.hostname !== 'localhost' && u.hostname !== '127.0.0.1') {
|
|
208
|
+
warn('cchost: base_url uses http:// to a non-local host; the auth token will be sent unencrypted');
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function validateToken(token) {
|
|
213
|
+
if (typeof token !== 'string' || token.length === 0) {
|
|
214
|
+
throw usageError('Auth token must not be empty.');
|
|
215
|
+
}
|
|
216
|
+
if (token !== token.trim()) {
|
|
217
|
+
throw usageError('Auth token must not have leading or trailing whitespace.');
|
|
218
|
+
}
|
|
219
|
+
if (/[\r\n]/.test(token)) {
|
|
220
|
+
throw usageError('Auth token must not contain newlines.');
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function validateModelField(label, val) {
|
|
225
|
+
if (typeof val !== 'string' || val.length === 0) {
|
|
226
|
+
throw usageError(`${label} must not be empty if provided.`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Validates one provided field by key. base_url warnings go through `warn`.
|
|
231
|
+
function validateField(key, value, warn) {
|
|
232
|
+
if (key === 'base_url') validateBaseUrl(value, warn);
|
|
233
|
+
else if (key === 'auth_token') validateToken(value);
|
|
234
|
+
else validateModelField(key, value);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
// Wizard
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
// Default terminal IO for the wizard. `readHidden` writes the prompt, then
|
|
242
|
+
// suppresses echo of typed characters so the token never appears on screen.
|
|
243
|
+
function defaultWizardIo() {
|
|
244
|
+
return {
|
|
245
|
+
write: (s) => process.stdout.write(s),
|
|
246
|
+
readLine: (q) =>
|
|
247
|
+
new Promise((resolve) => {
|
|
248
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
249
|
+
rl.question(q, (a) => {
|
|
250
|
+
rl.close();
|
|
251
|
+
resolve(a);
|
|
252
|
+
});
|
|
253
|
+
}),
|
|
254
|
+
readHidden: (q) =>
|
|
255
|
+
new Promise((resolve) => {
|
|
256
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
257
|
+
let muted = false;
|
|
258
|
+
rl._writeToOutput = (s) => {
|
|
259
|
+
if (!muted) rl.output.write(s);
|
|
260
|
+
};
|
|
261
|
+
rl.question(q, (a) => {
|
|
262
|
+
rl.close();
|
|
263
|
+
process.stdout.write('\n');
|
|
264
|
+
resolve(a);
|
|
265
|
+
});
|
|
266
|
+
// The prompt was written above (still unmuted); from here, suppress
|
|
267
|
+
// the echo of whatever the user types.
|
|
268
|
+
muted = true;
|
|
269
|
+
}),
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function reviewValue(field, config) {
|
|
274
|
+
if (field.key === 'auth_token') {
|
|
275
|
+
return config.auth_token ? maskToken(config.auth_token) : '-';
|
|
276
|
+
}
|
|
277
|
+
if (field.tier) {
|
|
278
|
+
return config[field.key] || config.model || '-';
|
|
279
|
+
}
|
|
280
|
+
return config[field.key] || '-';
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function renderReview(config, io) {
|
|
284
|
+
io.write('\nReview:\n');
|
|
285
|
+
WIZARD_FIELDS.forEach((field, i) => {
|
|
286
|
+
const label = `${field.label}:`.padEnd(16);
|
|
287
|
+
io.write(` ${i + 1}. ${label}${reviewValue(field, config)}\n`);
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Prompts for one field, looping until the input is valid. An empty input
|
|
292
|
+
// keeps the current value; a required field with no current value re-prompts.
|
|
293
|
+
async function promptOne(field, config, io, isEdit) {
|
|
294
|
+
for (;;) {
|
|
295
|
+
let placeholder = '';
|
|
296
|
+
let promptText;
|
|
297
|
+
if (field.hidden) {
|
|
298
|
+
promptText = `${field.label}${isEdit && config[field.key] ? ' [keep current]' : ''}: `;
|
|
299
|
+
} else {
|
|
300
|
+
if (field.tier) placeholder = config[field.key] || config.model || '';
|
|
301
|
+
else placeholder = config[field.key] || '';
|
|
302
|
+
promptText = `${field.label} [${placeholder}]: `;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const raw = field.hidden
|
|
306
|
+
? await io.readHidden(promptText)
|
|
307
|
+
: await io.readLine(promptText);
|
|
308
|
+
const value = raw.trim();
|
|
309
|
+
|
|
310
|
+
if (value === '') {
|
|
311
|
+
// Keep the current value. base_url/auth_token must end up set.
|
|
312
|
+
if (field.required && !config[field.key]) {
|
|
313
|
+
io.write(`${field.label} is required.\n`);
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
try {
|
|
320
|
+
validateField(field.key, value, (m) => io.write(m + '\n'));
|
|
321
|
+
} catch (err) {
|
|
322
|
+
io.write(err.message + '\n');
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
config[field.key] = value;
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Runs the interactive wizard. Returns the collected config on submit, or
|
|
331
|
+
// null if the user cancelled. Never writes the file itself.
|
|
332
|
+
async function runWizard(name, existing, io) {
|
|
333
|
+
const isEdit = !!existing;
|
|
334
|
+
const config = {};
|
|
335
|
+
if (existing) {
|
|
336
|
+
for (const f of FIELDS) {
|
|
337
|
+
if (existing[f] != null) config[f] = existing[f];
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
io.write(`\ncchost endpoint configuration for profile '${name}'\n\n`);
|
|
342
|
+
for (const field of WIZARD_FIELDS) {
|
|
343
|
+
await promptOne(field, config, io, isEdit);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
for (;;) {
|
|
347
|
+
renderReview(config, io);
|
|
348
|
+
const ans = (await io.readLine('\n[s]ubmit, [e]dit N, [c]ancel: ')).trim().toLowerCase();
|
|
349
|
+
if (ans === 's') return config;
|
|
350
|
+
if (ans === 'c') return null;
|
|
351
|
+
const m = /^e\s+([1-7])$/.exec(ans);
|
|
352
|
+
if (m) {
|
|
353
|
+
await promptOne(WIZARD_FIELDS[Number(m[1]) - 1], config, io, isEdit);
|
|
354
|
+
}
|
|
355
|
+
// Anything else: silently re-display the review.
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ---------------------------------------------------------------------------
|
|
360
|
+
// Command orchestration (called from cli.js)
|
|
361
|
+
// ---------------------------------------------------------------------------
|
|
362
|
+
|
|
363
|
+
// `cc env <profile>` and `cc env <profile> --flags`.
|
|
364
|
+
// `provided` is a partial config of fields supplied via flags.
|
|
365
|
+
// `opts.io` injects wizard IO (tests); `opts.isTTY` overrides TTY detection.
|
|
366
|
+
async function runConfigure(name, profileDir, provided, opts = {}) {
|
|
367
|
+
if (!fs.existsSync(profileDir)) {
|
|
368
|
+
throw stateError(`Profile "${name}" does not exist. Create it with: cc add ${name}`);
|
|
369
|
+
}
|
|
370
|
+
if (hasOAuthState(profileDir)) {
|
|
371
|
+
throw stateError(
|
|
372
|
+
`Profile '${name}' has OAuth credentials.\n` +
|
|
373
|
+
'Endpoint profiles must be created on a fresh profile.\n' +
|
|
374
|
+
'Run:\n' +
|
|
375
|
+
' cc add <new-name>\n' +
|
|
376
|
+
' cc env <new-name>',
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const existing = readEnvConfig(profileDir); // throws on malformed file
|
|
381
|
+
const editing = existing != null;
|
|
382
|
+
const nonInteractive = Object.keys(provided).length > 0;
|
|
383
|
+
|
|
384
|
+
if (nonInteractive) {
|
|
385
|
+
const warn = (m) => process.stderr.write(m + '\n');
|
|
386
|
+
// Creating: base_url and auth_token are mandatory.
|
|
387
|
+
if (!editing) {
|
|
388
|
+
const merged = { ...provided };
|
|
389
|
+
if (!merged.base_url || !merged.auth_token) {
|
|
390
|
+
throw usageError(
|
|
391
|
+
'Creating an endpoint profile requires both --base-url and --token.',
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
for (const [key, value] of Object.entries(provided)) {
|
|
396
|
+
validateField(key, value, warn);
|
|
397
|
+
}
|
|
398
|
+
const merged = { ...(existing || {}), ...provided };
|
|
399
|
+
writeEnvConfig(profileDir, merged);
|
|
400
|
+
console.log(
|
|
401
|
+
`${editing ? 'Updated' : 'Created'} endpoint config for profile '${name}' → ${hostOf(merged.base_url)}.`,
|
|
402
|
+
);
|
|
403
|
+
return 0;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Interactive wizard.
|
|
407
|
+
let io = opts.io;
|
|
408
|
+
if (!io) {
|
|
409
|
+
const isTTY = opts.isTTY != null ? opts.isTTY : process.stdin.isTTY;
|
|
410
|
+
if (!isTTY) {
|
|
411
|
+
throw usageError('cc env requires either flags or an interactive terminal.');
|
|
412
|
+
}
|
|
413
|
+
io = defaultWizardIo();
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const result = await runWizard(name, existing, io);
|
|
417
|
+
if (result == null) {
|
|
418
|
+
console.log('Cancelled. No changes written.');
|
|
419
|
+
return 0;
|
|
420
|
+
}
|
|
421
|
+
writeEnvConfig(profileDir, result);
|
|
422
|
+
console.log(
|
|
423
|
+
`${editing ? 'Updated' : 'Created'} endpoint config for profile '${name}' → ${hostOf(result.base_url)}.`,
|
|
424
|
+
);
|
|
425
|
+
return 0;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// `cc env <profile> show [--reveal]`.
|
|
429
|
+
function runShow(name, profileDir, { reveal = false, write } = {}) {
|
|
430
|
+
const w = write || ((s) => process.stdout.write(s + '\n'));
|
|
431
|
+
if (!fs.existsSync(profileDir)) {
|
|
432
|
+
throw stateError(`Profile "${name}" does not exist.`);
|
|
433
|
+
}
|
|
434
|
+
const config = readEnvConfig(profileDir); // throws on malformed file
|
|
435
|
+
if (config == null) {
|
|
436
|
+
throw stateError(`Profile '${name}' is not an endpoint profile (no ${ENV_FILE}).`);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
w(`Endpoint configuration for profile '${name}':`);
|
|
440
|
+
w(` Base URL: ${config.base_url || '-'}`);
|
|
441
|
+
if (reveal) {
|
|
442
|
+
w(` Token: ${config.auth_token || '-'}`);
|
|
443
|
+
} else {
|
|
444
|
+
w(` Token: ${config.auth_token ? maskToken(config.auth_token) : '-'}`);
|
|
445
|
+
}
|
|
446
|
+
w(` Main model: ${config.model || '-'}`);
|
|
447
|
+
w(` Opus: ${config.opus || config.model || '-'}`);
|
|
448
|
+
w(` Sonnet: ${config.sonnet || config.model || '-'}`);
|
|
449
|
+
w(` Haiku: ${config.haiku || config.model || '-'}`);
|
|
450
|
+
w(` Subagent: ${config.subagent || config.model || '-'}`);
|
|
451
|
+
if (reveal) {
|
|
452
|
+
w('');
|
|
453
|
+
w('WARNING: the full API key is printed above. Do not paste this output');
|
|
454
|
+
w('into chats, issues, screenshots, or logs.');
|
|
455
|
+
}
|
|
456
|
+
return 0;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
module.exports = {
|
|
460
|
+
ENV_FILE,
|
|
461
|
+
FIELDS,
|
|
462
|
+
TIER_FIELDS,
|
|
463
|
+
envFilePath,
|
|
464
|
+
hasEnvConfig,
|
|
465
|
+
readEnvConfig,
|
|
466
|
+
hasOAuthState,
|
|
467
|
+
secureFile,
|
|
468
|
+
writeEnvConfig,
|
|
469
|
+
buildEnvVars,
|
|
470
|
+
hostOf,
|
|
471
|
+
endpointSummary,
|
|
472
|
+
maskToken,
|
|
473
|
+
validateBaseUrl,
|
|
474
|
+
validateToken,
|
|
475
|
+
validateField,
|
|
476
|
+
runWizard,
|
|
477
|
+
runConfigure,
|
|
478
|
+
runShow,
|
|
479
|
+
defaultWizardIo,
|
|
480
|
+
};
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.3.0",
|
|
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",
|