cchost 0.2.0 → 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 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,10 +18,12 @@ 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, and storage mode (`shared` / `isolated`). |
22
- | `cc migrate <src> <dest>` | Copy projects from `default`\|`shared` to `shared`\|`<profile>`. Skip-on-collision; credential files never copied. |
23
- | `cc link <profile> [<profile>...]` | Link a profile's `projects/` to `~/.claude-shared/projects`. Migrates existing content; renames on collision. |
24
- | `cc unlink <profile>` | Restore a profile to its own private `projects/` (copies shared content back). |
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. |
24
+ | `cc migrate <src> <dest> [--force]` | Copy artifacts from `default`\|`shared` to `shared`\|`<profile>`. Skip-on-collision by default; `--force` overwrites. |
25
+ | `cc link <profile> [<profile>...]` | Link a profile's artifact dirs to `~/.claude-shared/`. Migrates existing content; renames on collision. |
26
+ | `cc unlink <profile>` | Restore a profile to its own private artifact dirs (copies shared content back). |
25
27
  | `cc remove <profile>` | Delete a profile. Requires typing the profile name to confirm. |
26
28
  | `cc doctor [--fix [--force]] [--json]` | Diagnose environment, storage, and platform issues. |
27
29
  | `cc help [command]` | Print usage (top-level, or for a single command). |
@@ -40,42 +42,99 @@ cc remove old-profile
40
42
  `cc list` output:
41
43
 
42
44
  ```
43
- Profile LoggedIn Email Storage
44
- personal true personal@example.com isolated
45
- scratch false - isolated
46
- work true work@example.com shared
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
- ### Migrating existing projects
52
+ ### What migrate copies and what link shares
50
53
 
51
- If you've been using Claude Code without profiles, your history lives in `~/.claude/projects/`. Pull it into a profile or the shared pool:
54
+ | Artifact | `cc migrate` copies it | `cc link` shares it | Notes |
55
+ | --- | :---: | :---: | --- |
56
+ | `projects/` | ✓ | ✓ | Resumable conversation transcripts |
57
+ | `skills/` | ✓ | ✓ | Custom slash-command scripts |
58
+ | `agents/` | ✓ | ✓ | Custom agent definitions |
59
+ | `commands/` | ✓ | ✓ | Custom command configs |
60
+ | `mcp.json` | ✓ (default→profile only) | — | Per-profile MCP server config; may carry auth tokens |
61
+ | `settings.json` | ✓ (default→profile only) | — | Per-profile UI/feature settings |
62
+ | `CLAUDE.md` | ✓ (default→profile only) | — | Per-profile project instructions |
63
+ | `sessions/` | — | — | Transient per-process state; sharing would corrupt bookkeeping |
64
+ | `plugins/` | — | — | Carries auth tokens; deferred to a future opt-in flag |
65
+ | `.credentials.json` | — | — | Never touched — each profile keeps its own auth |
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. |
68
+
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.
70
+
71
+ ### Migrating existing artifacts
72
+
73
+ If you've been using Claude Code without profiles, your history lives in `~/.claude/`. Pull it into a profile or the shared pool:
52
74
 
53
75
  ```bash
54
- cc migrate default work # copy ~/.claude/projects → ~/.claude-profiles/work/projects
55
- cc migrate default shared # copy ~/.claude/projects → ~/.claude-shared/projects
56
- cc migrate shared personal # copy ~/.claude-shared/projects → ~/.claude-profiles/personal/projects
76
+ cc migrate default work # copy ~/.claude/* → ~/.claude-profiles/work/
77
+ cc migrate default shared # copy ~/.claude/projects,skills,agents,commands → ~/.claude-shared/
78
+ cc migrate shared personal # copy ~/.claude-shared/* → ~/.claude-profiles/personal/
79
+ cc migrate default work --force # same as first, overwriting anything that already exists
57
80
  ```
58
81
 
59
- This is a **copy** — the source is left untouched. If an entry already exists at the destination, the source entry is skipped (no overwrite). Credential files (`.credentials.json`, `.claude.json`) are never copied: each profile keeps its own auth state.
82
+ This is a **copy** — the source is left untouched. By default, if an entry already exists at the destination it is silently skipped. Pass `--force` to overwrite instead — existing destination content will be cleared.
60
83
 
61
- ### Shared history across profiles
84
+ Credential files (`.credentials.json`, `.claude.json`) are never copied: each profile keeps its own auth state.
62
85
 
63
- If you want each profile to keep separate credentials but share project history (e.g. rotate accounts on the same project when usage limits hit):
86
+ ### Shared artifacts across profiles
87
+
88
+ If you want each profile to keep separate credentials but share project history and skills (e.g. rotate accounts on the same project when usage limits hit):
64
89
 
65
90
  ```bash
66
91
  cc link work
67
92
  cc link personal
68
93
  ```
69
94
 
70
- `work/projects` and `personal/projects` become links to `~/.claude-shared/projects`. On Windows this uses directory junctions (no admin/Developer Mode required); on macOS/Linux, regular symlinks.
95
+ `work/` and `personal/` each get four linked dirs (`projects`, `skills`, `agents`, `commands`) pointing to `~/.claude-shared/`. On Windows this uses directory junctions (no admin/Developer Mode required); on macOS/Linux, regular symlinks.
71
96
 
72
- **Only `projects/` is shared.** That's the directory Claude Code uses for resumable conversation transcripts (`projects/<project>/<session>.jsonl`). The sibling `sessions/` folder holds transient per-process registry state — PID, working directory, status, timestamps — and is **never** linked, migrated, or copied across profiles. Sharing it would corrupt Claude's running-process bookkeeping. `sessions/` stays per-profile under `~/.claude-profiles/<name>/sessions/`.
97
+ `sessions/` is **never** linked. It holds transient per-process registry state — PID, working directory, status, timestamps — and sharing it would corrupt Claude's running-process bookkeeping. It stays per-profile under `~/.claude-profiles/<name>/sessions/`.
73
98
 
74
99
  `cc link a b c` links several profiles at once; per-profile failures are reported but don't abort the rest of the batch.
75
100
 
76
101
  If two profiles share history and you launch them against the same project at the same time, both will write to the same session files (last write wins). Don't do that.
77
102
 
78
- `cc remove` is safe with shared profiles — it deletes only the link, never recursing into the shared target.
103
+ `cc remove` is safe with shared profiles — it deletes only the links, never recursing into the shared target.
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).
79
138
 
80
139
  ## How it works
81
140
 
@@ -92,6 +151,8 @@ The env var is set on the child process only — that's all `claude` needs.
92
151
 
93
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.
94
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
+
95
156
  ## Troubleshooting
96
157
 
97
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,10 +17,11 @@ 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, storage (shared/isolated)
20
- cc migrate <src> <dest> Copy projects from default|shared to shared|<profile>
21
- cc link <p>... Replace each profile's projects/ with a link to shared
22
- cc unlink <profile> Restore a profile's private projects/
20
+ cc list [--json] Show all profiles: login state, email, shared-dir count (N/4), endpoint
21
+ cc env <profile> [show] [...] Configure a custom API endpoint for a profile (wizard or flags)
22
+ cc migrate <src> <dest> [--force] Copy artifact dirs (and config files) between profiles
23
+ cc link <p>... Link each profile's artifact dirs to ~/.claude-shared/
24
+ cc unlink <profile> Restore a profile's private artifact dirs
23
25
  cc remove <profile> Delete a profile and all its data (typed confirmation)
24
26
  cc doctor [--fix [--force]] [--json] Diagnose environment, storage, and platform issues
25
27
  cc help [command] Show usage (optionally for a single command)
@@ -58,47 +60,104 @@ Columns:
58
60
  Profile - profile name
59
61
  LoggedIn - true if .credentials.json exists
60
62
  Email - account email from .claude.json; - if not yet logged in
61
- Storage - 'shared' if projects/ is linked to ~/.claude-shared, 'isolated' otherwise
63
+ Shared - N/4 linked to ~/.claude-shared/ (projects, skills, agents, commands)
64
+ 4/4 = fully shared, 0/4 = fully isolated
62
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
63
68
 
64
- With --json, emits an array of {name, dir, loggedIn, email, storage}.
69
+ With --json, emits an array of {name, dir, loggedIn, email, shared, endpoint}.
65
70
  `,
66
- migrate: `cc migrate <src> <dest>
71
+ env: `cc env <profile> [show] [...]
67
72
 
68
- Copy projects/ from a source to a destination. sessions/ (transient per-process
69
- registry state) is never migrated. This is a COPY — the source is left untouched.
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
108
+ `,
109
+ migrate: `cc migrate <src> <dest> [--force]
110
+
111
+ Copy artifact directories from a source to a destination. This is a COPY — the
112
+ source is left untouched. sessions/ is never migrated (transient per-process state).
70
113
 
71
114
  <src>: default | shared
72
115
  default = ~/.claude/ (Claude's built-in non-profile data)
73
116
  shared = ~/.claude-shared/ (the cc shared pool)
74
117
  <dest>: shared | <profile-name>
75
118
 
76
- On collisions, existing entries at the destination are left alone (the source
77
- entry is skipped). Credential files (.credentials.json, .claude.json) are
78
- never copied — the destination keeps its own auth state.
119
+ Directories copied in all cases:
120
+ projects/, skills/, agents/, commands/
121
+
122
+ Extra files copied only when src=default and dest=<profile>:
123
+ mcp.json, settings.json, CLAUDE.md
124
+ (These carry per-profile config and are NOT copied to shared.)
125
+
126
+ Never copied: .credentials.json, .claude.json, sessions/, plugins/
127
+
128
+ Collision behavior (default): existing entries at the destination are silently
129
+ skipped — the source entry is left alone.
130
+
131
+ --force Overwrite existing entries instead of skipping.
132
+ Warning: existing content at the destination will be cleared.
79
133
 
80
134
  Examples:
81
- cc migrate default work # ~/.claude/projects -> ~/.claude-profiles/work/projects
82
- cc migrate default shared # ~/.claude/projects -> ~/.claude-shared/projects
83
- cc migrate shared work # ~/.claude-shared/projects -> ~/.claude-profiles/work/projects
135
+ cc migrate default work # copy ~/.claude/* to ~/.claude-profiles/work/
136
+ cc migrate default shared # copy dirs to ~/.claude-shared/
137
+ cc migrate shared work # copy shared dirs to ~/.claude-profiles/work/
138
+ cc migrate default work --force # same, overwriting any existing items
84
139
  `,
85
140
  link: `cc link <profile> [<profile>...]
86
141
 
87
- Replace each profile's projects/ with a link to ~/.claude-shared/projects/.
88
- Existing content is migrated into shared first; collisions are renamed with a
89
- __<profile> suffix (never overwritten). sessions/ (transient per-process state)
90
- is intentionally never linked — it stays per-profile.
142
+ Replace each profile's artifact directories with links to ~/.claude-shared/:
143
+ projects/, skills/, agents/, commands/
144
+
145
+ Existing content in each directory is migrated into shared first; collisions
146
+ are renamed with a __<profile> suffix (never overwritten). sessions/ (transient
147
+ per-process state) is intentionally never linked — it stays per-profile.
91
148
 
92
149
  On Windows, link uses directory junctions (no admin/Developer Mode needed).
93
150
  On macOS/Linux, regular symlinks.
94
151
 
95
- Multiple profiles can be linked at once: 'cc link a b c'. Per-profile failures
96
- are reported but don't abort the rest of the batch.
152
+ Multiple profiles can be linked at once: 'cc link a b c'. Failures are handled
153
+ per directory and per profile: one directory failing does not stop the other
154
+ three, and one profile failing does not stop the rest of the batch. A
155
+ per-directory summary is printed; exit code is 1 if anything failed.
97
156
  `,
98
157
  unlink: `cc unlink <profile>
99
158
 
100
- Remove a profile's projects/ link to ~/.claude-shared/ and copy shared content
101
- back into the profile as a private dir.
159
+ Remove each linked artifact directory (projects/, skills/, agents/, commands/)
160
+ and copy the shared content back into the profile as private dirs.
102
161
  `,
103
162
  remove: `cc remove <profile>
104
163
 
@@ -234,21 +293,37 @@ async function cmdUse(rest) {
234
293
  err.exitCode = 1;
235
294
  throw err;
236
295
  }
237
- return spawnClaude({ args: passthrough, profileDir: dir });
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 });
238
309
  }
239
310
 
240
311
  function cmdList(rest) {
241
312
  const { flags } = parseFlagsStrict(rest, ['--json']);
242
313
  const list = profiles.listProfiles();
314
+ const total = share.SHARED_SUBDIRS.length;
243
315
 
244
316
  const enriched = list.map((p) => {
245
- const storage = isLinkLike(path.join(p.dir, 'projects')) ? 'shared' : 'isolated';
317
+ const sharedCount = share.SHARED_SUBDIRS.filter(sub =>
318
+ isLinkLike(path.join(p.dir, sub))
319
+ ).length;
246
320
  return {
247
321
  name: p.name,
248
322
  dir: p.dir,
249
323
  loggedIn: p.loggedIn,
250
324
  email: p.email,
251
- storage,
325
+ shared: sharedCount,
326
+ endpoint: envLib.endpointSummary(p.dir),
252
327
  };
253
328
  });
254
329
 
@@ -263,13 +338,14 @@ function cmdList(rest) {
263
338
  return 0;
264
339
  }
265
340
 
266
- const rows = [['Profile', 'LoggedIn', 'Email', 'Storage']];
341
+ const rows = [['Profile', 'LoggedIn', 'Email', 'Shared', 'Endpoint']];
267
342
  for (const p of enriched) {
268
343
  rows.push([
269
344
  p.name,
270
345
  p.loggedIn ? 'true' : 'false',
271
346
  p.email ? p.email : '-',
272
- p.storage,
347
+ `${p.shared}/${total}`,
348
+ p.endpoint,
273
349
  ]);
274
350
  }
275
351
  console.log(renderTable(rows));
@@ -311,29 +387,67 @@ async function cmdRemove(rest) {
311
387
 
312
388
  async function cmdLink(rest) {
313
389
  if (rest.length === 0) throw usageError('Usage: cc link <profile> [<profile>...]');
314
- const failures = [];
390
+ const multi = rest.length > 1;
391
+ const summary = [];
315
392
  for (const name of rest) {
316
- if (rest.length > 1) console.log(`\n[${name}]`);
393
+ if (multi) console.log(`\n[${name}]`);
394
+ let results;
317
395
  try {
318
- share.shareLink(name);
396
+ results = share.shareLink(name);
319
397
  } catch (err) {
398
+ // Profile-level failure (invalid name, profile missing) — nothing linked.
320
399
  process.stderr.write(color.stderr.red(` error: ${err.message}`) + '\n');
321
- failures.push({ name, message: err.message });
400
+ summary.push({ name, linked: 0, total: share.SHARED_SUBDIRS.length, error: err.message });
401
+ continue;
402
+ }
403
+ for (const r of results) {
404
+ if (r.status === 'failed') {
405
+ process.stderr.write(color.stderr.red(` ${r.sub}: failed — ${r.message}`) + '\n');
406
+ } else if (r.status === 'already-linked') {
407
+ console.log(` ${r.sub}: already linked`);
408
+ } else {
409
+ const note = r.moved ? ` (migrated ${r.moved} item(s) into shared)` : '';
410
+ console.log(` ${r.sub}: linked${note}`);
411
+ }
322
412
  }
413
+ const failed = results.filter((r) => r.status === 'failed');
414
+ const linked = results.length - failed.length;
415
+ console.log(` ${linked}/${results.length} directories linked${failed.length ? `, ${failed.length} failed` : ''}.`);
416
+ summary.push({ name, linked, total: results.length, failed: failed.map((f) => f.sub) });
323
417
  }
324
- if (rest.length > 1) {
325
- const ok = rest.length - failures.length;
326
- console.log(`\nSummary: ${ok} succeeded, ${failures.length} failed.`);
327
- for (const f of failures) console.log(` ${f.name}: ${f.message}`);
418
+
419
+ const anyFailure = summary.some((s) => s.error || (s.failed && s.failed.length));
420
+ if (multi) {
421
+ console.log('\nSummary:');
422
+ for (const s of summary) {
423
+ if (s.error) {
424
+ console.log(` ${s.name}: error — ${s.error}`);
425
+ } else if (s.failed && s.failed.length) {
426
+ console.log(` ${s.name}: ${s.linked}/${s.total} linked (failed: ${s.failed.join(', ')})`);
427
+ } else {
428
+ console.log(` ${s.name}: ${s.linked}/${s.total} linked`);
429
+ }
430
+ }
328
431
  }
329
- return failures.length === 0 ? 0 : 1;
432
+ return anyFailure ? 1 : 0;
330
433
  }
331
434
 
332
435
  async function cmdUnlink(rest) {
333
436
  const [name] = rest;
334
437
  if (!name) throw usageError('Usage: cc unlink <profile>');
335
- share.shareUnlink(name);
336
- return 0;
438
+ const results = share.shareUnlink(name);
439
+ let anyFailure = false;
440
+ for (const r of results) {
441
+ if (r.status === 'failed') {
442
+ anyFailure = true;
443
+ process.stderr.write(color.stderr.red(` ${r.sub}: failed — ${r.message}`) + '\n');
444
+ } else if (r.status === 'not-linked') {
445
+ console.log(` ${r.sub}: not linked, skipping`);
446
+ } else {
447
+ console.log(` ${r.sub}: unlinked, content copied back`);
448
+ }
449
+ }
450
+ return anyFailure ? 1 : 0;
337
451
  }
338
452
 
339
453
  async function cmdDoctor(rest) {
@@ -347,13 +461,74 @@ async function cmdDoctor(rest) {
347
461
  }
348
462
 
349
463
  async function cmdMigrate(rest) {
350
- const [src, dest, ...extra] = rest;
351
- if (!src || !dest) throw usageError('Usage: cc migrate <src> <dest>');
464
+ const { flags, positional } = parseFlagsStrict(rest, ['--force'], { allowPositional: true });
465
+ const [src, dest, ...extra] = positional;
466
+ if (!src || !dest) throw usageError('Usage: cc migrate <src> <dest> [--force]');
352
467
  if (extra.length > 0) throw usageError(`Unexpected extra arguments: ${extra.join(' ')}`);
353
- migrateLib.migrate(src, dest);
468
+ const force = flags.has('--force');
469
+ if (force) {
470
+ process.stdout.write('Warning: --force is set. Existing items at the destination will be overwritten and their current content will be cleared.\n');
471
+ }
472
+ migrateLib.migrate(src, dest, { force });
354
473
  return 0;
355
474
  }
356
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
+
357
532
  async function run(argv) {
358
533
  const [action, ...rest] = argv;
359
534
  if (!action || action === '--help' || action === '-h') {
@@ -376,6 +551,8 @@ async function run(argv) {
376
551
  return await cmdUse(rest);
377
552
  case 'list':
378
553
  return cmdList(rest);
554
+ case 'env':
555
+ return await cmdEnv(rest);
379
556
  case 'migrate':
380
557
  return await cmdMigrate(rest);
381
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
@@ -6,10 +6,19 @@ const os = require('os');
6
6
 
7
7
  const profiles = require('./profiles');
8
8
 
9
- // Only projects/ holds the resumable conversation transcripts. sessions/ is transient
10
- // per-process state and is intentionally not migrated.
11
- const SUBDIRS = ['projects'];
12
- const EXCLUDED_FILES = new Set(['.credentials.json', '.claude.json']);
9
+ // sessions/ is transient per-process state — intentionally not migrated.
10
+ // plugins/ carries auth tokens — excluded until an explicit opt-in flag is added.
11
+ const SUBDIRS = ['projects', 'skills', 'agents', 'commands'];
12
+
13
+ // Single files copied only when src=default and dest=<profile>.
14
+ // Not copied to shared: these carry per-profile config / auth tokens.
15
+ const SINGLE_FILES = ['mcp.json', 'settings.json', 'CLAUDE.md'];
16
+
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']);
13
22
 
14
23
  function defaultClaudeDir() {
15
24
  return path.join(os.homedir(), '.claude');
@@ -36,7 +45,7 @@ function resolveDest(dest) {
36
45
  return { kind: 'profile', name: dest, dir: profiles.profileDir(dest) };
37
46
  }
38
47
 
39
- function copyTreeSkipCollisions(srcRoot, dstRoot) {
48
+ function copyTreeSkipCollisions(srcRoot, dstRoot, { force = false } = {}) {
40
49
  if (!fs.existsSync(srcRoot)) return { copied: 0, skipped: 0 };
41
50
  fs.mkdirSync(dstRoot, { recursive: true });
42
51
  let copied = 0;
@@ -46,8 +55,12 @@ function copyTreeSkipCollisions(srcRoot, dstRoot) {
46
55
  const from = path.join(srcRoot, ent.name);
47
56
  const to = path.join(dstRoot, ent.name);
48
57
  if (fs.existsSync(to)) {
49
- skipped += 1;
50
- continue;
58
+ if (force) {
59
+ fs.rmSync(to, { recursive: true, force: true });
60
+ } else {
61
+ skipped += 1;
62
+ continue;
63
+ }
51
64
  }
52
65
  fs.cpSync(from, to, { recursive: true, verbatimSymlinks: true });
53
66
  copied += 1;
@@ -55,7 +68,23 @@ function copyTreeSkipCollisions(srcRoot, dstRoot) {
55
68
  return { copied, skipped };
56
69
  }
57
70
 
58
- function migrate(srcArg, destArg) {
71
+ // Returns 'copied', 'skipped', or 'absent'.
72
+ function copySingleFile(srcDir, dstDir, fileName, { force = false } = {}) {
73
+ const from = path.join(srcDir, fileName);
74
+ if (!fs.existsSync(from)) return 'absent';
75
+ const to = path.join(dstDir, fileName);
76
+ if (fs.existsSync(to)) {
77
+ if (force) {
78
+ fs.rmSync(to, { recursive: true, force: true });
79
+ } else {
80
+ return 'skipped';
81
+ }
82
+ }
83
+ fs.copyFileSync(from, to);
84
+ return 'copied';
85
+ }
86
+
87
+ function migrate(srcArg, destArg, { force = false } = {}) {
59
88
  const src = resolveSource(srcArg);
60
89
  const dest = resolveDest(destArg);
61
90
 
@@ -71,7 +100,7 @@ function migrate(srcArg, destArg) {
71
100
 
72
101
  if (!fs.existsSync(src.dir)) {
73
102
  console.log(`Source ${src.dir} does not exist. Nothing to migrate.`);
74
- return { copied: 0, skipped: 0 };
103
+ return { copied: 0, skipped: 0, files: {} };
75
104
  }
76
105
 
77
106
  let totalCopied = 0;
@@ -80,13 +109,27 @@ function migrate(srcArg, destArg) {
80
109
  const srcSub = path.join(src.dir, sub);
81
110
  const dstSub = path.join(dest.dir, sub);
82
111
  if (!fs.existsSync(srcSub)) continue;
83
- const { copied, skipped } = copyTreeSkipCollisions(srcSub, dstSub);
112
+ const { copied, skipped } = copyTreeSkipCollisions(srcSub, dstSub, { force });
84
113
  console.log(` ${sub}: copied ${copied}, skipped ${skipped}`);
85
114
  totalCopied += copied;
86
115
  totalSkipped += skipped;
87
116
  }
117
+
118
+ // Single config files: only when migrating from default into a profile.
119
+ const fileResults = {};
120
+ if (src.kind === 'default' && dest.kind === 'profile') {
121
+ for (const f of SINGLE_FILES) {
122
+ const result = copySingleFile(src.dir, dest.dir, f, { force });
123
+ fileResults[f] = result;
124
+ const label = result === 'absent' ? 'not present at source' : result;
125
+ console.log(` ${f}: ${label}`);
126
+ if (result === 'copied') totalCopied += 1;
127
+ else if (result === 'skipped') totalSkipped += 1;
128
+ }
129
+ }
130
+
88
131
  console.log(`Done. Total copied: ${totalCopied}, total skipped (already present): ${totalSkipped}.`);
89
- return { copied: totalCopied, skipped: totalSkipped };
132
+ return { copied: totalCopied, skipped: totalSkipped, files: fileResults };
90
133
  }
91
134
 
92
135
  module.exports = {
@@ -95,6 +138,8 @@ module.exports = {
95
138
  resolveSource,
96
139
  resolveDest,
97
140
  copyTreeSkipCollisions,
141
+ copySingleFile,
98
142
  SUBDIRS,
143
+ SINGLE_FILES,
99
144
  EXCLUDED_FILES,
100
145
  };
package/lib/share.js CHANGED
@@ -5,10 +5,11 @@ const path = require('path');
5
5
 
6
6
  const profiles = require('./profiles');
7
7
 
8
- // Only projects/ holds the resumable conversation transcripts (projects/<proj>/<session>.jsonl).
8
+ // Artifact directories that are safe to share across profiles via junctions/symlinks.
9
9
  // sessions/ is transient per-process registry state (PID, cwd, status, timestamps) and
10
10
  // must stay per-profile — sharing it would corrupt Claude's running-process bookkeeping.
11
- const SHARED_SUBDIRS = ['projects'];
11
+ // plugins/ carries auth tokens and is intentionally excluded (future opt-in).
12
+ const SHARED_SUBDIRS = ['projects', 'skills', 'agents', 'commands'];
12
13
 
13
14
  function symlinkType() {
14
15
  return process.platform === 'win32' ? 'junction' : 'dir';
@@ -70,6 +71,11 @@ function migrateIntoShared(profileName, src, sharedTarget) {
70
71
  return moved;
71
72
  }
72
73
 
74
+ // Links each of the four artifact dirs independently. A failure on one
75
+ // directory is recorded and the remaining directories are still attempted —
76
+ // callers get a per-directory result and decide how to report it.
77
+ // Returns [{ sub, status, moved?, message? }]; status is one of:
78
+ // 'linked' | 'already-linked' | 'failed'
73
79
  function shareLink(profileName) {
74
80
  profiles.validateProfileName(profileName);
75
81
  const pdir = profiles.profileDir(profileName);
@@ -79,26 +85,31 @@ function shareLink(profileName) {
79
85
  throw err;
80
86
  }
81
87
  ensureSharedDirs();
88
+ const results = [];
82
89
  for (const sub of SHARED_SUBDIRS) {
83
90
  const target = path.join(profiles.sharedDir(), sub);
84
91
  const link = path.join(pdir, sub);
85
- if (isLink(link)) {
86
- console.log(` ${sub}: already linked`);
87
- continue;
88
- }
89
- if (fs.existsSync(link)) {
90
- const moved = migrateIntoShared(profileName, link, target);
91
- console.log(` ${sub}: migrated ${moved} item(s) into shared`);
92
- }
93
92
  try {
93
+ if (isLink(link)) {
94
+ results.push({ sub, status: 'already-linked' });
95
+ continue;
96
+ }
97
+ let moved = 0;
98
+ if (fs.existsSync(link)) {
99
+ moved = migrateIntoShared(profileName, link, target);
100
+ }
94
101
  fs.symlinkSync(target, link, symlinkType());
102
+ results.push({ sub, status: 'linked', moved });
95
103
  } catch (err) {
96
- throw new Error(`Cannot link ${link} -> ${target}: ${err.message}`);
104
+ results.push({ sub, status: 'failed', message: err.message });
97
105
  }
98
- console.log(` ${sub}: linked -> ${target}`);
99
106
  }
107
+ return results;
100
108
  }
101
109
 
110
+ // Mirror of shareLink: each directory is restored independently.
111
+ // Returns [{ sub, status, message? }]; status is one of:
112
+ // 'unlinked' | 'not-linked' | 'failed'
102
113
  function shareUnlink(profileName) {
103
114
  profiles.validateProfileName(profileName);
104
115
  const pdir = profiles.profileDir(profileName);
@@ -107,22 +118,28 @@ function shareUnlink(profileName) {
107
118
  err.exitCode = 1;
108
119
  throw err;
109
120
  }
121
+ const results = [];
110
122
  for (const sub of SHARED_SUBDIRS) {
111
123
  const link = path.join(pdir, sub);
112
124
  const target = path.join(profiles.sharedDir(), sub);
113
- if (!isLink(link)) {
114
- console.log(` ${sub}: not linked, skipping`);
115
- continue;
116
- }
117
- removeLink(link);
118
- fs.mkdirSync(link, { recursive: true });
119
- if (fs.existsSync(target)) {
120
- for (const ent of fs.readdirSync(target)) {
121
- copyRecursive(path.join(target, ent), path.join(link, ent));
125
+ try {
126
+ if (!isLink(link)) {
127
+ results.push({ sub, status: 'not-linked' });
128
+ continue;
122
129
  }
130
+ removeLink(link);
131
+ fs.mkdirSync(link, { recursive: true });
132
+ if (fs.existsSync(target)) {
133
+ for (const ent of fs.readdirSync(target)) {
134
+ copyRecursive(path.join(target, ent), path.join(link, ent));
135
+ }
136
+ }
137
+ results.push({ sub, status: 'unlinked' });
138
+ } catch (err) {
139
+ results.push({ sub, status: 'failed', message: err.message });
123
140
  }
124
- console.log(` ${sub}: unlinked, content copied back`);
125
141
  }
142
+ return results;
126
143
  }
127
144
 
128
145
  module.exports = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cchost",
3
- "version": "0.2.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",