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/src/memory.js ADDED
@@ -0,0 +1,111 @@
1
+ // Shared auto-memory: Claude Code keeps learned facts per project inside each
2
+ // config dir (projects/<slug>/memory). ccm pools them in ~/.ccm/shared/memory
3
+ // and junction-links each profile's project memory to the pool, so what
4
+ // Claude learns about a repo on one account is known on every account.
5
+ // The memory follows the repo, not the account.
6
+
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { SHARED_DIR, DEFAULT_CLAUDE_DIR } from './paths.js';
10
+
11
+ export const SHARED_MEMORY_DIR = path.join(SHARED_DIR, 'memory');
12
+
13
+ // Copy files from src that are missing in dst or newer than dst's copy.
14
+ function mergeInto(srcDir, dstDir) {
15
+ fs.mkdirSync(dstDir, { recursive: true });
16
+ for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
17
+ const s = path.join(srcDir, entry.name);
18
+ const d = path.join(dstDir, entry.name);
19
+ if (entry.isDirectory()) { mergeInto(s, d); continue; }
20
+ if (!entry.isFile()) continue;
21
+ let dst;
22
+ try { dst = fs.statSync(d); } catch {}
23
+ if (!dst || fs.statSync(s).mtimeMs > dst.mtimeMs) fs.copyFileSync(s, d);
24
+ }
25
+ }
26
+
27
+ // One-time-per-project seeding from the untouched default ~/.claude, so the
28
+ // pool starts with what the default install already learned. ~/.claude itself
29
+ // is never linked or modified.
30
+ export function seedFromDefault() {
31
+ let slugs = [];
32
+ try { slugs = fs.readdirSync(path.join(DEFAULT_CLAUDE_DIR, 'projects')); } catch { return; }
33
+ for (const slug of slugs) {
34
+ const src = path.join(DEFAULT_CLAUDE_DIR, 'projects', slug, 'memory');
35
+ const target = path.join(SHARED_MEMORY_DIR, slug);
36
+ if (fs.existsSync(src) && !fs.existsSync(target)) mergeInto(src, target);
37
+ }
38
+ }
39
+
40
+ // Sweep a profile's project folders at launch. mode 'shared': local memory is
41
+ // merged into the pool (original kept as memory.bak) and replaced with a
42
+ // junction; projects with pooled memory get linked as soon as the profile has
43
+ // visited them. mode 'private': junctions are replaced with a real copy of
44
+ // the pool — the profile's memory forks from there.
45
+ export function syncMemory(dir, mode = 'shared') {
46
+ const warnings = [];
47
+ const projects = path.join(dir, 'projects');
48
+ let slugs = [];
49
+ try { slugs = fs.readdirSync(projects); } catch { return warnings; }
50
+ for (const slug of slugs) {
51
+ const slugDir = path.join(projects, slug);
52
+ try { if (!fs.lstatSync(slugDir).isDirectory()) continue; } catch { continue; }
53
+ const local = path.join(slugDir, 'memory');
54
+ const target = path.join(SHARED_MEMORY_DIR, slug);
55
+ let st = null;
56
+ try { st = fs.lstatSync(local); } catch {}
57
+
58
+ if (mode === 'private') {
59
+ if (st?.isSymbolicLink()) {
60
+ try {
61
+ fs.unlinkSync(local);
62
+ if (fs.existsSync(target)) fs.cpSync(target, local, { recursive: true });
63
+ } catch (e) { warnings.push(`memory: could not detach ${slug} (${e.code ?? e.message})`); }
64
+ }
65
+ continue;
66
+ }
67
+
68
+ if (st?.isSymbolicLink()) continue; // already pooled
69
+ if (st) {
70
+ // real local memory → merge into the pool, keep the original as a backup
71
+ try {
72
+ mergeInto(local, target);
73
+ const bak = path.join(slugDir, 'memory.bak');
74
+ fs.rmSync(bak, { recursive: true, force: true });
75
+ fs.renameSync(local, bak);
76
+ } catch (e) {
77
+ warnings.push(`memory: could not pool ${slug} (${e.code ?? e.message})`);
78
+ continue;
79
+ }
80
+ } else if (!fs.existsSync(target)) {
81
+ continue; // no memory anywhere for this project yet
82
+ }
83
+ try {
84
+ fs.symlinkSync(target, local, 'junction');
85
+ } catch (e) {
86
+ warnings.push(`memory: could not junction ${slug} (${e.code ?? e.message})`);
87
+ const bak = path.join(slugDir, 'memory.bak');
88
+ if (!fs.existsSync(local) && fs.existsSync(bak)) {
89
+ try { fs.renameSync(bak, local); } catch {}
90
+ }
91
+ }
92
+ }
93
+ return warnings;
94
+ }
95
+
96
+ // Doctor/status helper: how many of this profile's projects are pooled.
97
+ export function memoryStatus(dir) {
98
+ const projects = path.join(dir, 'projects');
99
+ let linked = 0;
100
+ let local = 0;
101
+ let slugs = [];
102
+ try { slugs = fs.readdirSync(projects); } catch {}
103
+ for (const slug of slugs) {
104
+ try {
105
+ const st = fs.lstatSync(path.join(projects, slug, 'memory'));
106
+ if (st.isSymbolicLink()) linked++;
107
+ else if (st.isDirectory()) local++;
108
+ } catch {}
109
+ }
110
+ return { linked, local };
111
+ }
package/src/notify.js ADDED
@@ -0,0 +1,62 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { loadConfig, saveConfig } from './registry.js';
3
+
4
+ // Usage buckets: 0 = fine (<80), 1 = warning (80-94), 2 = critical (>=95).
5
+ // A toast fires only when a window crosses UP into a bucket, or drops back to
6
+ // 0 from >=1 (quota reset → "fresh again"). No repeats while a bucket holds.
7
+ export function bucketFor(percent) {
8
+ if (percent >= 95) return 2;
9
+ if (percent >= 80) return 1;
10
+ return 0;
11
+ }
12
+
13
+ // Compare two usage caches → notification events per profile.
14
+ export function diffNotifications(prev, next) {
15
+ const events = [];
16
+ for (const [name, entry] of Object.entries(next ?? {})) {
17
+ if (!entry?.windows?.length) continue;
18
+ const prevWindows = new Map((prev?.[name]?.windows ?? []).map((w) => [w.label, w]));
19
+ const warns = [];
20
+ let fresh = false;
21
+ for (const w of entry.windows) {
22
+ const pb = bucketFor(prevWindows.get(w.label)?.percent ?? 0);
23
+ const nb = bucketFor(w.percent);
24
+ if (nb > pb) warns.push({ label: w.label, percent: Math.round(w.percent), resetsAt: w.resetsAt, bucket: nb });
25
+ else if (pb >= 1 && nb === 0) fresh = true;
26
+ }
27
+ if (warns.length) events.push({ profile: name, kind: 'warn', windows: warns });
28
+ else if (fresh) events.push({ profile: name, kind: 'fresh', windows: [] });
29
+ }
30
+ return events;
31
+ }
32
+
33
+ export function notificationsEnabled() {
34
+ return loadConfig().notifications?.enabled !== false;
35
+ }
36
+
37
+ export function setNotificationsEnabled(enabled) {
38
+ const cfg = loadConfig();
39
+ cfg.notifications = { ...(cfg.notifications ?? {}), enabled };
40
+ saveConfig(cfg);
41
+ }
42
+
43
+ // Windows toast via the WinRT API from PowerShell. Uses PowerShell's own
44
+ // AppUserModelID so no app registration is needed. Fire-and-forget.
45
+ export function sendToast(title, body) {
46
+ if (process.platform !== 'win32') {
47
+ console.error(`[notify] ${title}: ${body}`);
48
+ return;
49
+ }
50
+ const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
51
+ const xml = `<toast><visual><binding template="ToastGeneric"><text>${esc(title)}</text><text>${esc(body)}</text></binding></visual></toast>`;
52
+ const script = [
53
+ '[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null',
54
+ '[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom, ContentType = WindowsRuntime] | Out-Null',
55
+ '$xml = New-Object Windows.Data.Xml.Dom.XmlDocument',
56
+ `$xml.LoadXml('${xml.replace(/'/g, "''")}')`,
57
+ "[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe').Show([Windows.UI.Notifications.ToastNotification]::new($xml))",
58
+ ].join('; ');
59
+ spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-Command', script], {
60
+ detached: true, stdio: 'ignore', windowsHide: true,
61
+ }).unref();
62
+ }
package/src/oauth.js ADDED
@@ -0,0 +1,150 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { profileDir, DEFAULT_CLAUDE_DIR, HOME_CLAUDE_JSON } from './paths.js';
4
+ import { readJson } from './util.js';
5
+ import { listProfiles } from './registry.js';
6
+
7
+ // Claude Code's OAuth client — CLIENT_ID confirmed from the CLI binary
8
+ // (CLIENT_ID:"9d1c250a…"). The token endpoint is api.anthropic.com, verified
9
+ // by probing: it returns invalid_grant for a bad token (right endpoint) where
10
+ // claude.com/v1/oauth/token is a plain 405. ccm refreshes a profile's access
11
+ // token the same way Claude Code does, so the dashboard is accurate even for
12
+ // accounts that aren't currently running.
13
+ const TOKEN_URL = 'https://api.anthropic.com/v1/oauth/token';
14
+ const CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
15
+ const REFRESH_SKEW_MS = 60_000; // refresh a token about to expire, not just an expired one
16
+
17
+ function credsPath(name) {
18
+ return path.join(profileDir(name), '.credentials.json');
19
+ }
20
+
21
+ export function readOauth(name) {
22
+ return readJson(credsPath(name), null)?.claudeAiOauth ?? null;
23
+ }
24
+
25
+ export function isExpired(oauth, now = Date.now()) {
26
+ return !oauth?.expiresAt || oauth.expiresAt <= now + REFRESH_SKEW_MS;
27
+ }
28
+
29
+ // Whether a profile can start a session without dead-ending at Claude's login
30
+ // screen. 'logged-out' means the stored token is empty or unusable — the classic
31
+ // symptom being that the same account was live elsewhere, the (single-use)
32
+ // refresh token rotated, and Claude Code cleared these credentials on its next
33
+ // failed refresh. A present-but-expired token with a refresh token is still 'ok':
34
+ // Claude Code refreshes it on launch (and if THAT fails, the next check catches it).
35
+ export function authState(name) {
36
+ const oauth = readOauth(name);
37
+ const hasAccess = !!oauth?.accessToken;
38
+ const hasRefresh = !!oauth?.refreshToken;
39
+ if (!hasAccess) return 'logged-out';
40
+ if (isExpired(oauth) && !hasRefresh) return 'logged-out';
41
+ return 'ok';
42
+ }
43
+
44
+ // Persist rotated credentials without disturbing the rest of the file
45
+ // (mcpOAuth etc.). Write-then-rename so a crash can't leave a half-written
46
+ // credentials file that would log the account out.
47
+ function persist(name, oauth) {
48
+ const file = credsPath(name);
49
+ const full = readJson(file, {}) ?? {};
50
+ full.claudeAiOauth = oauth;
51
+ const tmp = file + '.tmp';
52
+ fs.writeFileSync(tmp, JSON.stringify(full, null, 2));
53
+ fs.renameSync(tmp, file);
54
+ }
55
+
56
+ // Exchange the refresh token for a fresh access token. Anthropic rotates
57
+ // refresh tokens, so the new one is persisted immediately. Returns the new
58
+ // oauth object, or { error } on failure (caller falls back gracefully).
59
+ export async function refreshToken(name) {
60
+ const oauth = readOauth(name);
61
+ if (!oauth?.refreshToken) return { error: 'no-refresh-token' };
62
+ try {
63
+ const ctrl = new AbortController();
64
+ const timer = setTimeout(() => ctrl.abort(), 10_000);
65
+ const res = await fetch(TOKEN_URL, {
66
+ method: 'POST',
67
+ headers: { 'content-type': 'application/json' },
68
+ body: JSON.stringify({ grant_type: 'refresh_token', refresh_token: oauth.refreshToken, client_id: CLIENT_ID }),
69
+ signal: ctrl.signal,
70
+ });
71
+ clearTimeout(timer);
72
+ if (!res.ok) return { error: res.status === 400 || res.status === 401 ? 'refresh-rejected' : `refresh-http-${res.status}` };
73
+ const data = await res.json();
74
+ if (!data.access_token) return { error: 'refresh-malformed' };
75
+ const next = {
76
+ ...oauth,
77
+ accessToken: data.access_token,
78
+ refreshToken: data.refresh_token ?? oauth.refreshToken,
79
+ expiresAt: Date.now() + (data.expires_in ?? 3600) * 1000,
80
+ scopes: data.scope ? data.scope.split(' ') : oauth.scopes,
81
+ };
82
+ persist(name, next);
83
+ return next;
84
+ } catch (e) {
85
+ return { error: e.name === 'AbortError' ? 'refresh-timeout' : 'refresh-network' };
86
+ }
87
+ }
88
+
89
+ // A non-expired oauth for the profile, refreshing if needed.
90
+ // Returns { oauth } or { error }.
91
+ export async function validOauth(name) {
92
+ const oauth = readOauth(name);
93
+ if (!oauth?.accessToken) return { error: 'no-credentials' };
94
+ if (!isExpired(oauth)) return { oauth };
95
+ if (!oauth.refreshToken) return { error: 'token-expired' };
96
+ const refreshed = await refreshToken(name);
97
+ return refreshed.error ? { error: refreshed.error } : { oauth: refreshed };
98
+ }
99
+
100
+ // Which Anthropic account a credential source belongs to (uuid preferred).
101
+ // The default ~/.claude keeps oauthAccount in ~/.claude.json (home level);
102
+ // profiles keep it inside their own config dir.
103
+ function accountIdOf(dir) {
104
+ const jsonPath = dir === DEFAULT_CLAUDE_DIR ? HOME_CLAUDE_JSON : path.join(dir, '.claude.json');
105
+ const a = readJson(jsonPath, null)?.oauthAccount;
106
+ return a?.accountUuid ?? a?.emailAddress ?? null;
107
+ }
108
+
109
+ function credentialSources() {
110
+ return [
111
+ ...listProfiles().map((p) => ({ label: p.name, dir: profileDir(p.name) })),
112
+ { label: '~/.claude', dir: DEFAULT_CLAUDE_DIR },
113
+ ];
114
+ }
115
+
116
+ // When a profile is logged out, report the SAME Anthropic account still valid in
117
+ // another source on this machine (another profile, or the ~/.claude default).
118
+ // That other login is usually what rotated the shared refresh token out from
119
+ // under this profile — surfacing it turns a mystery ("why is my logged-in
120
+ // account asking me to log in?") into an explanation. Returns its label or null.
121
+ export function sameAccountValidSource(name) {
122
+ const wantId = accountIdOf(profileDir(name));
123
+ if (!wantId) return null;
124
+ for (const src of credentialSources()) {
125
+ if (src.dir === profileDir(name) || accountIdOf(src.dir) !== wantId) continue;
126
+ const o = readJson(path.join(src.dir, '.credentials.json'), null)?.claudeAiOauth;
127
+ if (o?.accessToken && !isExpired(o)) return src.label;
128
+ }
129
+ return null;
130
+ }
131
+
132
+ // An access token that can query this profile's usage. Prefers the profile's
133
+ // own token (refreshing if needed). If that can't be revived, borrows a
134
+ // currently-valid token from any other source logged into the SAME account
135
+ // (another profile, or the ~/.claude default) — usage is per-account, so the
136
+ // number is identical. Borrowed tokens are used read-only: never refreshed or
137
+ // rotated, so a running ~/.claude session isn't disturbed.
138
+ export async function resolveToken(name) {
139
+ const own = await validOauth(name);
140
+ if (own.oauth) return { accessToken: own.oauth.accessToken };
141
+ const wantId = accountIdOf(profileDir(name));
142
+ if (wantId) {
143
+ for (const src of credentialSources()) {
144
+ if (src.dir === profileDir(name) || accountIdOf(src.dir) !== wantId) continue;
145
+ const o = readJson(path.join(src.dir, '.credentials.json'), null)?.claudeAiOauth;
146
+ if (o?.accessToken && !isExpired(o)) return { accessToken: o.accessToken, borrowedFrom: src.label };
147
+ }
148
+ }
149
+ return { error: own.error };
150
+ }
package/src/paths.js ADDED
@@ -0,0 +1,33 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+
4
+ export const CCM_HOME = process.env.CCM_HOME || path.join(os.homedir(), '.ccm');
5
+ export const PROFILES_DIR = path.join(CCM_HOME, 'profiles');
6
+ export const SHARED_DIR = path.join(CCM_HOME, 'shared');
7
+ export const CONFIG_PATH = path.join(CCM_HOME, 'config.json');
8
+ export const CACHE_DIR = path.join(CCM_HOME, 'cache');
9
+ export const USAGE_CACHE = path.join(CACHE_DIR, 'usage.json');
10
+
11
+ export const DEFAULT_CLAUDE_DIR = process.env.CCM_CLAUDE_DIR || path.join(os.homedir(), '.claude');
12
+ export const HOME_CLAUDE_JSON = path.join(os.homedir(), '.claude.json');
13
+
14
+ export const OVERRIDES_DIR = path.join(CCM_HOME, 'overrides');
15
+ export const SHARED_MCP = path.join(SHARED_DIR, 'mcp.json');
16
+ export const NOTIFY_STATE = path.join(CACHE_DIR, 'notify-state.json');
17
+ export const MCP_RECORD_DIR = path.join(CACHE_DIR, 'mcp-injected');
18
+
19
+ const LOCALAPPDATA = process.env.LOCALAPPDATA ?? path.join(os.homedir(), 'AppData', 'Local');
20
+ export const WT_FRAGMENT_DIR = path.join(LOCALAPPDATA, 'Microsoft', 'Windows Terminal', 'Fragments', 'ccm');
21
+ export const WT_FRAGMENT = path.join(WT_FRAGMENT_DIR, 'ccm.json');
22
+
23
+ export function profileDir(name) {
24
+ return path.join(PROFILES_DIR, name);
25
+ }
26
+
27
+ export function overrideSettingsPath(name) {
28
+ return path.join(OVERRIDES_DIR, `${name}.json`);
29
+ }
30
+
31
+ export function overrideClaudeMdPath(name) {
32
+ return path.join(OVERRIDES_DIR, `${name}.CLAUDE.md`);
33
+ }
package/src/picker.js ADDED
@@ -0,0 +1,10 @@
1
+ import { headroom } from './usage.js';
2
+
3
+ // Most available quota first; profiles with no data go last, original order
4
+ // kept. The split-flap board (src/tui) is the interactive picker now — this
5
+ // ordering is the shared "which account has room" logic.
6
+ export function sortByHeadroom(profiles, cache) {
7
+ return profiles.map((p, i) => ({ p, i, h: headroom(cache[p.name]) }))
8
+ .sort((a, b) => (b.h ?? -1) - (a.h ?? -1) || a.i - b.i)
9
+ .map((x) => x.p);
10
+ }
package/src/pin.js ADDED
@@ -0,0 +1,32 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ export const PIN_FILE = '.ccmrc';
5
+
6
+ // Walk up from `from` looking for a .ccmrc; first line is the profile name.
7
+ export function findPin(from = process.cwd()) {
8
+ let dir = path.resolve(from);
9
+ for (;;) {
10
+ const file = path.join(dir, PIN_FILE);
11
+ try {
12
+ const name = fs.readFileSync(file, 'utf8').split(/\r?\n/)[0].trim();
13
+ if (name) return { name, file };
14
+ } catch {}
15
+ const parent = path.dirname(dir);
16
+ if (parent === dir) return null;
17
+ dir = parent;
18
+ }
19
+ }
20
+
21
+ export function writePin(name, dir = process.cwd()) {
22
+ const file = path.join(dir, PIN_FILE);
23
+ fs.writeFileSync(file, name + '\n');
24
+ return file;
25
+ }
26
+
27
+ export function removePin(dir = process.cwd()) {
28
+ const file = path.join(dir, PIN_FILE);
29
+ if (!fs.existsSync(file)) return null;
30
+ fs.rmSync(file);
31
+ return file;
32
+ }
@@ -0,0 +1,50 @@
1
+ // Profile directory setup shared by the CLI commands and the TUI.
2
+
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { profileDir, DEFAULT_CLAUDE_DIR, HOME_CLAUDE_JSON } from './paths.js';
6
+ import { ensureShared, linkIntoProfile, unlinkShared } from './shared.js';
7
+ import { composeProfile } from './compose.js';
8
+ import { unregisterProfile } from './registry.js';
9
+ import { isRunning } from './launch.js';
10
+
11
+ // Session state that must travel with an import for --resume/--continue to
12
+ // see past conversations: transcripts, prompt history, checkpoints, tasks.
13
+ export const HISTORY_ITEMS = ['projects', 'sessions', 'session-data', 'tasks', 'file-history', 'history.jsonl'];
14
+
15
+ // Create the profile's CLAUDE_CONFIG_DIR and compose the shared layer into it.
16
+ // Returns warnings (junction fallbacks etc.) for the caller to surface.
17
+ export function prepareProfileDir(name) {
18
+ const dir = profileDir(name);
19
+ fs.mkdirSync(dir, { recursive: true });
20
+ ensureShared();
21
+ return [...linkIntoProfile(dir), ...composeProfile(name, dir)];
22
+ }
23
+
24
+ export function hasDefaultLogin() {
25
+ return fs.existsSync(path.join(DEFAULT_CLAUDE_DIR, '.credentials.json'));
26
+ }
27
+
28
+ // Delete a profile: unlink its shared junctions first (so the recursive delete
29
+ // can't follow them into ~/.ccm/shared), remove its CLAUDE_CONFIG_DIR, then drop
30
+ // it from the registry. Refuses while a session is live — its files are in use.
31
+ export function removeProfile(name) {
32
+ if (isRunning(name)) throw new Error(`profile "${name}" has a running session — close it first`);
33
+ const dir = profileDir(name);
34
+ unlinkShared(dir);
35
+ fs.rmSync(dir, { recursive: true, force: true });
36
+ unregisterProfile(name);
37
+ }
38
+
39
+ // Copy the default ~/.claude login (and optionally its session history)
40
+ // into an already-prepared profile.
41
+ export function importDefaultInto(name, { withHistory = true } = {}) {
42
+ const dir = profileDir(name);
43
+ fs.copyFileSync(path.join(DEFAULT_CLAUDE_DIR, '.credentials.json'), path.join(dir, '.credentials.json'));
44
+ if (fs.existsSync(HOME_CLAUDE_JSON)) fs.copyFileSync(HOME_CLAUDE_JSON, path.join(dir, '.claude.json'));
45
+ if (!withHistory) return;
46
+ for (const item of HISTORY_ITEMS) {
47
+ const src = path.join(DEFAULT_CLAUDE_DIR, item);
48
+ if (fs.existsSync(src)) fs.cpSync(src, path.join(dir, item), { recursive: true, force: true });
49
+ }
50
+ }
@@ -0,0 +1,94 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { CONFIG_PATH, PROFILES_DIR, profileDir } from './paths.js';
4
+ import { readJson, writeJson } from './util.js';
5
+
6
+ const PALETTE = ['cyan', 'magenta', 'yellow', 'green', 'blue', 'red'];
7
+
8
+ export function loadConfig() {
9
+ const cfg = readJson(CONFIG_PATH, null);
10
+ if (cfg?.profiles) return cfg;
11
+ // Rebuild from profile directories if the registry is missing or corrupt.
12
+ const rebuilt = { profiles: {} };
13
+ let names = [];
14
+ try {
15
+ names = fs.readdirSync(PROFILES_DIR).filter((n) =>
16
+ fs.existsSync(path.join(PROFILES_DIR, n, '.credentials.json')) ||
17
+ fs.existsSync(path.join(PROFILES_DIR, n, '.claude.json')));
18
+ } catch {}
19
+ for (const [i, name] of names.entries()) {
20
+ rebuilt.profiles[name] = {
21
+ email: null, displayName: null, organization: null, plan: null,
22
+ color: PALETTE[i % PALETTE.length], createdAt: null, lastUsed: null,
23
+ };
24
+ }
25
+ return rebuilt;
26
+ }
27
+
28
+ export function saveConfig(cfg) {
29
+ writeJson(CONFIG_PATH, cfg);
30
+ }
31
+
32
+ export function validName(name) {
33
+ return typeof name === 'string' && /^[A-Za-z0-9][A-Za-z0-9_-]{0,31}$/.test(name);
34
+ }
35
+
36
+ export function listProfiles() {
37
+ const cfg = loadConfig();
38
+ return Object.entries(cfg.profiles).map(([name, p]) => ({ name, ...p }));
39
+ }
40
+
41
+ export function getProfile(name) {
42
+ return loadConfig().profiles[name] ?? null;
43
+ }
44
+
45
+ // Command words can never be profile names — the CLI dispatches commands first.
46
+ const RESERVED = new Set([
47
+ 'add', 'import', 'list', 'ls', 'remove', 'rm', 'status', 'st', 'pick',
48
+ 'pin', 'unpin', 'statusline', 'refresh', 'help', 'version', 'doctor',
49
+ 'mcp', 'wt', 'notify', 'override', 'move-session', 'sessions', 'ui', 'board',
50
+ ]);
51
+
52
+ export function registerProfile(name) {
53
+ if (!validName(name)) throw new Error(`invalid profile name "${name}" (use letters, digits, - or _)`);
54
+ if (RESERVED.has(name)) throw new Error(`"${name}" is a ccm command — pick another profile name`);
55
+ const cfg = loadConfig();
56
+ if (cfg.profiles[name]) throw new Error(`profile "${name}" already exists`);
57
+ const used = new Set(Object.values(cfg.profiles).map((p) => p.color));
58
+ const color = PALETTE.find((c) => !used.has(c)) ?? PALETTE[Object.keys(cfg.profiles).length % PALETTE.length];
59
+ cfg.profiles[name] = {
60
+ email: null, displayName: null, organization: null, plan: null,
61
+ color, createdAt: new Date().toISOString(), lastUsed: null,
62
+ };
63
+ saveConfig(cfg);
64
+ return cfg.profiles[name];
65
+ }
66
+
67
+ export function updateProfile(name, patch) {
68
+ const cfg = loadConfig();
69
+ if (!cfg.profiles[name]) return null;
70
+ Object.assign(cfg.profiles[name], patch);
71
+ saveConfig(cfg);
72
+ return cfg.profiles[name];
73
+ }
74
+
75
+ export function unregisterProfile(name) {
76
+ const cfg = loadConfig();
77
+ delete cfg.profiles[name];
78
+ saveConfig(cfg);
79
+ }
80
+
81
+ // Pull identity (email, org, plan) out of the profile's own files after a login.
82
+ export function refreshIdentity(name) {
83
+ const dir = profileDir(name);
84
+ const acct = readJson(path.join(dir, '.claude.json'), null)?.oauthAccount;
85
+ const oauth = readJson(path.join(dir, '.credentials.json'), null)?.claudeAiOauth;
86
+ const patch = {};
87
+ if (acct) {
88
+ patch.email = acct.emailAddress ?? null;
89
+ patch.displayName = acct.displayName ?? null;
90
+ patch.organization = acct.organizationName ?? null;
91
+ }
92
+ if (oauth?.subscriptionType) patch.plan = oauth.subscriptionType;
93
+ return Object.keys(patch).length ? updateProfile(name, patch) : getProfile(name);
94
+ }