ccm-account-manager 1.13.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/LICENSE +21 -0
- package/README.md +148 -0
- package/bin/ccm.js +459 -0
- package/docs/board.svg +122 -0
- package/package.json +43 -0
- package/src/compose.js +92 -0
- package/src/doctor.js +142 -0
- package/src/launch.js +71 -0
- package/src/mcp.js +165 -0
- package/src/memory.js +111 -0
- package/src/notify.js +62 -0
- package/src/oauth.js +150 -0
- package/src/paths.js +33 -0
- package/src/picker.js +10 -0
- package/src/pin.js +32 -0
- package/src/profiles.js +50 -0
- package/src/registry.js +94 -0
- package/src/sessions.js +204 -0
- package/src/shared.js +62 -0
- package/src/status.js +59 -0
- package/src/statusline.js +74 -0
- package/src/tui/app.js +800 -0
- package/src/tui/flap.js +56 -0
- package/src/tui/term.js +103 -0
- package/src/tui/theme.js +27 -0
- package/src/ui/board.html +431 -0
- package/src/ui/server.js +145 -0
- package/src/usage.js +144 -0
- package/src/util.js +107 -0
- package/src/wt.js +60 -0
package/docs/board.svg
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 880 470" font-family="ui-monospace, 'Cascadia Mono', Consolas, 'SF Mono', Menlo, monospace" font-size="14">
|
|
2
|
+
<!-- window -->
|
|
3
|
+
<rect x="0.5" y="0.5" width="879" height="469" rx="10" fill="#1A1A19" stroke="#33322E"/>
|
|
4
|
+
<circle cx="24" cy="22" r="6" fill="#3A3935"/>
|
|
5
|
+
<circle cx="44" cy="22" r="6" fill="#3A3935"/>
|
|
6
|
+
<circle cx="64" cy="22" r="6" fill="#3A3935"/>
|
|
7
|
+
<text x="440" y="27" text-anchor="middle" fill="#595750" font-size="12">ccm</text>
|
|
8
|
+
|
|
9
|
+
<!-- wordmark + clock -->
|
|
10
|
+
<text x="28" y="72" fill="#F5F3EA">█▀▀▀ █▀▀▀ █▀▄▀█</text>
|
|
11
|
+
<text x="28" y="90" fill="#595750">█ █ █ ▀ █</text>
|
|
12
|
+
<text x="28" y="108" fill="#F5F3EA">▀▀▀▀ ▀▀▀▀ ▀ ▀</text>
|
|
13
|
+
<text x="852" y="72" text-anchor="end" fill="#FAB219" font-weight="bold">14:32:07</text>
|
|
14
|
+
|
|
15
|
+
<!-- header -->
|
|
16
|
+
<text x="28" y="142" fill="#FAB219" letter-spacing="6">CLAUDE CODE ACCOUNT BOARD</text>
|
|
17
|
+
<line x1="28" y1="156" x2="852" y2="156" stroke="#4A4944" stroke-dasharray="6 3"/>
|
|
18
|
+
|
|
19
|
+
<!-- row 1: WORK (selected, most headroom) -->
|
|
20
|
+
<rect x="28" y="168" width="4" height="15" fill="#FAB219"/>
|
|
21
|
+
<circle cx="45" cy="175" r="5.5" fill="#3987E5"/>
|
|
22
|
+
<text x="58" y="180" fill="#F5F3EA" font-weight="bold">WORK</text>
|
|
23
|
+
<text x="170" y="180" fill="#898781">jawad@work.com</text>
|
|
24
|
+
<text x="430" y="180" fill="#898781">MAX</text>
|
|
25
|
+
<text x="852" y="180" text-anchor="end" fill="#0CA30C">✦ MOST HEADROOM</text>
|
|
26
|
+
|
|
27
|
+
<text x="58" y="204" fill="#898781">5H</text>
|
|
28
|
+
<rect x="95" y="193" width="18" height="13" rx="2" fill="#F5F3EA"/>
|
|
29
|
+
<rect x="120" y="193" width="18" height="13" rx="2" fill="#F5F3EA"/>
|
|
30
|
+
<rect x="145" y="193" width="18" height="13" rx="2" fill="#F5F3EA"/>
|
|
31
|
+
<rect x="170" y="193" width="18" height="13" rx="2" fill="#26251F"/>
|
|
32
|
+
<rect x="195" y="193" width="18" height="13" rx="2" fill="#26251F"/>
|
|
33
|
+
<rect x="220" y="193" width="18" height="13" rx="2" fill="#26251F"/>
|
|
34
|
+
<rect x="245" y="193" width="18" height="13" rx="2" fill="#26251F"/>
|
|
35
|
+
<rect x="270" y="193" width="18" height="13" rx="2" fill="#26251F"/>
|
|
36
|
+
<text x="300" y="204" fill="#C3C2B7">38%</text>
|
|
37
|
+
<text x="352" y="204" fill="#898781">RESETS</text>
|
|
38
|
+
<text x="418" y="204" fill="#FAB219">IN 2H 41M</text>
|
|
39
|
+
|
|
40
|
+
<text x="58" y="226" fill="#898781">WK</text>
|
|
41
|
+
<rect x="95" y="215" width="18" height="13" rx="2" fill="#F5F3EA"/>
|
|
42
|
+
<rect x="120" y="215" width="18" height="13" rx="2" fill="#26251F"/>
|
|
43
|
+
<rect x="145" y="215" width="18" height="13" rx="2" fill="#26251F"/>
|
|
44
|
+
<rect x="170" y="215" width="18" height="13" rx="2" fill="#26251F"/>
|
|
45
|
+
<rect x="195" y="215" width="18" height="13" rx="2" fill="#26251F"/>
|
|
46
|
+
<rect x="220" y="215" width="18" height="13" rx="2" fill="#26251F"/>
|
|
47
|
+
<rect x="245" y="215" width="18" height="13" rx="2" fill="#26251F"/>
|
|
48
|
+
<rect x="270" y="215" width="18" height="13" rx="2" fill="#26251F"/>
|
|
49
|
+
<text x="300" y="226" fill="#C3C2B7">12%</text>
|
|
50
|
+
<text x="352" y="226" fill="#898781">RESETS</text>
|
|
51
|
+
<text x="418" y="226" fill="#FAB219">IN 4D 9H</text>
|
|
52
|
+
|
|
53
|
+
<!-- row 2: PERSONAL (in session) -->
|
|
54
|
+
<circle cx="45" cy="261" r="5.5" fill="#D55181"/>
|
|
55
|
+
<text x="58" y="266" fill="#F5F3EA">PERSONAL</text>
|
|
56
|
+
<text x="170" y="266" fill="#898781">me@personal.dev</text>
|
|
57
|
+
<text x="430" y="266" fill="#898781">PRO</text>
|
|
58
|
+
<text x="852" y="266" text-anchor="end" fill="#0CA30C">● IN SESSION</text>
|
|
59
|
+
|
|
60
|
+
<text x="58" y="290" fill="#898781">5H</text>
|
|
61
|
+
<rect x="95" y="279" width="18" height="13" rx="2" fill="#F5F3EA"/>
|
|
62
|
+
<rect x="120" y="279" width="18" height="13" rx="2" fill="#F5F3EA"/>
|
|
63
|
+
<rect x="145" y="279" width="18" height="13" rx="2" fill="#F5F3EA"/>
|
|
64
|
+
<rect x="170" y="279" width="18" height="13" rx="2" fill="#F5F3EA"/>
|
|
65
|
+
<rect x="195" y="279" width="18" height="13" rx="2" fill="#F5F3EA"/>
|
|
66
|
+
<rect x="220" y="279" width="18" height="13" rx="2" fill="#F5F3EA"/>
|
|
67
|
+
<rect x="245" y="279" width="18" height="13" rx="2" fill="#26251F"/>
|
|
68
|
+
<rect x="270" y="279" width="18" height="13" rx="2" fill="#26251F"/>
|
|
69
|
+
<text x="300" y="290" fill="#C3C2B7">71%</text>
|
|
70
|
+
<text x="352" y="290" fill="#898781">RESETS</text>
|
|
71
|
+
<text x="418" y="290" fill="#FAB219">IN 1H 03M</text>
|
|
72
|
+
|
|
73
|
+
<text x="58" y="312" fill="#898781">WK</text>
|
|
74
|
+
<rect x="95" y="301" width="18" height="13" rx="2" fill="#F5F3EA"/>
|
|
75
|
+
<rect x="120" y="301" width="18" height="13" rx="2" fill="#F5F3EA"/>
|
|
76
|
+
<rect x="145" y="301" width="18" height="13" rx="2" fill="#F5F3EA"/>
|
|
77
|
+
<rect x="170" y="301" width="18" height="13" rx="2" fill="#F5F3EA"/>
|
|
78
|
+
<rect x="195" y="301" width="18" height="13" rx="2" fill="#26251F"/>
|
|
79
|
+
<rect x="220" y="301" width="18" height="13" rx="2" fill="#26251F"/>
|
|
80
|
+
<rect x="245" y="301" width="18" height="13" rx="2" fill="#26251F"/>
|
|
81
|
+
<rect x="270" y="301" width="18" height="13" rx="2" fill="#26251F"/>
|
|
82
|
+
<text x="300" y="312" fill="#C3C2B7">54%</text>
|
|
83
|
+
<text x="352" y="312" fill="#898781">RESETS</text>
|
|
84
|
+
<text x="418" y="312" fill="#FAB219">IN 2D 16H</text>
|
|
85
|
+
|
|
86
|
+
<!-- row 3: SIDE (full) -->
|
|
87
|
+
<circle cx="45" cy="347" r="5.5" fill="#199E70"/>
|
|
88
|
+
<text x="58" y="352" fill="#F5F3EA">SIDE</text>
|
|
89
|
+
<text x="170" y="352" fill="#898781">side@gmail.com</text>
|
|
90
|
+
<text x="430" y="352" fill="#898781">PRO</text>
|
|
91
|
+
<text x="852" y="352" text-anchor="end" fill="#D03B3B" font-weight="bold">■ FULL</text>
|
|
92
|
+
|
|
93
|
+
<text x="58" y="376" fill="#898781">5H</text>
|
|
94
|
+
<rect x="95" y="365" width="18" height="13" rx="2" fill="#D03B3B"/>
|
|
95
|
+
<rect x="120" y="365" width="18" height="13" rx="2" fill="#D03B3B"/>
|
|
96
|
+
<rect x="145" y="365" width="18" height="13" rx="2" fill="#D03B3B"/>
|
|
97
|
+
<rect x="170" y="365" width="18" height="13" rx="2" fill="#D03B3B"/>
|
|
98
|
+
<rect x="195" y="365" width="18" height="13" rx="2" fill="#D03B3B"/>
|
|
99
|
+
<rect x="220" y="365" width="18" height="13" rx="2" fill="#D03B3B"/>
|
|
100
|
+
<rect x="245" y="365" width="18" height="13" rx="2" fill="#D03B3B"/>
|
|
101
|
+
<rect x="270" y="365" width="18" height="13" rx="2" fill="#D03B3B"/>
|
|
102
|
+
<text x="300" y="376" fill="#C3C2B7">97%</text>
|
|
103
|
+
<text x="352" y="376" fill="#898781">RESETS</text>
|
|
104
|
+
<text x="418" y="376" fill="#FAB219">IN 22M</text>
|
|
105
|
+
|
|
106
|
+
<text x="58" y="398" fill="#898781">WK</text>
|
|
107
|
+
<rect x="95" y="387" width="18" height="13" rx="2" fill="#FAB219"/>
|
|
108
|
+
<rect x="120" y="387" width="18" height="13" rx="2" fill="#FAB219"/>
|
|
109
|
+
<rect x="145" y="387" width="18" height="13" rx="2" fill="#FAB219"/>
|
|
110
|
+
<rect x="170" y="387" width="18" height="13" rx="2" fill="#FAB219"/>
|
|
111
|
+
<rect x="195" y="387" width="18" height="13" rx="2" fill="#FAB219"/>
|
|
112
|
+
<rect x="220" y="387" width="18" height="13" rx="2" fill="#FAB219"/>
|
|
113
|
+
<rect x="245" y="387" width="18" height="13" rx="2" fill="#26251F"/>
|
|
114
|
+
<rect x="270" y="387" width="18" height="13" rx="2" fill="#26251F"/>
|
|
115
|
+
<text x="300" y="398" fill="#C3C2B7">80%</text>
|
|
116
|
+
<text x="352" y="398" fill="#898781">RESETS</text>
|
|
117
|
+
<text x="418" y="398" fill="#FAB219">IN 5D 2H</text>
|
|
118
|
+
|
|
119
|
+
<!-- footer -->
|
|
120
|
+
<line x1="28" y1="422" x2="852" y2="422" stroke="#4A4944" stroke-dasharray="6 3"/>
|
|
121
|
+
<text x="28" y="448"><tspan fill="#FAB219">↑↓</tspan><tspan fill="#898781"> select </tspan><tspan fill="#FAB219">enter</tspan><tspan fill="#898781"> launch </tspan><tspan fill="#FAB219">a</tspan><tspan fill="#898781"> add account </tspan><tspan fill="#FAB219">s</tspan><tspan fill="#898781"> departures </tspan><tspan fill="#FAB219">r</tspan><tspan fill="#898781"> refresh </tspan><tspan fill="#FAB219">d</tspan><tspan fill="#898781"> doctor </tspan><tspan fill="#FAB219">q</tspan><tspan fill="#898781"> quit</tspan></text>
|
|
122
|
+
</svg>
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ccm-account-manager",
|
|
3
|
+
"version": "1.13.0",
|
|
4
|
+
"description": "Claude Code account manager — unlimited named profiles, instant switching, simultaneous sessions, quota dashboard, statusline integration",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"ccm": "bin/ccm.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"src/",
|
|
12
|
+
"docs/board.svg"
|
|
13
|
+
],
|
|
14
|
+
"keywords": [
|
|
15
|
+
"claude",
|
|
16
|
+
"claude-code",
|
|
17
|
+
"anthropic",
|
|
18
|
+
"account-manager",
|
|
19
|
+
"multi-account",
|
|
20
|
+
"cli",
|
|
21
|
+
"profiles",
|
|
22
|
+
"oauth",
|
|
23
|
+
"quota",
|
|
24
|
+
"tui"
|
|
25
|
+
],
|
|
26
|
+
"homepage": "https://github.com/Jawad-Adas/ccm#readme",
|
|
27
|
+
"bugs": {
|
|
28
|
+
"url": "https://github.com/Jawad-Adas/ccm/issues"
|
|
29
|
+
},
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+https://github.com/Jawad-Adas/ccm.git"
|
|
33
|
+
},
|
|
34
|
+
"author": "Jawad Adas (https://github.com/Jawad-Adas)",
|
|
35
|
+
"license": "MIT",
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=18"
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"test": "node --test",
|
|
41
|
+
"prepublishOnly": "node --test"
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/compose.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
SHARED_DIR, SHARED_MCP, MCP_RECORD_DIR,
|
|
5
|
+
overrideSettingsPath, overrideClaudeMdPath,
|
|
6
|
+
} from './paths.js';
|
|
7
|
+
import { readJson, writeJson, deepMerge } from './util.js';
|
|
8
|
+
import { getProfile } from './registry.js';
|
|
9
|
+
import { seedFromDefault, syncMemory } from './memory.js';
|
|
10
|
+
|
|
11
|
+
function newestMtime(...files) {
|
|
12
|
+
let m = 0;
|
|
13
|
+
for (const f of files) {
|
|
14
|
+
try { m = Math.max(m, fs.statSync(f).mtimeMs); } catch {}
|
|
15
|
+
}
|
|
16
|
+
return m;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function mtimeOf(file) {
|
|
20
|
+
try { return fs.statSync(file).mtimeMs; } catch { return 0; }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Compose a profile's config from the shared layer + per-profile overrides.
|
|
24
|
+
// Settings and CLAUDE.md use a newer-mtime rule so edits made inside a session
|
|
25
|
+
// (/config) survive until the shared/override sources actually change.
|
|
26
|
+
export function composeProfile(name, dir) {
|
|
27
|
+
composeSettings(name, dir);
|
|
28
|
+
composeClaudeMd(name, dir);
|
|
29
|
+
const warnings = composeMcp(name, dir);
|
|
30
|
+
seedFromDefault();
|
|
31
|
+
warnings.push(...syncMemory(dir, getProfile(name)?.memory === 'private' ? 'private' : 'shared'));
|
|
32
|
+
return warnings;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function composeSettings(name, dir) {
|
|
36
|
+
const sharedFile = path.join(SHARED_DIR, 'settings.json');
|
|
37
|
+
const ovFile = overrideSettingsPath(name);
|
|
38
|
+
const src = newestMtime(sharedFile, ovFile);
|
|
39
|
+
if (!src || src <= mtimeOf(path.join(dir, 'settings.json'))) return;
|
|
40
|
+
writeJson(path.join(dir, 'settings.json'), deepMerge(readJson(sharedFile, {}), readJson(ovFile, {})));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function composeClaudeMd(name, dir) {
|
|
44
|
+
const sharedFile = path.join(SHARED_DIR, 'CLAUDE.md');
|
|
45
|
+
const fragFile = overrideClaudeMdPath(name);
|
|
46
|
+
const src = newestMtime(sharedFile, fragFile);
|
|
47
|
+
if (!src || src <= mtimeOf(path.join(dir, 'CLAUDE.md'))) return;
|
|
48
|
+
let content = '';
|
|
49
|
+
try { content = fs.readFileSync(sharedFile, 'utf8'); } catch {}
|
|
50
|
+
let frag = '';
|
|
51
|
+
try { frag = fs.readFileSync(fragFile, 'utf8'); } catch {}
|
|
52
|
+
if (frag.trim()) {
|
|
53
|
+
content = content.trimEnd() + `\n\n<!-- ccm: profile "${name}" additions -->\n` + frag.trim() + '\n';
|
|
54
|
+
}
|
|
55
|
+
fs.writeFileSync(path.join(dir, 'CLAUDE.md'), content);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Pure merge of shared MCP servers into a profile's .mcp.json.
|
|
59
|
+
// `injected` records which server names ccm owns in this profile; servers the
|
|
60
|
+
// profile added itself always win over a same-named shared server.
|
|
61
|
+
export function mergeMcpServers(current, shared, injected) {
|
|
62
|
+
const next = { ...current, mcpServers: { ...(current.mcpServers ?? {}) } };
|
|
63
|
+
const warnings = [];
|
|
64
|
+
const record = [];
|
|
65
|
+
const injectedSet = new Set(injected);
|
|
66
|
+
for (const name of injected) {
|
|
67
|
+
if (!(name in shared)) delete next.mcpServers[name];
|
|
68
|
+
}
|
|
69
|
+
for (const [name, def] of Object.entries(shared)) {
|
|
70
|
+
if (name in next.mcpServers && !injectedSet.has(name)) {
|
|
71
|
+
warnings.push(`mcp server "${name}" exists in this profile; keeping the profile's own version`);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
next.mcpServers[name] = def;
|
|
75
|
+
record.push(name);
|
|
76
|
+
}
|
|
77
|
+
const changed = JSON.stringify(next) !== JSON.stringify({ ...current, mcpServers: current.mcpServers ?? {} });
|
|
78
|
+
return { next, record, warnings, changed };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function composeMcp(name, dir) {
|
|
82
|
+
const shared = readJson(SHARED_MCP, {})?.mcpServers ?? {};
|
|
83
|
+
const recordFile = path.join(MCP_RECORD_DIR, `${name}.json`);
|
|
84
|
+
const injected = readJson(recordFile, []);
|
|
85
|
+
if (!Object.keys(shared).length && !injected.length) return [];
|
|
86
|
+
const mcpFile = path.join(dir, '.mcp.json');
|
|
87
|
+
const current = readJson(mcpFile, {}) ?? {};
|
|
88
|
+
const { next, record, warnings, changed } = mergeMcpServers(current, shared, injected);
|
|
89
|
+
if (changed) writeJson(mcpFile, next);
|
|
90
|
+
if (JSON.stringify(record) !== JSON.stringify(injected)) writeJson(recordFile, record);
|
|
91
|
+
return warnings;
|
|
92
|
+
}
|
package/src/doctor.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import {
|
|
5
|
+
CCM_HOME, CONFIG_PATH, SHARED_DIR, PROFILES_DIR, profileDir,
|
|
6
|
+
overrideSettingsPath, overrideClaudeMdPath,
|
|
7
|
+
} from './paths.js';
|
|
8
|
+
import { readJson, colorize, bold, dim, timeUntil } from './util.js';
|
|
9
|
+
import { listProfiles } from './registry.js';
|
|
10
|
+
import { SHARED_SUBDIRS } from './shared.js';
|
|
11
|
+
import { readOauth, fetchUsage } from './usage.js';
|
|
12
|
+
import { sameAccountValidSource } from './oauth.js';
|
|
13
|
+
import { wtDetected, wtInstalled, wtInSync } from './wt.js';
|
|
14
|
+
import { memoryStatus } from './memory.js';
|
|
15
|
+
|
|
16
|
+
const ok = (msg) => ({ level: 'ok', msg });
|
|
17
|
+
const warn = (msg) => ({ level: 'warn', msg });
|
|
18
|
+
const err = (msg) => ({ level: 'err', msg });
|
|
19
|
+
|
|
20
|
+
function claudeVersion() {
|
|
21
|
+
let r = spawnSync('claude', ['--version'], { encoding: 'utf8' });
|
|
22
|
+
if (r.error?.code === 'ENOENT') r = spawnSync('claude', ['--version'], { encoding: 'utf8', shell: true });
|
|
23
|
+
return r.status === 0 ? r.stdout.trim() : null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function checkProfile(p) {
|
|
27
|
+
const results = [];
|
|
28
|
+
const dir = profileDir(p.name);
|
|
29
|
+
if (!fs.existsSync(dir)) return [err(`profile dir missing: ${dir} — remove with: ccm remove ${p.name}`)];
|
|
30
|
+
|
|
31
|
+
const oauth = readOauth(p.name);
|
|
32
|
+
if (!oauth?.accessToken) {
|
|
33
|
+
const other = sameAccountValidSource(p.name);
|
|
34
|
+
results.push(warn(`logged out — run: ccm login ${p.name}`
|
|
35
|
+
+ (other ? ` (this account is still logged in via ${other}; that rotated the shared token)` : '')));
|
|
36
|
+
} else if (oauth.expiresAt && oauth.expiresAt < Date.now()) {
|
|
37
|
+
results.push(warn('token expired — refreshes automatically on next launch'));
|
|
38
|
+
} else if (oauth.expiresAt) {
|
|
39
|
+
results.push(ok(`token valid, expires in ${timeUntil(new Date(oauth.expiresAt).toISOString())}`));
|
|
40
|
+
}
|
|
41
|
+
if (!p.email) results.push(warn('identity unknown — launch once so ccm can read the account email'));
|
|
42
|
+
|
|
43
|
+
for (const d of SHARED_SUBDIRS) {
|
|
44
|
+
const link = path.join(dir, d);
|
|
45
|
+
let st = null;
|
|
46
|
+
try { st = fs.lstatSync(link); } catch {}
|
|
47
|
+
if (!st) {
|
|
48
|
+
try {
|
|
49
|
+
fs.symlinkSync(path.join(SHARED_DIR, d), link, 'junction');
|
|
50
|
+
results.push(ok(`re-created missing ${d} junction`));
|
|
51
|
+
} catch (e) {
|
|
52
|
+
results.push(err(`${d} junction missing and could not be created (${e.code ?? e.message})`));
|
|
53
|
+
}
|
|
54
|
+
} else if (st.isSymbolicLink()) {
|
|
55
|
+
if (!fs.existsSync(link)) results.push(err(`${d} junction is broken (target gone)`));
|
|
56
|
+
} else {
|
|
57
|
+
results.push(warn(`${d} is a plain copy, not a junction — edits are not shared`));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const lockFile = path.join(dir, 'ccm.lock');
|
|
62
|
+
const lock = readJson(lockFile, null);
|
|
63
|
+
if (lock?.pid) {
|
|
64
|
+
let alive = false;
|
|
65
|
+
try { process.kill(lock.pid, 0); alive = true; } catch {}
|
|
66
|
+
if (alive) results.push(ok('session running'));
|
|
67
|
+
else {
|
|
68
|
+
try { fs.rmSync(lockFile); results.push(ok('cleaned stale lock file')); } catch {}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const overrides = [];
|
|
73
|
+
if (fs.existsSync(overrideSettingsPath(p.name))) overrides.push('settings');
|
|
74
|
+
if (fs.existsSync(overrideClaudeMdPath(p.name))) overrides.push('CLAUDE.md');
|
|
75
|
+
if (overrides.length) results.push(ok(`overrides active: ${overrides.join(', ')}`));
|
|
76
|
+
|
|
77
|
+
const mem = memoryStatus(dir);
|
|
78
|
+
if (p.memory === 'private') results.push(ok('auto-memory: private (opted out of pooling)'));
|
|
79
|
+
else if (mem.local > 0) results.push(warn(`auto-memory: ${mem.local} project(s) pool on next launch (${mem.linked} pooled)`));
|
|
80
|
+
else if (mem.linked > 0) results.push(ok(`auto-memory: pooled across accounts (${mem.linked} project(s))`));
|
|
81
|
+
return results;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Structured results: [{group, level, msg}] — group '' = system-wide.
|
|
85
|
+
export async function collectDoctor() {
|
|
86
|
+
const entries = [];
|
|
87
|
+
const add = (group, r) => entries.push({ group, ...r });
|
|
88
|
+
|
|
89
|
+
const version = claudeVersion();
|
|
90
|
+
add('', version ? ok(`claude on PATH (${version})`) : err('claude not found on PATH — install Claude Code'));
|
|
91
|
+
add('', fs.existsSync(CCM_HOME) ? ok(`ccm home: ${CCM_HOME}`) : err(`ccm home missing: ${CCM_HOME}`));
|
|
92
|
+
add('', readJson(CONFIG_PATH, null)?.profiles ? ok('registry parses') : warn('registry missing/corrupt — will be rebuilt from profile dirs'));
|
|
93
|
+
|
|
94
|
+
const sharedSettings = readJson(path.join(SHARED_DIR, 'settings.json'), null);
|
|
95
|
+
add('', sharedSettings?.statusLine?.command === 'ccm statusline'
|
|
96
|
+
? ok('statusline integration installed')
|
|
97
|
+
: warn('statusline not installed — run: ccm statusline install'));
|
|
98
|
+
|
|
99
|
+
if (wtDetected()) {
|
|
100
|
+
add('', !wtInstalled() ? warn('Windows Terminal fragment not installed — run: ccm wt install')
|
|
101
|
+
: wtInSync() ? ok('Windows Terminal fragment in sync')
|
|
102
|
+
: warn('Windows Terminal fragment out of date — run: ccm wt install'));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const profiles = listProfiles();
|
|
106
|
+
if (!profiles.length) add('', warn('no profiles yet — run: ccm import <name>'));
|
|
107
|
+
for (const p of profiles) {
|
|
108
|
+
for (const r of checkProfile(p)) add(p.name, r);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const probe = profiles.find((p) => {
|
|
112
|
+
const o = readOauth(p.name);
|
|
113
|
+
return o?.accessToken && (!o.expiresAt || o.expiresAt > Date.now());
|
|
114
|
+
});
|
|
115
|
+
if (probe) {
|
|
116
|
+
const res = await fetchUsage(probe.name);
|
|
117
|
+
add('', res.windows ? ok('usage API reachable') : warn(`usage API: ${res.error}`));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let orphans = [];
|
|
121
|
+
try {
|
|
122
|
+
const known = new Set(profiles.map((p) => p.name));
|
|
123
|
+
orphans = fs.readdirSync(PROFILES_DIR).filter((n) => !known.has(n));
|
|
124
|
+
} catch {}
|
|
125
|
+
if (orphans.length) add('', warn(`unregistered profile dirs: ${orphans.join(', ')}`));
|
|
126
|
+
|
|
127
|
+
const failures = entries.filter((e) => e.level === 'err').length;
|
|
128
|
+
return { entries, failures };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function runDoctor() {
|
|
132
|
+
const { entries, failures } = await collectDoctor();
|
|
133
|
+
const icon = { ok: colorize('green', '✔'), warn: colorize('yellow', '!'), err: colorize('red', '✖') };
|
|
134
|
+
const lines = [];
|
|
135
|
+
let lastGroup = '';
|
|
136
|
+
for (const e of entries) {
|
|
137
|
+
if (e.group && e.group !== lastGroup) lines.push(bold(e.group));
|
|
138
|
+
lastGroup = e.group;
|
|
139
|
+
lines.push(`${icon[e.level]} ${e.group ? dim(' ') : ''}${e.msg}`);
|
|
140
|
+
}
|
|
141
|
+
return { text: lines.join('\n'), failures, entries };
|
|
142
|
+
}
|
package/src/launch.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { profileDir } from './paths.js';
|
|
5
|
+
import { readJson, writeJson, colorize, bold, dim } from './util.js';
|
|
6
|
+
import { getProfile, updateProfile, refreshIdentity } from './registry.js';
|
|
7
|
+
import { ensureShared, linkIntoProfile } from './shared.js';
|
|
8
|
+
import { composeProfile } from './compose.js';
|
|
9
|
+
import { authState, sameAccountValidSource } from './oauth.js';
|
|
10
|
+
|
|
11
|
+
// Returned by launchProfile when it refuses to start a logged-out account,
|
|
12
|
+
// so callers can tell "blocked" apart from a real Claude exit code.
|
|
13
|
+
export const LOGGED_OUT_EXIT = 4;
|
|
14
|
+
|
|
15
|
+
function lockPath(name) {
|
|
16
|
+
return path.join(profileDir(name), 'ccm.lock');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function isRunning(name) {
|
|
20
|
+
const lock = readJson(lockPath(name), null);
|
|
21
|
+
if (!lock?.pid) return false;
|
|
22
|
+
try {
|
|
23
|
+
process.kill(lock.pid, 0);
|
|
24
|
+
return true;
|
|
25
|
+
} catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Launch Claude Code bound to a profile. Blocks until the session exits.
|
|
31
|
+
// opts.cwd runs the session in another folder (resuming a session from a
|
|
32
|
+
// different project must happen in that project's directory).
|
|
33
|
+
export function launchProfile(name, args = [], opts = {}) {
|
|
34
|
+
if (!getProfile(name)) throw new Error(`unknown profile "${name}" — run: ccm list`);
|
|
35
|
+
|
|
36
|
+
// Preflight: never hand off to a silent Claude login screen. If the saved
|
|
37
|
+
// token was cleared, starting a session would just dead-end at /login — so
|
|
38
|
+
// stop with an explanation instead. `intent: 'login'` (ccm login / add) is the
|
|
39
|
+
// one path that WANTS the login screen and skips this.
|
|
40
|
+
if (opts.intent !== 'login' && authState(name) === 'logged-out') {
|
|
41
|
+
const other = sameAccountValidSource(name);
|
|
42
|
+
console.error(colorize('yellow', `\n"${name}" is logged out — its saved token was cleared, so starting a session would just drop you at Claude's login screen.`));
|
|
43
|
+
if (other) console.error(dim(`This account is still logged in via ${other}; using one account in two places rotates its single-use token and logs the other out.`));
|
|
44
|
+
console.error(`Sign back in with: ${bold(`ccm login ${name}`)}\n`);
|
|
45
|
+
return LOGGED_OUT_EXIT;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const dir = profileDir(name);
|
|
49
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
50
|
+
ensureShared();
|
|
51
|
+
for (const w of [...linkIntoProfile(dir), ...composeProfile(name, dir)]) {
|
|
52
|
+
console.error(colorize('yellow', `warn: ${w}`));
|
|
53
|
+
}
|
|
54
|
+
updateProfile(name, { lastUsed: new Date().toISOString() });
|
|
55
|
+
|
|
56
|
+
writeJson(lockPath(name), { pid: process.pid, startedAt: new Date().toISOString() });
|
|
57
|
+
const env = { ...process.env, CLAUDE_CONFIG_DIR: dir };
|
|
58
|
+
const cwd = opts.cwd && fs.existsSync(opts.cwd) ? opts.cwd : undefined;
|
|
59
|
+
let res = spawnSync('claude', args, { stdio: 'inherit', env, cwd });
|
|
60
|
+
if (res.error?.code === 'ENOENT') {
|
|
61
|
+
res = spawnSync('claude', args, { stdio: 'inherit', env, cwd, shell: true });
|
|
62
|
+
}
|
|
63
|
+
try { fs.rmSync(lockPath(name)); } catch {}
|
|
64
|
+
refreshIdentity(name);
|
|
65
|
+
|
|
66
|
+
if (res.error) {
|
|
67
|
+
console.error(colorize('red', `error: could not start "claude" (${res.error.message}). Is Claude Code installed and on PATH?`));
|
|
68
|
+
return 1;
|
|
69
|
+
}
|
|
70
|
+
return res.status ?? 0;
|
|
71
|
+
}
|
package/src/mcp.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { SHARED_MCP, profileDir, DEFAULT_CLAUDE_DIR, HOME_CLAUDE_JSON } from './paths.js';
|
|
4
|
+
import { readJson, writeJson, bold, dim, colorize } from './util.js';
|
|
5
|
+
import { listProfiles } from './registry.js';
|
|
6
|
+
|
|
7
|
+
function sharedServers() {
|
|
8
|
+
return readJson(SHARED_MCP, {})?.mcpServers ?? {};
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// The ccm-injected file inside a profile dir (legacy shared-server view).
|
|
12
|
+
function profileServers(dir) {
|
|
13
|
+
return readJson(path.join(dir, '.mcp.json'), {})?.mcpServers ?? {};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Where Claude Code actually stores a config dir's MCP servers. For the default
|
|
17
|
+
// ~/.claude account this lives at ~/.claude.json (beside, not inside, the dir).
|
|
18
|
+
export function claudeJsonPath(dir) {
|
|
19
|
+
return dir === DEFAULT_CLAUDE_DIR ? HOME_CLAUDE_JSON : path.join(dir, '.claude.json');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Claude Code keys projects by their forward-slash absolute path (even on
|
|
23
|
+
// Windows). A local-scoped server only loads when its key matches the running
|
|
24
|
+
// cwd in this exact form.
|
|
25
|
+
export function normalizeProjectKey(p) {
|
|
26
|
+
return path.resolve(p).replace(/\\/g, '/');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function writeClaudeJson(dir, obj) {
|
|
30
|
+
const file = claudeJsonPath(dir);
|
|
31
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
32
|
+
const tmp = `${file}.ccm-tmp`;
|
|
33
|
+
fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + '\n');
|
|
34
|
+
fs.renameSync(tmp, file); // atomic: never leave .claude.json half-written
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// An account's own MCP servers (from its .claude.json), each tagged with the
|
|
38
|
+
// scope Claude Code loads it at: 'user' (everywhere) or 'local' (one project,
|
|
39
|
+
// with `project` set). Shared/ccm-injected servers are not included here — they
|
|
40
|
+
// already exist in every account.
|
|
41
|
+
export function listAccountServers(name) {
|
|
42
|
+
const cj = readJson(claudeJsonPath(profileDir(name)), {}) ?? {};
|
|
43
|
+
const out = [];
|
|
44
|
+
for (const [n, def] of Object.entries(cj.mcpServers ?? {})) {
|
|
45
|
+
out.push({ name: n, def, scope: 'user' });
|
|
46
|
+
}
|
|
47
|
+
for (const [project, entry] of Object.entries(cj.projects ?? {})) {
|
|
48
|
+
for (const [n, def] of Object.entries(entry?.mcpServers ?? {})) {
|
|
49
|
+
out.push({ name: n, def, scope: 'local', project });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Copy one MCP server from account `from` into account `to`, landing it at the
|
|
56
|
+
// chosen scope in the target's .claude.json. Returns { replaced } (true when the
|
|
57
|
+
// target already had a server of that name at that scope) or { error }.
|
|
58
|
+
export function copyServer({ from, name, sourceScope, sourceProject, to, targetScope, targetProject }) {
|
|
59
|
+
const matches = listAccountServers(from).filter((s) => s.name === name);
|
|
60
|
+
const src = matches.find((s) =>
|
|
61
|
+
(!sourceScope || s.scope === sourceScope) &&
|
|
62
|
+
(sourceScope !== 'local' || !sourceProject || normalizeProjectKey(s.project) === normalizeProjectKey(sourceProject)),
|
|
63
|
+
);
|
|
64
|
+
if (!src) return { error: `no MCP server "${name}" in ${from}` };
|
|
65
|
+
const def = JSON.parse(JSON.stringify(src.def)); // independent clone
|
|
66
|
+
|
|
67
|
+
const targetDir = profileDir(to);
|
|
68
|
+
const cj = readJson(claudeJsonPath(targetDir), {}) ?? {};
|
|
69
|
+
let replaced = false;
|
|
70
|
+
let project = null;
|
|
71
|
+
if (targetScope === 'local') {
|
|
72
|
+
project = normalizeProjectKey(targetProject);
|
|
73
|
+
cj.projects = cj.projects ?? {};
|
|
74
|
+
cj.projects[project] = cj.projects[project] ?? {};
|
|
75
|
+
cj.projects[project].mcpServers = cj.projects[project].mcpServers ?? {};
|
|
76
|
+
replaced = name in cj.projects[project].mcpServers;
|
|
77
|
+
cj.projects[project].mcpServers[name] = def;
|
|
78
|
+
} else {
|
|
79
|
+
cj.mcpServers = cj.mcpServers ?? {};
|
|
80
|
+
replaced = name in cj.mcpServers;
|
|
81
|
+
cj.mcpServers[name] = def;
|
|
82
|
+
}
|
|
83
|
+
writeClaudeJson(targetDir, cj);
|
|
84
|
+
return { replaced, scope: targetScope, project };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Default the target scope/project from the source when not explicitly chosen:
|
|
88
|
+
// a local server stays local to its own project; a user server stays user-scoped.
|
|
89
|
+
export function resolveCopyTarget({ from, name, scope, project }, cwd) {
|
|
90
|
+
const matches = listAccountServers(from).filter((s) => s.name === name);
|
|
91
|
+
const src = matches.find((s) => !scope || s.scope === scope) ?? matches[0];
|
|
92
|
+
const targetScope = scope ?? (src?.scope === 'local' ? 'local' : 'user');
|
|
93
|
+
const targetProject = targetScope === 'local' ? (project ?? src?.project ?? cwd) : null;
|
|
94
|
+
return { sourceScope: src?.scope ?? null, sourceProject: src?.project ?? null, targetScope, targetProject };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// `ccm mcp copy <server> --from A --to B [--scope user|local] [--project P]`
|
|
98
|
+
export function parseMcpCopy(args) {
|
|
99
|
+
const opts = { name: null, from: null, to: null, scope: null, project: null };
|
|
100
|
+
const rest = [];
|
|
101
|
+
for (let i = 0; i < args.length; i++) {
|
|
102
|
+
const a = args[i];
|
|
103
|
+
if (a === '--from') opts.from = args[++i];
|
|
104
|
+
else if (a === '--to') opts.to = args[++i];
|
|
105
|
+
else if (a === '--scope') opts.scope = args[++i];
|
|
106
|
+
else if (a === '--project') opts.project = args[++i];
|
|
107
|
+
else rest.push(a);
|
|
108
|
+
}
|
|
109
|
+
opts.name = rest[0] ?? null;
|
|
110
|
+
return opts;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function mcpList() {
|
|
114
|
+
const shared = sharedServers();
|
|
115
|
+
const out = [bold('Shared (injected into every profile):')];
|
|
116
|
+
const names = Object.keys(shared);
|
|
117
|
+
out.push(names.length ? names.map((n) => ` ${colorize('green', '●')} ${n}`).join('\n') : dim(' (none)'));
|
|
118
|
+
for (const p of listProfiles()) {
|
|
119
|
+
const own = listAccountServers(p.name);
|
|
120
|
+
const user = own.filter((s) => s.scope === 'user').map((s) => s.name);
|
|
121
|
+
const local = own.filter((s) => s.scope === 'local');
|
|
122
|
+
const parts = [];
|
|
123
|
+
if (user.length) parts.push(`user: ${user.join(', ')}`);
|
|
124
|
+
if (local.length) parts.push(`local: ${local.map((s) => `${s.name} @ ${path.basename(s.project)}`).join(', ')}`);
|
|
125
|
+
out.push(`${bold(p.name)}: ${parts.length ? parts.join(' ') : dim('shared only')}`);
|
|
126
|
+
}
|
|
127
|
+
return out.join('\n');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Find a server definition by name: given profile first, then all profiles,
|
|
131
|
+
// then the default ~/.claude. Searches both the legacy .mcp.json and .claude.json.
|
|
132
|
+
function findServerDef(name, fromProfile) {
|
|
133
|
+
const sources = [
|
|
134
|
+
...(fromProfile ? [{ label: fromProfile, dir: profileDir(fromProfile) }] : []),
|
|
135
|
+
...listProfiles().map((p) => ({ label: p.name, dir: profileDir(p.name) })),
|
|
136
|
+
{ label: '~/.claude', dir: DEFAULT_CLAUDE_DIR },
|
|
137
|
+
];
|
|
138
|
+
for (const src of sources) {
|
|
139
|
+
const legacy = profileServers(src.dir)[name];
|
|
140
|
+
if (legacy) return { def: legacy, from: src.label };
|
|
141
|
+
const cj = readJson(claudeJsonPath(src.dir), {}) ?? {};
|
|
142
|
+
if (cj.mcpServers?.[name]) return { def: cj.mcpServers[name], from: src.label };
|
|
143
|
+
for (const entry of Object.values(cj.projects ?? {})) {
|
|
144
|
+
if (entry?.mcpServers?.[name]) return { def: entry.mcpServers[name], from: src.label };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function mcpShare(name, fromProfile) {
|
|
151
|
+
const found = findServerDef(name, fromProfile);
|
|
152
|
+
if (!found) return { error: `no MCP server "${name}" found in any profile or ~/.claude` };
|
|
153
|
+
const cfg = readJson(SHARED_MCP, {}) ?? {};
|
|
154
|
+
cfg.mcpServers = { ...(cfg.mcpServers ?? {}), [name]: found.def };
|
|
155
|
+
writeJson(SHARED_MCP, cfg);
|
|
156
|
+
return { from: found.from };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function mcpUnshare(name) {
|
|
160
|
+
const cfg = readJson(SHARED_MCP, {}) ?? {};
|
|
161
|
+
if (!cfg.mcpServers?.[name]) return { error: `"${name}" is not a shared MCP server` };
|
|
162
|
+
delete cfg.mcpServers[name];
|
|
163
|
+
writeJson(SHARED_MCP, cfg);
|
|
164
|
+
return {};
|
|
165
|
+
}
|