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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jawad Adas
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,148 @@
1
+ <div align="center">
2
+
3
+ # ccm
4
+
5
+ **The departure board for your Claude Code accounts.**
6
+
7
+ Unlimited named accounts. All running **at the same time**. Every quota on one board.
8
+
9
+ [![npm](https://img.shields.io/npm/v/ccm-account-manager?color=FAB219&label=npm)](https://www.npmjs.com/package/ccm-account-manager)
10
+ [![license: MIT](https://img.shields.io/badge/license-MIT-FAB219)](LICENSE)
11
+ [![node ≥ 18](https://img.shields.io/badge/node-%E2%89%A5%2018-FAB219)](package.json)
12
+ [![dependencies: 0](https://img.shields.io/badge/dependencies-0-FAB219)](package.json)
13
+ [![PRs welcome](https://img.shields.io/badge/PRs-welcome-FAB219)](https://github.com/Jawad-Adas/ccm/issues)
14
+
15
+ <img src="docs/board.svg" alt="The ccm split-flap account board: three Claude accounts with live 5-hour and weekly quota meters, reset countdowns, and status chips" width="100%">
16
+
17
+ *A real full-screen terminal app — cells flip and settle left-to-right like a Solari airport board.*
18
+ *Dark-committed: departure boards have no light mode.*
19
+
20
+ </div>
21
+
22
+ ---
23
+
24
+ ## Why
25
+
26
+ You're deep in a session. The 5-hour window hits 100% and Claude stops mid-thought. You *have* a second account — but switching means logging out, logging back in, losing your place, and doing the same dance again two hours later.
27
+
28
+ ccm makes that wall visible before you hit it. Every account is a permanent, isolated profile — **launching is switching**. The board shows which account has headroom *before* you start, and because every profile is its own `CLAUDE_CONFIG_DIR`, any number of accounts run simultaneously in different terminals with no swap step and no shared state to corrupt.
29
+
30
+ If that's a problem you have, a ⭐ helps other multi-account people find this.
31
+
32
+ ## 30-second start
33
+
34
+ ```powershell
35
+ npm install -g ccm-account-manager
36
+
37
+ ccm import work # adopt your current ~/.claude login as profile "work"
38
+ ccm add personal # opens Claude Code once — log in with account #2, then /exit
39
+ ccm # the board. pick a row, press enter.
40
+ ```
41
+
42
+ Requires Node 18+ and Claude Code on PATH. **Zero dependencies.** Your `~/.claude` and the plain `claude` command are never touched — ccm is purely additive.
43
+
44
+ <details>
45
+ <summary>Prefer to run from source?</summary>
46
+
47
+ ```powershell
48
+ git clone https://github.com/Jawad-Adas/ccm && cd ccm
49
+ npm install -g .
50
+ ```
51
+
52
+ </details>
53
+
54
+ ## What you get
55
+
56
+ - **Launching is switching.** `ccm work` starts Claude Code on the *work* account. `ccm personal` in another tab starts the second one. Both run at once.
57
+ - **The board.** Full-screen split-flap TUI: accounts as rows, quota windows as tile meters, amber reset clocks, status chips (`✦ MOST HEADROOM`, `▲ ALMOST FULL`, `■ FULL`, `✕ LOGGED OUT`). Add (`a`) and remove (`-`) accounts, resume sessions, and run health checks without leaving it.
58
+ - **A web twin.** `ccm ui` serves the same board on `127.0.0.1` and launches accounts into Windows Terminal tabs.
59
+ - **Quota-aware picker.** Accounts sort most-headroom-first, so picking the account with room is the default gesture — switching stays your explicit choice.
60
+ - **Move a session across accounts.** Hit a limit mid-conversation? `ccm move-session personal` copies the session to another account and resumes it there. The original stays put.
61
+ - **Configure once.** Shared `settings.json`, `CLAUDE.md`, skills, agents, and MCP servers are composed into every profile at launch, with per-profile overrides (`ccm override work model=opus`).
62
+ - **Copy MCP servers between accounts.** Set up an MCP server once and copy it to another account — from the board (`x`), the web dashboard, or `ccm mcp copy`. Reads the real `.claude.json` locations, so both user- and project-scoped servers show up; you choose where each copy lands (everywhere, or local to a project).
63
+ - **Shared auto-memory.** What Claude learns about a repo is pooled across accounts — the memory follows the project, not the login.
64
+ - **Inside Claude Code too.** `ccm statusline install` shows `● work · 5h 43% · wk 12%` in your session — and when you near a limit, a `→ ccm move-session <best>` escape hatch right where you're looking.
65
+ - **Windows toasts** when any account crosses 80% / 95%, and again when a limit resets ("fresh again").
66
+ - **Pin an account to a folder.** `ccm pin work` — from then on, `ccm` in that folder (and subfolders) launches *work* directly.
67
+
68
+ ## How it compares
69
+
70
+ | | log out / log in | hand-rolled `CLAUDE_CONFIG_DIR` | ccm |
71
+ |---|:---:|:---:|:---:|
72
+ | Accounts running simultaneously | ✗ | ✓ | ✓ |
73
+ | See quota before committing to an account | ✗ | ✗ | ✓ live board |
74
+ | Shared settings / skills / MCP across accounts | — | copy by hand | composed at launch |
75
+ | Move a live session to another account | ✗ | ✗ | `ccm move-session` |
76
+ | New account setup | repeat every switch | manual dirs + env vars | `ccm add name` |
77
+
78
+ ## Commands
79
+
80
+ | Command | What it does |
81
+ |---|---|
82
+ | `ccm` | Launches the pinned account for this folder, or shows the board |
83
+ | `ccm <name> [args…]` | Launch that account; args pass through to `claude` |
84
+ | `ccm ui` | The board in your browser (local only, 127.0.0.1) |
85
+ | `ccm pick` | Force the picker (ignores any pin) |
86
+ | `ccm import [name]` | Adopt the current `~/.claude` login, including session history so `--resume` sees past conversations |
87
+ | `ccm add <name>` | New profile + first login |
88
+ | `ccm login <name>` | Sign back in to a profile whose saved token was cleared |
89
+ | `ccm list` | Profiles at a glance (email, plan, running/last-used) |
90
+ | `ccm remove <name>` | Delete a profile (confirms; refuses if running) |
91
+ | `ccm status [--fresh\|--json]` | Usage bars, reset times, severity for every account |
92
+ | `ccm move-session <to> [id]` | Copy the latest session for this folder (or a given id) to another account and resume it there |
93
+ | `ccm override <name> [key=value…]` | Per-profile settings merged over the shared layer at launch |
94
+ | `ccm mcp list / share / unshare` | Shared MCP servers injected into every profile; a profile's own servers always win |
95
+ | `ccm mcp copy <name> --from <p> --to <p> [--scope user\|local]` | Copy one account's MCP server (user- or project-scoped) to another account — also on the board (`x`) and web dashboard |
96
+ | `ccm doctor` | Health check: claude binary, tokens, junctions (auto-repairs), stale locks, integrations |
97
+ | `ccm notify on\|off\|test` | Windows toasts on 80% / 95% and on limit reset |
98
+ | `ccm wt install` | One Windows Terminal profile per account, colored tabs, auto-synced |
99
+ | `ccm pin <name>` / `ccm unpin` | This folder always uses that account |
100
+ | `ccm statusline install` | Live account + quota readout inside Claude Code |
101
+
102
+ ## How it works
103
+
104
+ ```
105
+ ~/.ccm/
106
+ config.json profile registry
107
+ cache/usage.json cached quota data
108
+ profiles/<name>/ a full CLAUDE_CONFIG_DIR: credentials, sessions, history
109
+ shared/ settings.json, CLAUDE.md, agents/, skills/, commands/, hooks/
110
+ ```
111
+
112
+ - **Shared config**: directories are junction-linked into every profile (no admin needed on Windows); `settings.json` / `CLAUDE.md` / MCP servers are *composed* into each profile at launch — shared base + per-profile overrides from `~/.ccm/overrides/`. A newer-mtime rule means changes made inside a session (`/config`) survive until the shared or override sources actually change.
113
+ - **Shared auto-memory**: per-project memory (`projects/<slug>/memory`) is pooled in `~/.ccm/shared/memory` and linked into every profile at launch. Originals are kept as `memory.bak` when first pooled. Opt an account out with `ccm override <name> memory=private`.
114
+ - **Quota data** comes from the same OAuth usage endpoint Claude Code's own `/usage` uses, fetched live when the board opens. ccm refreshes each profile's access token the way Claude Code does, so numbers are accurate even for accounts that aren't running. If a profile's token can't be refreshed, ccm borrows a valid token from another source on the same account (read-only, never rotated — usage is per-account, so the number is identical). Only when no live token exists anywhere does the board fall back to the last reading, clearly marked **stale** ("as of Xh ago") — never a stale number dressed up as live.
115
+ - **Session lists match `/resume`**: subagent / SDK transcripts (the many small files a workflow fan-out spawns) are hidden, so a folder with one real chat and 400 subagent runs shows one session, not 401. Titles come from the first *real* prompt — the injected `/resume`/`/compact` caveat blocks are skipped, images collapse to a tidy `[img]`, and content-less resume-shells don't clutter the list.
116
+ - **Pinning**: `ccm pin work` writes a `.ccmrc`; `ccm` walks up from the current folder and auto-launches the nearest pin.
117
+
118
+ ## FAQ
119
+
120
+ **Is this against Anthropic's ToS?**
121
+ ccm deliberately does **not** auto-rotate accounts when quota runs out — rotating to evade rate limits is against Anthropic's ToS. ccm shows you your accounts and their real usage; switching is always your explicit choice.
122
+
123
+ **Will it touch my existing `~/.claude`?**
124
+ Never. ccm is purely additive — `ccm import` *copies* your login into a profile; the original and the plain `claude` command keep working untouched.
125
+
126
+ **A logged-in account suddenly asks me to log in — why?**
127
+ If the *same* Anthropic account is live in two places (say a ccm profile **and** your default `~/.claude`), they share one refresh token. Those tokens are single-use and rotate on every refresh, so whichever side refreshes second eventually gets rejected and Claude Code clears its own credentials. ccm detects that cleared state and, instead of dropping you at a surprise login screen, refuses the launch and points you to `ccm login <name>` — shown as a **✕ LOGGED OUT** chip on the board and flagged by `ccm doctor`. To avoid it entirely, use each account from **one** place.
128
+
129
+ **macOS / Linux?**
130
+ Built and daily-driven on Windows. The core (profiles, board, picker, quota, move-session) is plain Node with no Windows-only APIs, but other platforms are untested — Windows Terminal tabs and toast notifications are Windows-only. Reports and PRs welcome.
131
+
132
+ **What if Anthropic changes the usage endpoint?**
133
+ Only the quota columns degrade. Profiles, launching, session moves, and everything else keep working.
134
+
135
+ **Why zero dependencies?**
136
+ A tool that wraps your API credentials should be auditable in an afternoon. Every line that runs is in this repo.
137
+
138
+ ---
139
+
140
+ <div align="center">
141
+
142
+ **If ccm ever saves you from a rate-limit wall, [⭐ star it](https://github.com/Jawad-Adas/ccm)** — it's how other multi-account people find it.
143
+
144
+ Found a bug or want your platform supported? [Open an issue.](https://github.com/Jawad-Adas/ccm/issues)
145
+
146
+ MIT © [Jawad Adas](https://github.com/Jawad-Adas)
147
+
148
+ </div>
package/bin/ccm.js ADDED
@@ -0,0 +1,459 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import readline from 'node:readline';
5
+ import { fileURLToPath } from 'node:url';
6
+ import {
7
+ profileDir, DEFAULT_CLAUDE_DIR,
8
+ overrideSettingsPath, overrideClaudeMdPath,
9
+ } from '../src/paths.js';
10
+ import { colorize, bold, dim, timeAgo, readJson, writeJson, setDotted, unsetDotted } from '../src/util.js';
11
+ import {
12
+ listProfiles, getProfile, registerProfile,
13
+ refreshIdentity, validName, updateProfile,
14
+ } from '../src/registry.js';
15
+ import { prepareProfileDir, hasDefaultLogin, importDefaultInto, removeProfile } from '../src/profiles.js';
16
+ import { launchProfile, isRunning } from '../src/launch.js';
17
+ import { findPin, writePin, removePin, PIN_FILE } from '../src/pin.js';
18
+ import { gatherStatus, renderStatus } from '../src/status.js';
19
+ import { refreshAll, loadCache, bestAlternative } from '../src/usage.js';
20
+ import { statuslineMain, installStatusline } from '../src/statusline.js';
21
+ import { runTui } from '../src/tui/app.js';
22
+ import { startUi } from '../src/ui/server.js';
23
+ import { slugForPath, findSession, copySessionTo } from '../src/sessions.js';
24
+ import { diffNotifications, notificationsEnabled, setNotificationsEnabled, sendToast } from '../src/notify.js';
25
+ import { installWt, uninstallWt, wtDetected, wtInstalled, wtInSync, refreshWtIfInstalled } from '../src/wt.js';
26
+ import { runDoctor } from '../src/doctor.js';
27
+ import { mcpList, mcpShare, mcpUnshare, parseMcpCopy, resolveCopyTarget, copyServer } from '../src/mcp.js';
28
+
29
+ const VERSION = readJson(path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), {}).version ?? '?';
30
+
31
+ function fail(msg) {
32
+ console.error(colorize('red', `error: ${msg}`));
33
+ process.exit(1);
34
+ }
35
+
36
+ function ask(question) {
37
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
38
+ return new Promise((resolve) => rl.question(question, (a) => { rl.close(); resolve(a.trim()); }));
39
+ }
40
+
41
+ function setupProfileDir(name) {
42
+ for (const w of prepareProfileDir(name)) console.error(colorize('yellow', `warn: ${w}`));
43
+ return profileDir(name);
44
+ }
45
+
46
+ function uiCmd(args) {
47
+ const portIdx = args.indexOf('--port');
48
+ startUi({
49
+ port: portIdx > -1 ? Number(args[portIdx + 1]) || 7788 : 7788,
50
+ open: !args.includes('--no-open'),
51
+ });
52
+ return 0;
53
+ }
54
+
55
+ async function defaultAction() {
56
+ const profiles = listProfiles();
57
+ if (!profiles.length) {
58
+ console.log(renderNoProfiles());
59
+ return 0;
60
+ }
61
+ const pin = findPin();
62
+ if (pin) {
63
+ if (!getProfile(pin.name)) fail(`${pin.file} pins unknown profile "${pin.name}"`);
64
+ console.log(dim(`pinned → ${pin.name} (${pin.file})`));
65
+ return launchProfile(pin.name, []);
66
+ }
67
+ return pickAndLaunch(profiles);
68
+ }
69
+
70
+ async function pickAndLaunch(profiles) {
71
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
72
+ console.log(renderList(profiles));
73
+ console.log(dim('\n(non-interactive terminal — run "ccm <name>" to launch)'));
74
+ return 0;
75
+ }
76
+ return runTui();
77
+ }
78
+
79
+ async function moveSessionCmd(args) {
80
+ const positional = args.filter((a) => !a.startsWith('--'));
81
+ const to = positional[0];
82
+ const id = positional[1] ?? null;
83
+ const noLaunch = args.includes('--no-launch');
84
+ if (!to) fail('usage: ccm move-session <to-profile> [session-id] [--no-launch]');
85
+ if (!getProfile(to)) fail(`unknown profile "${to}" — run: ccm list`);
86
+ const slug = slugForPath(process.cwd());
87
+ const found = findSession(slug, id, to);
88
+ if (!found) {
89
+ fail(`no session found for this folder${id ? ` with id ${id}` : ''} — run this from the project folder you were working in`);
90
+ }
91
+ const { artifacts } = copySessionTo(found, to, slug);
92
+ console.log(`${colorize('green', '✔')} session ${bold(found.id.slice(0, 8))} (${timeAgo(new Date(found.mtime).toISOString())}, from ${found.source.label}) → ${bold(to)}${artifacts ? dim(` +${artifacts} artifact(s)`) : ''}`);
93
+ console.log(dim('The original stays on the source account.'));
94
+ if (!noLaunch && process.stdin.isTTY) {
95
+ console.log(dim(`launching: ccm ${to} --resume ${found.id.slice(0, 8)}…`));
96
+ return launchProfile(to, ['--resume', found.id]);
97
+ }
98
+ console.log(`continue with: ${bold(`ccm ${to} --resume ${found.id}`)}`);
99
+ return 0;
100
+ }
101
+
102
+ function overrideCmd(args) {
103
+ const name = args[0];
104
+ if (!name || name.startsWith('--')) fail('usage: ccm override <profile> [key=value ...] [--unset key] [--clear]');
105
+ if (!getProfile(name)) fail(`unknown profile "${name}" — run: ccm list`);
106
+ const file = overrideSettingsPath(name);
107
+ const rest = args.slice(1);
108
+ if (!rest.length) {
109
+ const cur = readJson(file, null);
110
+ console.log(`${bold(name)} settings override ${dim(`(${file})`)}`);
111
+ console.log(cur ? JSON.stringify(cur, null, 2) : dim(' (none — set with: ccm override ' + name + ' key=value)'));
112
+ const md = overrideClaudeMdPath(name);
113
+ console.log(`CLAUDE.md fragment ${dim(`(${md})`)}: ${fs.existsSync(md) ? colorize('green', 'present — appended to shared CLAUDE.md') : dim('none — create the file to add profile-specific memory')}`);
114
+ const mem = getProfile(name).memory === 'private' ? colorize('yellow', 'private — this account keeps its own') : colorize('green', 'shared — pooled across accounts per project');
115
+ console.log(`auto-memory: ${mem}`);
116
+ return 0;
117
+ }
118
+ let cfg = readJson(file, {}) ?? {};
119
+ if (rest.includes('--clear')) cfg = {};
120
+ for (let i = 0; i < rest.length; i++) {
121
+ const a = rest[i];
122
+ if (a === '--clear') continue;
123
+ if (a === '--unset') {
124
+ const key = rest[++i];
125
+ if (!key) fail('--unset needs a key');
126
+ unsetDotted(cfg, key);
127
+ continue;
128
+ }
129
+ const eq = a.indexOf('=');
130
+ if (eq < 1) fail(`expected key=value, got "${a}"`);
131
+ const key = a.slice(0, eq);
132
+ const raw = a.slice(eq + 1);
133
+ if (key === 'memory') {
134
+ // ccm's own knob, stored in the registry — not a Claude Code setting
135
+ if (raw !== 'shared' && raw !== 'private') fail('memory must be "shared" or "private"');
136
+ updateProfile(name, { memory: raw });
137
+ console.log(`${colorize('green', '✔')} auto-memory for ${bold(name)}: ${raw} ${dim('(applies on next launch)')}`);
138
+ continue;
139
+ }
140
+ let value;
141
+ try { value = JSON.parse(raw); } catch { value = raw; }
142
+ setDotted(cfg, key, value);
143
+ }
144
+ writeJson(file, cfg);
145
+ console.log(`${colorize('green', '✔')} ${file}`);
146
+ console.log(dim(`Applied on the next launch of ${name} (merged over shared settings).`));
147
+ return 0;
148
+ }
149
+
150
+ async function refreshCmd() {
151
+ const prev = loadCache();
152
+ await refreshAll(listProfiles().map((p) => p.name));
153
+ if (!notificationsEnabled()) return 0;
154
+ const events = diffNotifications(prev, loadCache()).slice(0, 3);
155
+ for (const ev of events) {
156
+ const profile = getProfile(ev.profile);
157
+ if (ev.kind === 'fresh') {
158
+ sendToast(`Claude — ${ev.profile}`, 'Quota reset — this account is fresh again.');
159
+ continue;
160
+ }
161
+ const lines = ev.windows.map((w) => `${w.label}: ${w.percent}%`).join(', ');
162
+ let tip = '';
163
+ if (ev.windows.some((w) => w.bucket === 2)) {
164
+ const alt = bestAlternative(ev.profile, listProfiles().map((p) => p.name));
165
+ if (alt && alt.headroom >= 30) tip = ` — continue elsewhere: ccm move-session ${alt.name}`;
166
+ }
167
+ sendToast(`Claude — ${ev.profile}${profile?.email ? ` (${profile.email})` : ''}`, `${lines}${tip}`);
168
+ }
169
+ return 0;
170
+ }
171
+
172
+ function notifyCmd(args) {
173
+ const sub = args[0] ?? 'status';
174
+ if (sub === 'on' || sub === 'off') {
175
+ setNotificationsEnabled(sub === 'on');
176
+ console.log(`${colorize('green', '✔')} quota notifications ${sub}`);
177
+ } else if (sub === 'test') {
178
+ sendToast('ccm — test', 'Notifications are working. You would see quota warnings like: session (5h): 95%');
179
+ console.log('test toast sent');
180
+ } else {
181
+ console.log(`quota notifications: ${notificationsEnabled() ? colorize('green', 'on') : colorize('yellow', 'off')} ${dim('(ccm notify on|off|test)')}`);
182
+ console.log(dim('Fires on crossing 80% and 95%, and when a limit resets. Checks run whenever usage is refreshed (statusline keeps this fresh while sessions are open).'));
183
+ }
184
+ return 0;
185
+ }
186
+
187
+ function wtCmd(args) {
188
+ const sub = args[0] ?? 'status';
189
+ if (sub === 'install') {
190
+ if (!wtDetected()) console.log(colorize('yellow', 'warn: Windows Terminal not detected — writing the fragment anyway'));
191
+ const file = installWt();
192
+ console.log(`${colorize('green', '✔')} ${file}`);
193
+ console.log(dim('Profiles appear in Windows Terminal\'s dropdown after you restart it. Kept in sync automatically on ccm add/remove.'));
194
+ } else if (sub === 'uninstall') {
195
+ console.log(uninstallWt() ? `${colorize('green', '✔')} fragment removed` : 'nothing installed');
196
+ } else {
197
+ console.log(!wtInstalled() ? 'not installed — run: ccm wt install'
198
+ : wtInSync() ? colorize('green', 'installed and in sync')
199
+ : colorize('yellow', 'installed but out of date — run: ccm wt install'));
200
+ }
201
+ return 0;
202
+ }
203
+
204
+ async function mcpCmd(args) {
205
+ const sub = args[0];
206
+ if (sub === 'list' || !sub) {
207
+ console.log(mcpList());
208
+ return 0;
209
+ }
210
+ if (sub === 'share') {
211
+ const name = args[1];
212
+ if (!name) fail('usage: ccm mcp share <server-name> [--from <profile>]');
213
+ const fromIdx = args.indexOf('--from');
214
+ const res = mcpShare(name, fromIdx > -1 ? args[fromIdx + 1] : null);
215
+ if (res.error) fail(res.error);
216
+ console.log(`${colorize('green', '✔')} "${name}" (from ${res.from}) is now shared — injected into every profile on its next launch`);
217
+ return 0;
218
+ }
219
+ if (sub === 'unshare') {
220
+ const name = args[1];
221
+ if (!name) fail('usage: ccm mcp unshare <server-name>');
222
+ const res = mcpUnshare(name);
223
+ if (res.error) fail(res.error);
224
+ console.log(`${colorize('green', '✔')} "${name}" unshared — removed from profiles on their next launch`);
225
+ return 0;
226
+ }
227
+ if (sub === 'copy') {
228
+ const o = parseMcpCopy(args.slice(1));
229
+ if (!o.name || !o.from || !o.to) {
230
+ fail('usage: ccm mcp copy <server> --from <profile> --to <profile> [--scope user|local] [--project <path>]');
231
+ }
232
+ if (!getProfile(o.from)) fail(`unknown profile "${o.from}"`);
233
+ if (!getProfile(o.to)) fail(`unknown profile "${o.to}"`);
234
+ if (o.scope && o.scope !== 'user' && o.scope !== 'local') fail('--scope must be "user" or "local"');
235
+ const t = resolveCopyTarget(o, process.cwd());
236
+ const res = copyServer({ from: o.from, name: o.name, sourceScope: t.sourceScope, sourceProject: t.sourceProject, to: o.to, targetScope: t.targetScope, targetProject: t.targetProject });
237
+ if (res.error) fail(res.error);
238
+ const where = res.scope === 'local' ? `local: ${res.project}` : 'user (everywhere)';
239
+ const verb = res.replaced ? 'replaced' : 'copied';
240
+ console.log(`${colorize('green', '✔')} ${verb} "${o.name}" ${o.from} → ${o.to} (${where})`);
241
+ if (res.scope === 'local') console.log(dim(' loads when you run that account in that folder'));
242
+ return 0;
243
+ }
244
+ fail('usage: ccm mcp [list | share <name> [--from <profile>] | unshare <name> | copy <name> --from <p> --to <p> [--scope user|local] [--project <path>]]');
245
+ }
246
+
247
+ function renderNoProfiles() {
248
+ return [
249
+ 'No profiles yet. Get started:',
250
+ ` ${bold('ccm import work')} adopt your current ~/.claude login as profile "work"`,
251
+ ` ${bold('ccm add personal')} create a profile and log in to another account`,
252
+ ].join('\n');
253
+ }
254
+
255
+ function renderList(profiles) {
256
+ if (!profiles.length) return renderNoProfiles();
257
+ const pin = findPin();
258
+ return profiles.map((p) => [
259
+ ' ',
260
+ colorize(p.color, '●'),
261
+ bold(p.name.padEnd(14)),
262
+ (p.email ?? '(email unknown)').padEnd(30),
263
+ dim((p.plan ?? '').padEnd(8)),
264
+ isRunning(p.name) ? colorize('green', 'RUNNING ') : dim(`${timeAgo(p.lastUsed).padEnd(9)}`),
265
+ pin?.name === p.name ? colorize('cyan', `pinned here`) : '',
266
+ ].join(' ')).join('\n');
267
+ }
268
+
269
+ async function addCmd(args) {
270
+ const name = args[0];
271
+ if (!name) fail('usage: ccm add <name>');
272
+ if (!validName(name)) fail(`invalid name "${name}" (letters, digits, - and _ only)`);
273
+ if (getProfile(name)) fail(`profile "${name}" already exists`);
274
+ registerProfile(name);
275
+ setupProfileDir(name);
276
+ console.log(`Profile ${bold(name)} created.`);
277
+ console.log('Launching Claude Code for its first login — sign in to the account you want,');
278
+ console.log(`then exit (${dim('/exit')}) to finish registration.\n`);
279
+ const code = launchProfile(name, [], { intent: 'login' });
280
+ const p = refreshIdentity(name);
281
+ if (p?.email) console.log(`\n${colorize('green', '✔')} ${bold(name)} registered as ${bold(p.email)} (${p.plan ?? 'unknown plan'})`);
282
+ else console.log(`\n${colorize('yellow', '!')} No login detected yet — launch it later with ${bold(`ccm ${name}`)} and run /login.`);
283
+ return code;
284
+ }
285
+
286
+ async function loginCmd(args) {
287
+ const name = args[0];
288
+ if (!name) fail('usage: ccm login <name>');
289
+ if (!getProfile(name)) fail(`unknown profile "${name}" — run: ccm list`);
290
+ console.log(`Signing in to ${bold(name)} — complete the login in Claude Code, then ${dim('/exit')}.\n`);
291
+ const code = launchProfile(name, args.slice(1), { intent: 'login' });
292
+ const p = refreshIdentity(name);
293
+ if (p?.email) console.log(`\n${colorize('green', '✔')} ${bold(name)} logged in as ${bold(p.email)} (${p.plan ?? 'unknown plan'})`);
294
+ else console.log(`\n${colorize('yellow', '!')} No login detected yet — re-run ${bold(`ccm login ${name}`)} and complete /login.`);
295
+ return code;
296
+ }
297
+
298
+ async function importCmd(args) {
299
+ const name = args.find((a) => !a.startsWith('-')) ?? 'main';
300
+ const withHistory = !args.includes('--no-history');
301
+ if (!validName(name)) fail(`invalid name "${name}"`);
302
+ if (getProfile(name)) fail(`profile "${name}" already exists`);
303
+ if (!hasDefaultLogin()) fail(`no login found in ${DEFAULT_CLAUDE_DIR} — run claude once and log in first`);
304
+ registerProfile(name);
305
+ setupProfileDir(name);
306
+ importDefaultInto(name, { withHistory });
307
+ if (withHistory) console.log(dim('Copied session history — past conversations show up in --resume.'));
308
+ const p = refreshIdentity(name);
309
+ console.log(`${colorize('green', '✔')} Imported current login as ${bold(name)}` + (p?.email ? ` (${p.email}, ${p.plan ?? '?'})` : ''));
310
+ console.log(dim('Your ~/.claude and the plain "claude" command are untouched.'));
311
+ return 0;
312
+ }
313
+
314
+ async function removeCmd(args) {
315
+ const name = args.find((a) => !a.startsWith('-'));
316
+ const yes = args.includes('--yes') || args.includes('-y');
317
+ if (!name) fail('usage: ccm remove <name> [--yes]');
318
+ if (!getProfile(name)) fail(`unknown profile "${name}"`);
319
+ if (isRunning(name)) fail(`profile "${name}" has a running session — close it first`);
320
+ if (!yes) {
321
+ const a = await ask(`Delete profile "${name}" and its credentials/history? [y/N] `);
322
+ if (!/^y(es)?$/i.test(a)) { console.log('aborted'); return 0; }
323
+ }
324
+ removeProfile(name);
325
+ console.log(`${colorize('green', '✔')} removed ${bold(name)}`);
326
+ return 0;
327
+ }
328
+
329
+ async function statusCmd(args) {
330
+ const rows = await gatherStatus({ maxAgeMs: args.includes('--fresh') ? 0 : undefined });
331
+ if (args.includes('--json')) {
332
+ console.log(JSON.stringify(rows, null, 2));
333
+ return 0;
334
+ }
335
+ console.log(renderStatus(rows));
336
+ return 0;
337
+ }
338
+
339
+ function pinCmd(args) {
340
+ const name = args[0];
341
+ if (!name) {
342
+ const pin = findPin();
343
+ console.log(pin ? `pinned: ${bold(pin.name)} (${pin.file})` : 'no pin in effect here');
344
+ return 0;
345
+ }
346
+ if (!getProfile(name)) fail(`unknown profile "${name}" — run: ccm list`);
347
+ const file = writePin(name);
348
+ console.log(`${colorize('green', '✔')} ${file} — "ccm" in this folder (and below) now launches ${bold(name)}`);
349
+ return 0;
350
+ }
351
+
352
+ function unpinCmd() {
353
+ const file = removePin();
354
+ console.log(file ? `${colorize('green', '✔')} removed ${file}` : `no ${PIN_FILE} in this folder`);
355
+ return 0;
356
+ }
357
+
358
+ function help() {
359
+ console.log(`${bold('ccm')} v${VERSION} — Claude Code account manager
360
+
361
+ ${bold('Usage')}
362
+ ccm pinned account here? launch it — otherwise the account board
363
+ (full-screen split-flap TUI: pick, add accounts, transfer, doctor)
364
+ ccm <name> [args…] launch Claude Code on that account (args pass through, e.g. --resume)
365
+ ccm pick open the account board (ignores pin)
366
+ ccm ui [--port N] the board as a local web page (launch accounts into WT tabs)
367
+
368
+ ${bold('Accounts')}
369
+ ccm import [name] adopt your current ~/.claude login as a profile, incl. session
370
+ history so --resume sees past chats (default: main; --no-history to skip)
371
+ ccm add <name> create a profile and log in to another account
372
+ ccm login <name> sign back in to a profile whose saved token was cleared
373
+ ccm list all profiles at a glance
374
+ ccm remove <name> delete a profile (asks first; --yes to skip)
375
+
376
+ ${bold('Sessions')}
377
+ ccm move-session <to> [id] [--no-launch]
378
+ copy the latest session for this folder (or a given id)
379
+ to another account and resume it there
380
+
381
+ ${bold('Insight')}
382
+ ccm status [--fresh] quota dashboard for every account (--json for raw data)
383
+ ccm statusline install show active account + quota inside Claude Code's statusline
384
+ ccm notify on|off|test Windows toasts when an account crosses 80%/95% or resets
385
+ ccm doctor health-check profiles, junctions, tokens, integrations
386
+
387
+ ${bold('Per-profile config')} ${dim('(merged over the shared layer at launch)')}
388
+ ccm override <name> [key=value ...] [--unset key] [--clear]
389
+ settings overrides, e.g. ccm override work model=opus theme=dark
390
+ ~/.ccm/overrides/<name>.CLAUDE.md is appended to shared CLAUDE.md
391
+ memory=private|shared opts an account out of / into the pooled
392
+ per-project auto-memory (shared is the default)
393
+ ccm mcp list shared + each account's user/local MCP servers
394
+ ccm mcp share <name> [--from <profile>] / unshare <name>
395
+ ccm mcp copy <name> --from <p> --to <p> [--scope user|local] [--project <path>]
396
+ copy one account's MCP server to another account
397
+
398
+ ${bold('Pinning & Windows Terminal')}
399
+ ccm pin <name> this folder (and below) always uses that account
400
+ ccm unpin remove the pin in this folder
401
+ ccm wt install one Windows Terminal profile per account (colored tabs)
402
+
403
+ Profiles live in ~/.ccm/profiles/<name> — each is an isolated CLAUDE_CONFIG_DIR,
404
+ so different accounts can run at the same time in different terminals.
405
+ Shared config (settings.json, CLAUDE.md, mcp.json, agents, skills, commands,
406
+ hooks) is managed once in ~/.ccm/shared and composed into every profile,
407
+ with per-profile overrides from ~/.ccm/overrides.`);
408
+ return 0;
409
+ }
410
+
411
+ const [cmd, ...rest] = process.argv.slice(2);
412
+ let exitCode = 0;
413
+ try {
414
+ switch (cmd) {
415
+ case undefined: exitCode = await defaultAction(); break;
416
+ case 'pick': exitCode = await pickAndLaunch(listProfiles()); break;
417
+ case 'add': exitCode = await addCmd(rest); refreshWtIfInstalled(); break;
418
+ case 'login': exitCode = await loginCmd(rest); refreshWtIfInstalled(); break;
419
+ case 'import': exitCode = await importCmd(rest); refreshWtIfInstalled(); break;
420
+ case 'list': case 'ls': console.log(renderList(listProfiles())); break;
421
+ case 'remove': case 'rm': exitCode = await removeCmd(rest); refreshWtIfInstalled(); break;
422
+ case 'status': case 'st': exitCode = await statusCmd(rest); break;
423
+ case 'refresh': exitCode = await refreshCmd(); break;
424
+ case 'pin': exitCode = pinCmd(rest); break;
425
+ case 'unpin': exitCode = unpinCmd(); break;
426
+ case 'ui': case 'board': exitCode = uiCmd(rest); break;
427
+ case 'move-session': exitCode = await moveSessionCmd(rest); break;
428
+ case 'override': exitCode = overrideCmd(rest); break;
429
+ case 'mcp': exitCode = await mcpCmd(rest); break;
430
+ case 'wt': exitCode = wtCmd(rest); break;
431
+ case 'notify': exitCode = notifyCmd(rest); break;
432
+ case 'doctor': {
433
+ const { text, failures } = await runDoctor();
434
+ console.log(text);
435
+ exitCode = failures ? 1 : 0;
436
+ break;
437
+ }
438
+ case 'statusline':
439
+ if (rest[0] === 'install') {
440
+ const file = installStatusline();
441
+ console.log(`${colorize('green', '✔')} statusline set in ${file}`);
442
+ console.log(dim('Applies to every profile on its next ccm launch. (Your default ~/.claude is untouched.)'));
443
+ } else await statuslineMain();
444
+ break;
445
+ case 'help': case '--help': case '-h': exitCode = help(); break;
446
+ case 'version': case '--version': case '-v': console.log(VERSION); break;
447
+ default:
448
+ if (getProfile(cmd)) exitCode = launchProfile(cmd, rest);
449
+ else {
450
+ const names = listProfiles().map((p) => p.name);
451
+ fail(`unknown command or profile "${cmd}"${names.length ? ` — profiles: ${names.join(', ')}` : ''} (see: ccm help)`);
452
+ }
453
+ }
454
+ } catch (e) {
455
+ fail(e.message);
456
+ }
457
+ // Set exitCode instead of process.exit(): force-exiting while fetch's network
458
+ // handles are still closing intermittently trips a libuv assertion on Windows.
459
+ process.exitCode = exitCode;