cchost 0.4.3 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/env.js CHANGED
@@ -8,7 +8,7 @@
8
8
  // an API key and is treated with the same care as `.credentials.json`: it is
9
9
  // never migrated, never linked, and written with user-only file permissions.
10
10
  //
11
- // Endpoint profiles are created and edited through `cc add <profile> --custom`;
11
+ // Endpoint profiles are created and edited through `cchost add <profile> --custom`;
12
12
  // this module supplies the configuration logic that command delegates to.
13
13
 
14
14
  const fs = require('fs');
@@ -21,6 +21,26 @@ const ENV_FILE = '.cc-env.json';
21
21
  // Persisted fields, in canonical serialization order.
22
22
  const FIELDS = ['base_url', 'auth_token', 'model', 'opus', 'sonnet', 'haiku', 'subagent'];
23
23
  const TIER_FIELDS = ['opus', 'sonnet', 'haiku', 'subagent'];
24
+ // Fields whose value must be a string if present. Optional fields are ignored
25
+ // (treated as absent) when hand-edited to a non-string value.
26
+ const STRICT_STRING_FIELDS = ['base_url', 'auth_token'];
27
+
28
+ // Environment variables Claude Code reads for authentication. If any of these
29
+ // are inherited by a subscription profile, they can silently override the
30
+ // profile's own OAuth login (and billing).
31
+ const AUTH_ENV_VARS = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN'];
32
+
33
+ function authEnvConflicts(env = process.env) {
34
+ return AUTH_ENV_VARS.filter((key) => env[key]);
35
+ }
36
+
37
+ // Endpoint profiles must not inherit the parent's Anthropic auth/endpoint
38
+ // settings. Keep only the values buildEnvVars() set for this profile.
39
+ const ENDPOINT_ENV_VARS = [...AUTH_ENV_VARS, 'ANTHROPIC_BASE_URL'];
40
+
41
+ function endpointEnvToStrip(profileEnv, env = process.env) {
42
+ return ENDPOINT_ENV_VARS.filter((key) => env[key] && !profileEnv[key]);
43
+ }
24
44
 
25
45
  // File field -> env var(s) injected into the spawned child.
26
46
  const TIER_ENV = {
@@ -80,6 +100,20 @@ function readEnvConfig(profileDir) {
80
100
  e.malformed = true;
81
101
  throw e;
82
102
  }
103
+ for (const key of STRICT_STRING_FIELDS) {
104
+ if (data[key] == null) continue;
105
+ if (typeof data[key] !== 'string') {
106
+ const e = stateError(`${ENV_FILE} is malformed: field "${key}" must be a string.`);
107
+ e.malformed = true;
108
+ throw e;
109
+ }
110
+ }
111
+ // Optional fields are best-effort: a hand-edited non-string value must not
112
+ // make the whole profile unusable. Treat it as absent instead.
113
+ for (const key of FIELDS) {
114
+ if (STRICT_STRING_FIELDS.includes(key)) continue;
115
+ if (data[key] != null && typeof data[key] !== 'string') delete data[key];
116
+ }
83
117
  return data;
84
118
  }
85
119
 
@@ -129,9 +163,27 @@ function writeEnvConfig(profileDir, config) {
129
163
  const target = envFilePath(profileDir);
130
164
  const tmp = path.join(profileDir, `${ENV_FILE}.tmp-${process.pid}`);
131
165
  // Write to a temp file then rename, so a crash never leaves a partial file.
132
- // Permissions are locked down after the rename: restricting the temp file's
133
- // ACL first makes the subsequent rename fail with EPERM on Windows.
134
- fs.writeFileSync(tmp, JSON.stringify(out, null, 2) + '\n');
166
+ // The temp file is created 0600 so an orphaned temp cannot expose the token.
167
+ // Permissions are locked down again after the rename: restricting the temp
168
+ // file's ACL first makes the subsequent rename fail with EPERM on Windows.
169
+ // Remove any stale temp left by pid reuse, then create with O_EXCL so a race
170
+ // cannot make us write the token into a file with an inherited, looser mode.
171
+ fs.rmSync(tmp, { force: true });
172
+ try {
173
+ fs.writeFileSync(tmp, JSON.stringify(out, null, 2) + '\n', {
174
+ mode: 0o600,
175
+ flag: 'wx',
176
+ });
177
+ } catch (err) {
178
+ if (err.code !== 'EEXIST') {
179
+ try {
180
+ fs.unlinkSync(tmp);
181
+ } catch {
182
+ // best effort cleanup
183
+ }
184
+ }
185
+ throw err;
186
+ }
135
187
  try {
136
188
  fs.renameSync(tmp, target);
137
189
  } catch (err) {
@@ -173,7 +225,7 @@ function hostOf(url) {
173
225
  }
174
226
  }
175
227
 
176
- // Endpoint summary for `cc list`. Returns the base-url host, 'subscription'
228
+ // Endpoint summary for `cchost list`. Returns the base-url host, 'subscription'
177
229
  // (no endpoint config), or '(invalid config)' for a malformed file — never
178
230
  // throws, so a single bad profile cannot break the whole table.
179
231
  function endpointSummary(profileDir) {
@@ -382,7 +434,7 @@ async function runWizard(name, existing, io) {
382
434
  // Command orchestration (called from cli.js)
383
435
  // ---------------------------------------------------------------------------
384
436
 
385
- // Configures a profile's custom endpoint. Called by `cc add <profile> --custom`
437
+ // Configures a profile's custom endpoint. Called by `cchost add <profile> --custom`
386
438
  // (both for first-time creation and for editing an existing endpoint profile).
387
439
  // `provided` is a partial config of fields supplied via flags.
388
440
  // `opts.io` injects wizard IO (tests); `opts.isTTY` overrides TTY detection.
@@ -391,14 +443,14 @@ async function runWizard(name, existing, io) {
391
443
  // wizard was cancelled, so the caller can clean up a freshly-created profile.
392
444
  async function runConfigure(name, profileDir, provided, opts = {}) {
393
445
  if (!fs.existsSync(profileDir)) {
394
- throw stateError(`Profile "${name}" does not exist. Create it with: cc add ${name}`);
446
+ throw stateError(`Profile "${name}" does not exist. Create it with: cchost add ${name}`);
395
447
  }
396
448
  if (hasOAuthState(profileDir)) {
397
449
  throw stateError(
398
450
  `Profile '${name}' has OAuth credentials and cannot be converted to a custom endpoint.\n` +
399
451
  'A profile is born subscription or endpoint and stays that way.\n' +
400
452
  'Create a fresh endpoint profile instead:\n' +
401
- ' cc add <new-name> --custom',
453
+ ' cchost add <new-name> --custom',
402
454
  );
403
455
  }
404
456
 
@@ -434,7 +486,7 @@ async function runConfigure(name, profileDir, provided, opts = {}) {
434
486
  const isTTY = opts.isTTY != null ? opts.isTTY : process.stdin.isTTY;
435
487
  if (!isTTY) {
436
488
  throw usageError(
437
- 'cc add --custom requires either endpoint flags or an interactive terminal.',
489
+ 'cchost add --custom requires either endpoint flags or an interactive terminal.',
438
490
  );
439
491
  }
440
492
  io = defaultWizardIo();
@@ -455,6 +507,10 @@ module.exports = {
455
507
  ENV_FILE,
456
508
  FIELDS,
457
509
  TIER_FIELDS,
510
+ AUTH_ENV_VARS,
511
+ authEnvConflicts,
512
+ ENDPOINT_ENV_VARS,
513
+ endpointEnvToStrip,
458
514
  envFilePath,
459
515
  hasEnvConfig,
460
516
  readEnvConfig,
package/lib/profiles.js CHANGED
@@ -102,20 +102,30 @@ function addProfile(name) {
102
102
  return { dir };
103
103
  }
104
104
 
105
- function readEmail(dir) {
105
+ // Single read/parse of a profile's .claude.json. Missing or malformed JSON is
106
+ // treated as absent state so one bad profile cannot break `cchost list`.
107
+ function readClaudeJson(dir) {
106
108
  try {
107
109
  const raw = fs.readFileSync(path.join(dir, '.claude.json'), 'utf8');
108
- if (!raw.trim()) return '';
110
+ if (!raw.trim()) return null;
109
111
  const data = JSON.parse(raw);
110
- if (data && data.oauthAccount && typeof data.oauthAccount.emailAddress === 'string') {
111
- return data.oauthAccount.emailAddress;
112
- }
113
- return '';
112
+ return data && typeof data === 'object' && !Array.isArray(data) ? data : null;
114
113
  } catch {
115
- return '';
114
+ return null;
116
115
  }
117
116
  }
118
117
 
118
+ function emailFromClaude(data) {
119
+ if (data && data.oauthAccount && typeof data.oauthAccount.emailAddress === 'string') {
120
+ return data.oauthAccount.emailAddress;
121
+ }
122
+ return '';
123
+ }
124
+
125
+ function readEmail(dir) {
126
+ return emailFromClaude(readClaudeJson(dir));
127
+ }
128
+
119
129
  function hasCredentials(dir) {
120
130
  try {
121
131
  return fs.existsSync(path.join(dir, '.credentials.json'));
@@ -124,7 +134,33 @@ function hasCredentials(dir) {
124
134
  }
125
135
  }
126
136
 
127
- function listProfiles() {
137
+ // Where this profile's Claude Code credentials live:
138
+ // 'file' — .credentials.json in the profile (Windows/Linux)
139
+ // 'keychain' — inferred from .claude.json on macOS; the token is in a
140
+ // Keychain entry namespaced by the profile's exact path
141
+ // 'none' — no sign of a login
142
+ function credentialSourceFrom(hasFile, data, platform) {
143
+ if (hasFile) return 'file';
144
+ if (platform === 'darwin' && data && data.oauthAccount != null) return 'keychain';
145
+ return 'none';
146
+ }
147
+
148
+ function credentialSource(dir, platform = process.platform) {
149
+ return credentialSourceFrom(hasCredentials(dir), readClaudeJson(dir), platform);
150
+ }
151
+
152
+ // Everything `cchost list` needs about one profile, read from one parse.
153
+ function readProfile(dir, platform = process.platform) {
154
+ const data = readClaudeJson(dir);
155
+ const credentialSource = credentialSourceFrom(hasCredentials(dir), data, platform);
156
+ return {
157
+ loggedIn: credentialSource !== 'none',
158
+ credentialSource,
159
+ email: emailFromClaude(data),
160
+ };
161
+ }
162
+
163
+ function listProfiles(platform = process.platform) {
128
164
  const base = baseDir();
129
165
  if (!fs.existsSync(base)) return [];
130
166
  let entries;
@@ -138,12 +174,7 @@ function listProfiles() {
138
174
  if (!ent.isDirectory()) continue;
139
175
  const name = ent.name;
140
176
  const dir = path.join(base, name);
141
- out.push({
142
- name,
143
- dir,
144
- loggedIn: hasCredentials(dir),
145
- email: readEmail(dir),
146
- });
177
+ out.push({ name, dir, ...readProfile(dir, platform) });
147
178
  }
148
179
  out.sort((a, b) => a.name.localeCompare(b.name));
149
180
  return out;
@@ -159,6 +190,8 @@ module.exports = {
159
190
  ensureProfileDir,
160
191
  readEmail,
161
192
  hasCredentials,
193
+ credentialSource,
194
+ readProfile,
162
195
  listProfiles,
163
196
  addProfile,
164
197
  };
package/lib/share.js CHANGED
@@ -180,6 +180,30 @@ function createResourceLink(target, link, resource) {
180
180
  }
181
181
  }
182
182
 
183
+ // Where one resource of a profile currently stands. Returns one of:
184
+ // 'shared' — linked to ~/.claude/ (symlink, junction, or hard link)
185
+ // 'private' — a real file/dir with content, unique to this profile
186
+ // 'empty' — a real but empty file/dir (link would replace it)
187
+ // 'missing' — nothing at the path
188
+ // 'foreign-link' — a symlink pointing somewhere other than ~/.claude/
189
+ function resourceState(profileDir, resource) {
190
+ const p = path.join(profileDir, resource);
191
+ if (isSharedResource(profileDir, resource)) return 'shared';
192
+ if (isLink(p)) return 'foreign-link';
193
+ let st;
194
+ try {
195
+ st = fs.statSync(p);
196
+ } catch {
197
+ return 'missing';
198
+ }
199
+ try {
200
+ const hasContent = st.isDirectory() ? fs.readdirSync(p).length > 0 : st.size > 0;
201
+ return hasContent ? 'private' : 'empty';
202
+ } catch {
203
+ return 'private';
204
+ }
205
+ }
206
+
183
207
  // Link each requested resource of a profile into the shared pool (~/.claude/).
184
208
  // Each resource is attempted independently — one failure doesn't abort the
185
209
  // rest. Returns [{ resource, status, ... }]; status ∈
@@ -188,18 +212,21 @@ function createResourceLink(target, link, resource) {
188
212
  // 'refused' is the v0.4.1 behavior when the profile holds private content at
189
213
  // the resource path: we do NOT auto-move it into the pool (was the v0.4.0
190
214
  // behavior for dirs). The user must move/remove it manually.
215
+ //
216
+ // opts.dryRun reports the same statuses without touching the filesystem.
191
217
  function shareLink(profileName, opts = {}) {
218
+ const dryRun = Boolean(opts.dryRun);
192
219
  profiles.validateProfileName(profileName);
193
220
  const pdir = profiles.profileDir(profileName);
194
221
  if (!fs.existsSync(pdir)) {
195
222
  const err = new Error(
196
- `Profile "${profileName}" does not exist. Create it with \`cc add ${profileName}\` first.`,
223
+ `Profile "${profileName}" does not exist. Create it with \`cchost add ${profileName}\` first.`,
197
224
  );
198
225
  err.exitCode = 1;
199
226
  throw err;
200
227
  }
201
228
  const subset = resolveSubset(opts);
202
- ensurePoolResources(subset);
229
+ if (!dryRun) ensurePoolResources(subset);
203
230
  const pool = poolDir();
204
231
  const legacyPool = profiles.sharedDir();
205
232
  const results = [];
@@ -217,13 +244,22 @@ function shareLink(profileName, opts = {}) {
217
244
  }
218
245
  if (resolved === path.join(legacyPool, resource)) {
219
246
  // Legacy v0.3 pointer — silently relink to the new pool location.
247
+ if (dryRun) {
248
+ results.push({ resource, status: 'linked', relinkedFromLegacy: true });
249
+ continue;
250
+ }
220
251
  removeLink(link);
221
252
  const r = createResourceLink(target, link, resource);
222
253
  results.push({ resource, status: 'linked', relinkedFromLegacy: true, kind: r.kind });
223
254
  continue;
224
255
  }
225
- // Points somewhere unexpected — don't touch it.
226
- results.push({ resource, status: 'already-linked' });
256
+ // Points somewhere unexpected — don't touch it, but report it as not
257
+ // shared so callers don't claim the resource is linked.
258
+ results.push({
259
+ resource,
260
+ status: 'foreign-link',
261
+ message: `symlink points to ${resolved}, not ${target} — left untouched`,
262
+ });
227
263
  continue;
228
264
  }
229
265
 
@@ -246,14 +282,20 @@ function shareLink(profileName, opts = {}) {
246
282
  status: 'refused',
247
283
  message:
248
284
  `private content exists at ${link} — move or remove it manually, ` +
249
- `then re-run \`cc link ${profileName}\``,
285
+ `then re-run \`cchost link ${profileName}\``,
250
286
  });
251
287
  continue;
252
288
  }
253
289
  // Empty private dir — safe to remove and replace with a symlink.
254
- try { fs.rmdirSync(link); } catch { /* fall through; create will fail clearly */ }
290
+ if (!dryRun) {
291
+ try { fs.rmdirSync(link); } catch { /* fall through; create will fail clearly */ }
292
+ }
255
293
  }
256
294
 
295
+ if (dryRun) {
296
+ results.push({ resource, status: 'linked' });
297
+ continue;
298
+ }
257
299
  const r = createResourceLink(target, link, resource);
258
300
  results.push({ resource, status: 'linked', kind: r.kind });
259
301
  } catch (err) {
@@ -268,7 +310,12 @@ function shareLink(profileName, opts = {}) {
268
310
  // collisions, defensive) so the profile keeps its current view.
269
311
  // Returns [{ resource, status, ... }]; status ∈
270
312
  // 'unlinked' | 'not-linked' | 'failed'
313
+ //
314
+ // opts.dryRun reports the same statuses without touching the filesystem; dir
315
+ // results then carry `items`, the number of top-level entries that would be
316
+ // copied into the profile.
271
317
  function shareUnlink(profileName, opts = {}) {
318
+ const dryRun = Boolean(opts.dryRun);
272
319
  profiles.validateProfileName(profileName);
273
320
  const pdir = profiles.profileDir(profileName);
274
321
  if (!fs.existsSync(pdir)) {
@@ -290,6 +337,15 @@ function shareUnlink(profileName, opts = {}) {
290
337
  continue;
291
338
  }
292
339
 
340
+ if (dryRun) {
341
+ const r = { resource, status: 'unlinked' };
342
+ if (!isFileResource(resource)) {
343
+ try { r.items = fs.readdirSync(target).length; } catch { r.items = 0; }
344
+ }
345
+ results.push(r);
346
+ continue;
347
+ }
348
+
293
349
  // Read pool content BEFORE removing the link (in case the link is the
294
350
  // only path to it... well, not really — target is reachable directly —
295
351
  // but reading first means we can fail without leaving a deleted link).
@@ -783,6 +839,7 @@ module.exports = {
783
839
  isFileResource,
784
840
  isLink,
785
841
  isSharedResource,
842
+ resourceState,
786
843
  removeLink,
787
844
  shareLink,
788
845
  shareUnlink,
package/lib/spawn.js CHANGED
@@ -155,7 +155,7 @@ function platformSpawn(bin, args, opts) {
155
155
  return spawn(bin, args, opts);
156
156
  }
157
157
 
158
- function spawnClaude({ args = [], profileDir = null, extraEnv = {} } = {}) {
158
+ function spawnClaude({ args = [], profileDir = null, extraEnv = {}, unsetEnv = [] } = {}) {
159
159
  const bin = findClaude();
160
160
  if (!bin) {
161
161
  const err = new Error(
@@ -169,6 +169,7 @@ function spawnClaude({ args = [], profileDir = null, extraEnv = {} } = {}) {
169
169
  warnIfOld(bin);
170
170
 
171
171
  const childEnv = { ...process.env, ...extraEnv };
172
+ for (const name of unsetEnv) delete childEnv[name];
172
173
  if (profileDir) {
173
174
  childEnv.CLAUDE_CONFIG_DIR = profileDir;
174
175
  } else {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "cchost",
3
- "version": "0.4.3",
4
- "description": "Host multiple isolated Claude Code accounts on one machine via the `cc` command.",
3
+ "version": "0.5.0",
4
+ "description": "Multi-account profile manager for Claude Code — switch accounts, share skills/agents/projects, route to custom endpoints. Works on Windows, Linux, and macOS.",
5
5
  "keywords": [
6
6
  "claude",
7
7
  "claude-code",
@@ -10,7 +10,10 @@
10
10
  "profile",
11
11
  "multi-account",
12
12
  "credentials",
13
- "isolation"
13
+ "isolation",
14
+ "macos",
15
+ "windows",
16
+ "linux"
14
17
  ],
15
18
  "homepage": "https://github.com/allenhack638/cchost#readme",
16
19
  "bugs": {
@@ -23,6 +26,7 @@
23
26
  "url": "git+https://github.com/allenhack638/cchost.git"
24
27
  },
25
28
  "bin": {
29
+ "cchost": "bin/cc.js",
26
30
  "cc": "bin/cc.js"
27
31
  },
28
32
  "engines": {
@@ -44,4 +48,4 @@
44
48
  "devDependencies": {
45
49
  "vitest": "^2.1.0"
46
50
  }
47
- }
51
+ }