cchost 0.1.2 → 0.2.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # cchost
2
2
 
3
- Host multiple isolated Claude Code accounts on one machine via a single `cc` command. Works identically in PowerShell, cmd, bash, zsh, and fish on Windows, macOS, and Linux.
3
+ Host multiple isolated Claude Code accounts on one machine via a single `cc` command. `cc` is a plain Node CLI on your PATH, so it runs the same from PowerShell, cmd, bash, zsh, or fish — there are no shell scripts or rc-file edits involved. Tested on Windows and Linux; macOS is expected to work but is not yet verified.
4
4
 
5
5
  Each profile is a complete, isolated `CLAUDE_CONFIG_DIR` — credentials, session history, project state, plugins, and settings. Create one with `cc add <name>`; launch under it with `cc use <name>`.
6
6
 
@@ -16,13 +16,14 @@ Requires Node.js 18+ and Claude Code 2.1.140+ already installed. Get Claude Code
16
16
 
17
17
  | Command | What it does |
18
18
  | --- | --- |
19
- | `cc add <profile> [--email=] [--org=] [--name=]` | Create a profile directory. Optional alias flags (EXPERIMENTAL, see below). |
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 [--original] [--json]` | Show every profile: login state, account email, and storage mode (`shared` / `isolated`). |
21
+ | `cc list [--json]` | Show every profile: login state, account email, and storage mode (`shared` / `isolated`). |
22
22
  | `cc migrate <src> <dest>` | Copy projects from `default`\|`shared` to `shared`\|`<profile>`. Skip-on-collision; credential files never copied. |
23
23
  | `cc link <profile> [<profile>...]` | Link a profile's `projects/` to `~/.claude-shared/projects`. Migrates existing content; renames on collision. |
24
24
  | `cc unlink <profile>` | Restore a profile to its own private `projects/` (copies shared content back). |
25
25
  | `cc remove <profile>` | Delete a profile. Requires typing the profile name to confirm. |
26
+ | `cc doctor [--fix [--force]] [--json]` | Diagnose environment, storage, and platform issues. |
26
27
  | `cc help [command]` | Print usage (top-level, or for a single command). |
27
28
 
28
29
  ### Examples
@@ -76,21 +77,14 @@ If two profiles share history and you launch them against the same project at th
76
77
 
77
78
  `cc remove` is safe with shared profiles — it deletes only the link, never recursing into the shared target.
78
79
 
79
- ### Aliases (`cc add` flags) — EXPERIMENTAL
80
-
81
- `cc add work --email=work@example.com --org=Acme --name="Work Account"` stores those values in `~/.claude-profiles/work/.cc-alias.json`. On every `cc use`, they're written into the profile's `.claude.json` `oauthAccount` so `cc list` and Claude's own UI display them instead of the real underlying account.
82
-
83
- **EXPERIMENTAL.** Claude Code may overwrite `oauthAccount` on server re-sync; we re-apply on the next `cc use`, but masking across all of Claude's UI surfaces has not been verified. Use `cc list --original` to see the real values from `.claude.json`.
84
-
85
80
  ## How it works
86
81
 
87
82
  `cc` is a Node CLI with **zero runtime dependencies**. For `cc use` it:
88
83
 
89
84
  1. Resolves the profile directory under `~/.claude-profiles/<name>/`
90
- 2. Applies any stored alias to `.claude.json` `oauthAccount` (best-effort)
91
- 3. Sets `CLAUDE_CONFIG_DIR` in its **own** spawn environment
92
- 4. Spawns `claude` with `stdio: 'inherit'` so the TUI works correctly
93
- 5. Forwards the child's exit code
85
+ 2. Sets `CLAUDE_CONFIG_DIR` in its **own** spawn environment
86
+ 3. Spawns `claude` with `stdio: 'inherit'` so the TUI works correctly
87
+ 4. Forwards the child's exit code
94
88
 
95
89
  The env var is set on the child process only — that's all `claude` needs.
96
90
 
package/lib/cli.js CHANGED
@@ -14,32 +14,29 @@ const color = require('./color');
14
14
  const HELP = `cc - Multi-profile launcher for Claude Code
15
15
 
16
16
  Usage:
17
- cc add <profile> [--email=] [--org=] [--name=] Create a profile (EXPERIMENTAL aliases)
17
+ cc add <profile> Create a profile directory
18
18
  cc use <profile> [...args] Launch Claude under a named profile
19
- cc list [--original] [--json] Show all profiles: login state, email, storage (shared/isolated)
19
+ cc list [--json] Show all profiles: login state, email, storage (shared/isolated)
20
20
  cc migrate <src> <dest> Copy projects from default|shared to shared|<profile>
21
21
  cc link <p>... Replace each profile's projects/ with a link to shared
22
22
  cc unlink <profile> Restore a profile's private projects/
23
23
  cc remove <profile> Delete a profile and all its data (typed confirmation)
24
+ cc doctor [--fix [--force]] [--json] Diagnose environment, storage, and platform issues
24
25
  cc help [command] Show usage (optionally for a single command)
26
+ cc --version Print the installed cchost version
25
27
 
26
28
  Profiles live in ~/.claude-profiles/<profile>/ and become CLAUDE_CONFIG_DIR
27
29
  for that launch. Args after the profile name are forwarded verbatim to claude.
28
30
  `;
29
31
 
30
32
  const HELP_BY_CMD = {
31
- add: `cc add <profile> [--email=...] [--org=...] [--name=...]
33
+ add: `cc add <profile>
32
34
 
33
- Create a new profile directory without launching Claude. Optional alias flags
34
- record account values shown in 'cc list' and written into the profile's
35
- .claude.json oauthAccount on subsequent 'cc use' launches.
36
-
37
- EXPERIMENTAL: Claude Code may overwrite these on server re-sync. Masking
38
- behavior across all UI surfaces has not been verified.
35
+ Create a new profile directory without launching Claude.
39
36
 
40
37
  Examples:
41
38
  cc add work
42
- cc add work --email=work@example.com --org=Acme --name="Work Account"
39
+ cc add personal
43
40
  `,
44
41
  use: `cc use <profile> [...args]
45
42
 
@@ -55,19 +52,16 @@ Examples:
55
52
  cc use work --resume
56
53
  cc use personal -p "summarize this codebase"
57
54
  `,
58
- list: `cc list [--original] [--json]
55
+ list: `cc list [--json]
59
56
 
60
57
  Columns:
61
58
  Profile - profile name
62
59
  LoggedIn - true if .credentials.json exists
63
- Email - account email (alias-aware; - if not yet logged in)
60
+ Email - account email from .claude.json; - if not yet logged in
64
61
  Storage - 'shared' if projects/ is linked to ~/.claude-shared, 'isolated' otherwise
65
62
  (sessions/ is transient per-process state and is never shared)
66
63
 
67
- By default, the displayed email reflects any alias set via 'cc add'.
68
- With --original, shows the real value from .claude.json.
69
-
70
- With --json, emits an array of {name, dir, loggedIn, email, storage, alias}.
64
+ With --json, emits an array of {name, dir, loggedIn, email, storage}.
71
65
  `,
72
66
  migrate: `cc migrate <src> <dest>
73
67
 
@@ -114,6 +108,21 @@ name — y/n is intentionally not accepted because credential loss is unrecovera
114
108
 
115
109
  If the profile's projects/ is a link to ~/.claude-shared/, only the link is
116
110
  removed — the shared target is preserved.
111
+ `,
112
+ doctor: `cc doctor [--fix [--force]] [--json]
113
+
114
+ Run diagnostic checks across four groups:
115
+
116
+ Environment Node >=18, claude on PATH, claude >=2.1.140, no conflicting env vars, no cc shadow
117
+ Storage ~/.claude-profiles/ writable, .claude.json parseable, junctions/symlinks valid
118
+ Platform Windows: OneDrive home, WindowsApps stub, stale ~/bin/cc; Git Bash: cygpath
119
+ Sanity Test-spawn claude --version through the same code path cc use uses
120
+
121
+ Exit codes: 0 = all ok, 1 = warnings, 2 = errors.
122
+
123
+ --fix Auto-apply safe fixes: create missing dirs, recreate broken junctions
124
+ --fix --force Also apply confirmation-required fixes (e.g. reset a corrupt .claude.json)
125
+ --json Machine-readable output for CI / scripting
117
126
  `,
118
127
  help: `cc help [command]
119
128
 
@@ -205,41 +214,12 @@ function parseFlagsStrict(args, flagNames, { allowPositional = false } = {}) {
205
214
  return { flags, positional };
206
215
  }
207
216
 
208
- function parseAliasFlags(args) {
209
- const out = {};
210
- for (const a of args) {
211
- if (!a.startsWith('--')) {
212
- throw usageError(`Unexpected positional argument: ${a}`);
213
- }
214
- const eq = a.indexOf('=');
215
- if (eq === -1) {
216
- throw usageError(`Flag must be in --key=value form: ${a}`);
217
- }
218
- const key = a.slice(2, eq);
219
- const value = a.slice(eq + 1);
220
- if (!profiles.ALLOWED_ALIAS_KEYS.includes(key)) {
221
- throw usageError(`Unknown flag: --${key}. Allowed: --email, --org, --name.`);
222
- }
223
- if (value.length === 0) {
224
- throw usageError(`Flag --${key} requires a non-empty value.`);
225
- }
226
- out[key] = value;
227
- }
228
- return out;
229
- }
230
-
231
217
  async function cmdAdd(rest) {
232
- const [name, ...flagArgs] = rest;
233
- if (!name) throw usageError('Usage: cc add <profile> [--email=...] [--org=...] [--name=...]');
234
- const aliasFlags = parseAliasFlags(flagArgs);
235
- const { dir } = profiles.addProfile(name, aliasFlags);
218
+ const [name, ...extra] = rest;
219
+ if (!name) throw usageError('Usage: cc add <profile>');
220
+ if (extra.length > 0) throw usageError(`cc add takes no extra arguments: ${extra[0]}`);
221
+ const { dir } = profiles.addProfile(name);
236
222
  console.log(`Created profile "${name}" at ${dir}.`);
237
- if (Object.keys(aliasFlags).length > 0) {
238
- process.stderr.write(
239
- color.stderr.yellow('Aliases are EXPERIMENTAL — Claude may overwrite them on server re-sync.') + '\n'
240
- );
241
- console.log(` Stored aliases: ${JSON.stringify(aliasFlags)}`);
242
- }
243
223
  console.log(`Next: cc use ${name}`);
244
224
  return 0;
245
225
  }
@@ -254,33 +234,21 @@ async function cmdUse(rest) {
254
234
  err.exitCode = 1;
255
235
  throw err;
256
236
  }
257
- try {
258
- profiles.applyAlias(dir);
259
- } catch {
260
- // best-effort; never block the launch
261
- }
262
- return spawnClaude({
263
- args: passthrough,
264
- profileDir: dir,
265
- });
237
+ return spawnClaude({ args: passthrough, profileDir: dir });
266
238
  }
267
239
 
268
240
  function cmdList(rest) {
269
- const { flags } = parseFlagsStrict(rest, ['--json', '--original']);
241
+ const { flags } = parseFlagsStrict(rest, ['--json']);
270
242
  const list = profiles.listProfiles();
271
- const useOriginal = flags.has('--original');
272
243
 
273
244
  const enriched = list.map((p) => {
274
- const alias = p.alias || {};
275
- const displayEmail = useOriginal ? p.email : (alias.email || p.email);
276
245
  const storage = isLinkLike(path.join(p.dir, 'projects')) ? 'shared' : 'isolated';
277
246
  return {
278
247
  name: p.name,
279
248
  dir: p.dir,
280
249
  loggedIn: p.loggedIn,
281
- email: displayEmail,
250
+ email: p.email,
282
251
  storage,
283
- alias,
284
252
  };
285
253
  });
286
254
 
@@ -368,6 +336,16 @@ async function cmdUnlink(rest) {
368
336
  return 0;
369
337
  }
370
338
 
339
+ async function cmdDoctor(rest) {
340
+ const { flags } = parseFlagsStrict(rest, ['--fix', '--force', '--json']);
341
+ const fix = flags.has('--fix');
342
+ const force = flags.has('--force');
343
+ if (force && !fix) throw usageError('--force requires --fix');
344
+ const json = flags.has('--json');
345
+ const doctor = require('./doctor');
346
+ return doctor.runDoctor({ fix, force, json, prompt });
347
+ }
348
+
371
349
  async function cmdMigrate(rest) {
372
350
  const [src, dest, ...extra] = rest;
373
351
  if (!src || !dest) throw usageError('Usage: cc migrate <src> <dest>');
@@ -382,6 +360,11 @@ async function run(argv) {
382
360
  printHelp();
383
361
  return 0;
384
362
  }
363
+ if (action === '--version' || action === '-v') {
364
+ const { version } = require('../package.json');
365
+ process.stdout.write(version + '\n');
366
+ return 0;
367
+ }
385
368
  if (action === 'help') {
386
369
  printHelp(rest[0]);
387
370
  return 0;
@@ -401,6 +384,8 @@ async function run(argv) {
401
384
  return await cmdUnlink(rest);
402
385
  case 'remove':
403
386
  return await cmdRemove(rest);
387
+ case 'doctor':
388
+ return await cmdDoctor(rest);
404
389
  default: {
405
390
  process.stderr.write(color.stderr.red(`Unknown command: ${action}`) + '\n\n');
406
391
  printHelp();
package/lib/doctor.js ADDED
@@ -0,0 +1,375 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+
7
+ const profiles = require('./profiles');
8
+ const { findClaude, getClaudeVersion, compareVersion, MIN_VERSION } = require('./spawn');
9
+ const color = require('./color');
10
+
11
+ const GROUP_ORDER = ['Environment', 'Storage', 'Platform', 'Sanity'];
12
+
13
+ // fix: { safe, description, cmd, apply }
14
+ // safe=true → auto-applied by --fix
15
+ // safe=false → needs --fix --force, prompted y/n
16
+ // cmd → printed when fix cannot be auto-applied
17
+ // apply → async fn to execute
18
+
19
+ function mk(group, name, status, message, fix) {
20
+ return { group, name, status, message, fix: fix || null };
21
+ }
22
+
23
+ // --- Environment ---
24
+
25
+ function checkNodeVersion() {
26
+ const v = `v${process.versions.node}`;
27
+ if (parseInt(process.versions.node.split('.')[0], 10) >= 18) {
28
+ return mk('Environment', 'Node.js version', 'ok', `${v} (>=18)`);
29
+ }
30
+ return mk('Environment', 'Node.js version', 'error', `${v} — requires >=18`, {
31
+ safe: false, description: 'Install Node.js 18+', cmd: 'https://nodejs.org', apply: null,
32
+ });
33
+ }
34
+
35
+ function checkClaudeOnPath() {
36
+ const bin = findClaude();
37
+ if (bin) return mk('Environment', 'claude on PATH', 'ok', bin);
38
+ return mk('Environment', 'claude on PATH', 'error', 'not found', {
39
+ safe: false,
40
+ description: 'Install Claude Code',
41
+ cmd: 'npm install -g @anthropic-ai/claude-code',
42
+ apply: null,
43
+ });
44
+ }
45
+
46
+ function checkClaudeVersion() {
47
+ const bin = findClaude();
48
+ if (!bin) return null;
49
+ const v = getClaudeVersion(bin);
50
+ const minStr = MIN_VERSION.join('.');
51
+ if (!v) {
52
+ return mk('Environment', 'claude version', 'warn', 'could not determine', {
53
+ safe: false, description: 'Update Claude Code', cmd: 'claude update', apply: null,
54
+ });
55
+ }
56
+ if (compareVersion(v, MIN_VERSION) >= 0) return mk('Environment', 'claude version', 'ok', `${v.raw} (>=${minStr})`);
57
+ return mk('Environment', 'claude version', 'warn', `${v.raw} < ${minStr} — CLAUDE_CONFIG_DIR isolation unreliable`, {
58
+ safe: false, description: 'Update Claude Code', cmd: 'claude update', apply: null,
59
+ });
60
+ }
61
+
62
+ const CONFLICT_VARS = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN'];
63
+
64
+ function checkConflictingEnv() {
65
+ const set = CONFLICT_VARS.filter(k => process.env[k]);
66
+ if (set.length === 0) return mk('Environment', 'conflicting env vars', 'ok', 'none set');
67
+ const cmd = process.platform === 'win32'
68
+ ? set.map(k => `Remove-Item Env:${k}`).join('; ')
69
+ : set.map(k => `unset ${k}`).join('; ');
70
+ return mk('Environment', 'conflicting env vars', 'warn', `set: ${set.join(', ')}`, {
71
+ safe: false, description: 'Unset to avoid auth interference with cc use', cmd, apply: null,
72
+ });
73
+ }
74
+
75
+ function findAllOnPath(name) {
76
+ const PATH = process.env.PATH || process.env.Path || '';
77
+ const sep = process.platform === 'win32' ? ';' : ':';
78
+ const exts = process.platform === 'win32'
79
+ ? (process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').map(s => s.trim()).filter(Boolean)
80
+ : [''];
81
+ const found = [];
82
+ const seen = new Set();
83
+ for (const dir of PATH.split(sep).filter(Boolean)) {
84
+ for (const ext of exts) {
85
+ try {
86
+ const full = path.join(dir, name + ext);
87
+ if (fs.statSync(full).isFile()) {
88
+ const key = process.platform === 'win32' ? full.toLowerCase() : full;
89
+ if (!seen.has(key)) { seen.add(key); found.push(full); }
90
+ break;
91
+ }
92
+ } catch { /* skip */ }
93
+ }
94
+ }
95
+ return found;
96
+ }
97
+
98
+ function checkCcShadow() {
99
+ const all = findAllOnPath('cc');
100
+ if (all.length <= 1) {
101
+ return mk('Environment', 'cc on PATH', 'ok', all[0] || 'not found yet (expected after npm install -g)');
102
+ }
103
+ return mk('Environment', 'cc on PATH', 'warn', `multiple: ${all.join(', ')}`, {
104
+ safe: false, description: 'Remove duplicate cc entries to avoid version confusion', cmd: null, apply: null,
105
+ });
106
+ }
107
+
108
+ // --- Storage ---
109
+
110
+ function checkProfilesDirWritable() {
111
+ const base = profiles.baseDir();
112
+ if (!fs.existsSync(base)) {
113
+ return mk('Storage', '~/.claude-profiles/', 'warn', 'does not exist', {
114
+ safe: true,
115
+ description: `Create ${base}`,
116
+ cmd: null,
117
+ apply: async () => {
118
+ fs.mkdirSync(base, { recursive: true });
119
+ process.stdout.write(` Created ${base}\n`);
120
+ },
121
+ });
122
+ }
123
+ try {
124
+ fs.accessSync(base, fs.constants.W_OK);
125
+ return mk('Storage', '~/.claude-profiles/', 'ok', 'writable');
126
+ } catch {
127
+ const cmd = process.platform === 'win32'
128
+ ? `icacls "${base}" /grant "%USERNAME%:F"`
129
+ : `chmod u+w "${base}"`;
130
+ return mk('Storage', '~/.claude-profiles/', 'error', `not writable: ${base}`, {
131
+ safe: false, description: 'Fix directory permissions', cmd, apply: null,
132
+ });
133
+ }
134
+ }
135
+
136
+ function checkProfileClaudeJson(prof) {
137
+ const p = path.join(prof.dir, '.claude.json');
138
+ if (!fs.existsSync(p)) return mk('Storage', `"${prof.name}" .claude.json`, 'ok', 'not yet logged in');
139
+ try {
140
+ const raw = fs.readFileSync(p, 'utf8');
141
+ if (raw.trim()) JSON.parse(raw);
142
+ return mk('Storage', `"${prof.name}" .claude.json`, 'ok', 'valid JSON');
143
+ } catch (e) {
144
+ return mk('Storage', `"${prof.name}" .claude.json`, 'error', `parse error: ${e.message}`, {
145
+ safe: false,
146
+ description: `Back up ${path.basename(p)} and reset to {}`,
147
+ cmd: null,
148
+ apply: async () => {
149
+ const bak = `${p}.bak.${Date.now()}`;
150
+ fs.renameSync(p, bak);
151
+ fs.writeFileSync(p, '{}');
152
+ process.stdout.write(` Backed up to ${path.basename(bak)}, reset to {}\n`);
153
+ },
154
+ });
155
+ }
156
+ }
157
+
158
+ function checkProfileLinks(prof) {
159
+ const results = [];
160
+ const pp = path.join(prof.dir, 'projects');
161
+ let isLinkNode = false;
162
+ try { isLinkNode = fs.lstatSync(pp).isSymbolicLink(); } catch { return results; }
163
+ if (!isLinkNode) return results;
164
+
165
+ try {
166
+ fs.statSync(pp);
167
+ results.push(mk('Storage', `"${prof.name}" projects/ link`, 'ok', 'valid'));
168
+ return results;
169
+ } catch { /* broken */ }
170
+
171
+ const expected = path.join(profiles.sharedDir(), 'projects');
172
+ const targetExists = fs.existsSync(expected);
173
+ results.push(mk('Storage', `"${prof.name}" projects/ link`, 'error', 'broken — target missing', {
174
+ safe: targetExists,
175
+ description: targetExists ? `Recreate junction/symlink to ${expected}` : 'Target missing',
176
+ cmd: targetExists ? null : `cc link ${prof.name}`,
177
+ apply: targetExists ? async () => {
178
+ try { fs.unlinkSync(pp); } catch { try { fs.rmdirSync(pp); } catch { /* ignore */ } }
179
+ fs.symlinkSync(expected, pp, process.platform === 'win32' ? 'junction' : 'dir');
180
+ process.stdout.write(` Recreated: ${pp} -> ${expected}\n`);
181
+ } : null,
182
+ }));
183
+ return results;
184
+ }
185
+
186
+ // --- Platform ---
187
+
188
+ function checkOneDrive() {
189
+ if (process.platform !== 'win32') return null;
190
+ if (/onedrive/i.test(os.homedir()) || /onedrive/i.test(process.env.USERPROFILE || '')) {
191
+ return mk('Platform', 'OneDrive home folder', 'warn',
192
+ 'home is OneDrive-synced — can corrupt directory junctions', {
193
+ safe: false, description: 'Exclude ~/.claude-profiles from OneDrive sync', cmd: null, apply: null,
194
+ });
195
+ }
196
+ return mk('Platform', 'OneDrive home folder', 'ok', 'not OneDrive-redirected');
197
+ }
198
+
199
+ function checkWindowsAppsCc() {
200
+ if (process.platform !== 'win32') return null;
201
+ const p = path.join(process.env.LOCALAPPDATA || '', 'Microsoft', 'WindowsApps', 'cc.cmd');
202
+ if (!fs.existsSync(p)) return mk('Platform', 'WindowsApps cc.cmd', 'ok', 'not present');
203
+ return mk('Platform', 'WindowsApps cc.cmd', 'warn', `${p} may shadow npm-installed cc`, {
204
+ safe: false, description: 'Delete the stale stub', cmd: `del "${p}"`, apply: null,
205
+ });
206
+ }
207
+
208
+ function checkStaleBinCc() {
209
+ if (process.platform !== 'win32') return null;
210
+ const p = path.join(os.homedir(), 'bin', 'cc');
211
+ if (!fs.existsSync(p)) return null;
212
+ return mk('Platform', '~/bin/cc', 'warn', `${p} may be an old version shadowing npm-installed cc`, {
213
+ safe: false, description: 'Remove the old script', cmd: `Remove-Item "${p}"`, apply: null,
214
+ });
215
+ }
216
+
217
+ function checkCygpath() {
218
+ if (process.platform !== 'win32') return null;
219
+ if (!process.env.MSYSTEM && !process.env.MINGW_PREFIX) return null;
220
+ if (findAllOnPath('cygpath').length > 0) return mk('Platform', 'cygpath (Git Bash)', 'ok', 'available');
221
+ return mk('Platform', 'cygpath (Git Bash)', 'warn', 'not found — path conversion may fail', {
222
+ safe: false, description: 'Use Git for Windows which bundles cygpath', cmd: null, apply: null,
223
+ });
224
+ }
225
+
226
+ // --- Sanity ---
227
+
228
+ function checkSpawnClaude() {
229
+ const bin = findClaude();
230
+ if (!bin) return null;
231
+ const v = getClaudeVersion(bin);
232
+ if (v) return mk('Sanity', 'test-spawn claude --version', 'ok', v.raw);
233
+ return mk('Sanity', 'test-spawn claude --version', 'error', 'no version output', {
234
+ safe: false, description: 'Reinstall Claude Code', cmd: 'npm install -g @anthropic-ai/claude-code', apply: null,
235
+ });
236
+ }
237
+
238
+ // --- Collect all ---
239
+
240
+ function runChecks() {
241
+ const results = [
242
+ checkNodeVersion(),
243
+ checkClaudeOnPath(),
244
+ checkClaudeVersion(),
245
+ checkConflictingEnv(),
246
+ checkCcShadow(),
247
+ checkProfilesDirWritable(),
248
+ ];
249
+ for (const prof of profiles.listProfiles()) {
250
+ results.push(checkProfileClaudeJson(prof));
251
+ results.push(...checkProfileLinks(prof));
252
+ }
253
+ results.push(
254
+ checkOneDrive(),
255
+ checkWindowsAppsCc(),
256
+ checkStaleBinCc(),
257
+ checkCygpath(),
258
+ checkSpawnClaude(),
259
+ );
260
+ return results.filter(Boolean);
261
+ }
262
+
263
+ // --- Output formatting ---
264
+
265
+ function colorIcon(status) {
266
+ const icon = status === 'ok' ? '✓' : status === 'warn' ? '⚠' : '✗';
267
+ if (!color.colorEnabled(process.stdout)) return icon;
268
+ if (status === 'ok') return `\x1b[32m${icon}\x1b[39m`;
269
+ if (status === 'warn') return `\x1b[33m${icon}\x1b[39m`;
270
+ return `\x1b[31m${icon}\x1b[39m`;
271
+ }
272
+
273
+ function formatText(results) {
274
+ const groups = {};
275
+ for (const g of GROUP_ORDER) groups[g] = [];
276
+ for (const r of results) {
277
+ if (groups[r.group]) groups[r.group].push(r);
278
+ }
279
+
280
+ const lines = [];
281
+ for (const g of GROUP_ORDER) {
282
+ if (groups[g].length === 0) continue;
283
+ lines.push(g);
284
+ for (const r of groups[g]) {
285
+ lines.push(` ${colorIcon(r.status)} ${r.name}: ${r.message}`);
286
+ if (r.status !== 'ok' && r.fix) {
287
+ if (r.fix.cmd) {
288
+ lines.push(` → Run: ${r.fix.cmd}`);
289
+ } else if (r.fix.apply) {
290
+ lines.push(` → Run: ${r.fix.safe ? 'cc doctor --fix' : 'cc doctor --fix --force'}`);
291
+ }
292
+ }
293
+ }
294
+ lines.push('');
295
+ }
296
+ return lines.join('\n');
297
+ }
298
+
299
+ function serializeFix(fix) {
300
+ if (!fix) return null;
301
+ return { safe: fix.safe, description: fix.description || null, cmd: fix.cmd || null };
302
+ }
303
+
304
+ function formatJson(results) {
305
+ const counts = { ok: 0, warn: 0, error: 0 };
306
+ for (const r of results) counts[r.status] = (counts[r.status] || 0) + 1;
307
+ return JSON.stringify({
308
+ summary: counts,
309
+ checks: results.map(r => ({
310
+ group: r.group, name: r.name, status: r.status, message: r.message,
311
+ fix: serializeFix(r.fix),
312
+ })),
313
+ }, null, 2);
314
+ }
315
+
316
+ // --- Fix runner ---
317
+
318
+ async function applyFixes(results, force, promptFn) {
319
+ const toFix = results.filter(r => r.fix && r.fix.apply && (r.fix.safe || force));
320
+ if (toFix.length === 0) {
321
+ process.stdout.write('Nothing auto-fixable found.\n');
322
+ return;
323
+ }
324
+ for (const r of toFix) {
325
+ if (r.fix.safe) {
326
+ process.stdout.write(`Fixing: ${r.name}\n`);
327
+ await r.fix.apply();
328
+ } else {
329
+ const ans = await promptFn(`Fix "${r.name}" — ${r.fix.description || r.name}? [y/N]: `);
330
+ if (ans.trim().toLowerCase() === 'y') {
331
+ await r.fix.apply();
332
+ } else {
333
+ process.stdout.write(' Skipped.\n');
334
+ }
335
+ }
336
+ }
337
+ }
338
+
339
+ // --- Entry point ---
340
+
341
+ async function runDoctor({ fix, force, json, prompt: promptFn }) {
342
+ const results = runChecks();
343
+
344
+ if (json) {
345
+ process.stdout.write(formatJson(results) + '\n');
346
+ } else {
347
+ process.stdout.write(formatText(results));
348
+ }
349
+
350
+ if (fix) {
351
+ if (!json) process.stdout.write('Applying fixes...\n');
352
+ await applyFixes(results, force, promptFn);
353
+ } else if (!json) {
354
+ const errors = results.filter(r => r.status === 'error');
355
+ const warns = results.filter(r => r.status === 'warn');
356
+ if (errors.length === 0 && warns.length === 0) {
357
+ process.stdout.write('All checks passed.\n');
358
+ } else {
359
+ const parts = [];
360
+ if (errors.length) parts.push(`${errors.length} error${errors.length !== 1 ? 's' : ''}`);
361
+ if (warns.length) parts.push(`${warns.length} warning${warns.length !== 1 ? 's' : ''}`);
362
+ process.stdout.write(`${parts.join(', ')}.\n`);
363
+ const hasAutoFix = results.some(r => r.fix && r.fix.apply && r.fix.safe);
364
+ const hasForce = results.some(r => r.fix && r.fix.apply && !r.fix.safe);
365
+ if (hasAutoFix) process.stdout.write('Run: cc doctor --fix\n');
366
+ else if (hasForce) process.stdout.write('Run: cc doctor --fix --force\n');
367
+ }
368
+ }
369
+
370
+ if (results.some(r => r.status === 'error')) return 2;
371
+ if (results.some(r => r.status === 'warn')) return 1;
372
+ return 0;
373
+ }
374
+
375
+ module.exports = { runChecks, runDoctor };
package/lib/profiles.js CHANGED
@@ -13,14 +13,6 @@ const RESERVED_WIN = new Set([
13
13
  const MAX_NAME_LEN = 64;
14
14
  const INVALID_CHARS_RE = /[\\/:*?"<>|\x00-\x1f]/;
15
15
 
16
- const ALIAS_FILE = '.cc-alias.json';
17
- const ALLOWED_ALIAS_KEYS = ['email', 'org', 'name'];
18
- const OAUTH_FIELD_MAP = {
19
- email: 'emailAddress',
20
- org: 'organizationName',
21
- name: 'displayName',
22
- };
23
-
24
16
  function baseDir() {
25
17
  return path.join(os.homedir(), '.claude-profiles');
26
18
  }
@@ -88,31 +80,7 @@ function ensureProfileDir(name) {
88
80
  return { dir, created };
89
81
  }
90
82
 
91
- function aliasPath(dir) {
92
- return path.join(dir, ALIAS_FILE);
93
- }
94
-
95
- function readAlias(dir) {
96
- try {
97
- const raw = fs.readFileSync(aliasPath(dir), 'utf8');
98
- if (!raw.trim()) return {};
99
- const data = JSON.parse(raw);
100
- if (!data || typeof data !== 'object') return {};
101
- const out = {};
102
- for (const k of ALLOWED_ALIAS_KEYS) {
103
- if (typeof data[k] === 'string' && data[k].length > 0) out[k] = data[k];
104
- }
105
- return out;
106
- } catch {
107
- return {};
108
- }
109
- }
110
-
111
- function writeAlias(dir, data) {
112
- fs.writeFileSync(aliasPath(dir), JSON.stringify(data, null, 2));
113
- }
114
-
115
- function addProfile(name, aliasFlags) {
83
+ function addProfile(name) {
116
84
  validateProfileName(name);
117
85
  const dir = profileDir(name);
118
86
  if (fs.existsSync(dir)) {
@@ -120,57 +88,9 @@ function addProfile(name, aliasFlags) {
120
88
  e.exitCode = 1;
121
89
  throw e;
122
90
  }
123
- const alias = {};
124
- for (const [k, v] of Object.entries(aliasFlags || {})) {
125
- if (!ALLOWED_ALIAS_KEYS.includes(k)) {
126
- throw usageError(`Unknown flag: --${k}. Allowed: --email, --org, --name.`);
127
- }
128
- if (typeof v !== 'string' || v.length === 0) {
129
- throw usageError(`Flag --${k} requires a non-empty value.`);
130
- }
131
- alias[k] = v;
132
- }
133
91
  ensureBaseDir();
134
92
  fs.mkdirSync(dir);
135
- if (Object.keys(alias).length > 0) {
136
- writeAlias(dir, alias);
137
- }
138
- return { dir, alias };
139
- }
140
-
141
- function applyAlias(dir) {
142
- const alias = readAlias(dir);
143
- if (Object.keys(alias).length === 0) return false;
144
- const claudeJsonPath = path.join(dir, '.claude.json');
145
- if (!fs.existsSync(claudeJsonPath)) return false;
146
- let data;
147
- try {
148
- const raw = fs.readFileSync(claudeJsonPath, 'utf8');
149
- if (!raw.trim()) return false;
150
- data = JSON.parse(raw);
151
- } catch {
152
- return false;
153
- }
154
- if (!data || typeof data !== 'object') return false;
155
- if (!data.oauthAccount || typeof data.oauthAccount !== 'object') return false;
156
- let mutated = false;
157
- for (const [k, v] of Object.entries(alias)) {
158
- const oauthKey = OAUTH_FIELD_MAP[k];
159
- if (!oauthKey) continue;
160
- if (data.oauthAccount[oauthKey] !== v) {
161
- data.oauthAccount[oauthKey] = v;
162
- mutated = true;
163
- }
164
- }
165
- if (mutated) {
166
- try {
167
- fs.writeFileSync(claudeJsonPath, JSON.stringify(data, null, 2));
168
- return true;
169
- } catch {
170
- return false;
171
- }
172
- }
173
- return false;
93
+ return { dir };
174
94
  }
175
95
 
176
96
  function readEmail(dir) {
@@ -214,7 +134,6 @@ function listProfiles() {
214
134
  dir,
215
135
  loggedIn: hasCredentials(dir),
216
136
  email: readEmail(dir),
217
- alias: readAlias(dir),
218
137
  });
219
138
  }
220
139
  out.sort((a, b) => a.name.localeCompare(b.name));
@@ -232,9 +151,4 @@ module.exports = {
232
151
  hasCredentials,
233
152
  listProfiles,
234
153
  addProfile,
235
- readAlias,
236
- writeAlias,
237
- applyAlias,
238
- ALLOWED_ALIAS_KEYS,
239
- OAUTH_FIELD_MAP,
240
154
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cchost",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Host multiple isolated Claude Code accounts on one machine via the `cc` command.",
5
5
  "keywords": [
6
6
  "claude",
@@ -43,4 +43,4 @@
43
43
  "devDependencies": {
44
44
  "vitest": "^2.1.0"
45
45
  }
46
- }
46
+ }