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 +7 -13
- package/lib/cli.js +49 -64
- package/lib/doctor.js +375 -0
- package/lib/profiles.js +2 -88
- package/package.json +2 -2
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.
|
|
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
|
|
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 [--
|
|
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.
|
|
91
|
-
3.
|
|
92
|
-
4.
|
|
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>
|
|
17
|
+
cc add <profile> Create a profile directory
|
|
18
18
|
cc use <profile> [...args] Launch Claude under a named profile
|
|
19
|
-
cc list [--
|
|
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>
|
|
33
|
+
add: `cc add <profile>
|
|
32
34
|
|
|
33
|
-
Create a new profile directory without launching Claude.
|
|
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
|
|
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 [--
|
|
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
|
|
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
|
-
|
|
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, ...
|
|
233
|
-
if (!name) throw usageError('Usage: cc add <profile>
|
|
234
|
-
|
|
235
|
-
const { dir } = profiles.addProfile(name
|
|
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
|
-
|
|
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'
|
|
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:
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
+
}
|