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.
@@ -0,0 +1,145 @@
1
+ // ccm ui — the Solari board as a local web page. Zero-dep http server bound
2
+ // to 127.0.0.1 with a Host allowlist (blocks DNS-rebinding). Launching an
3
+ // account opens a new Windows Terminal tab running `ccm <name>`.
4
+
5
+ import http from 'node:http';
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { spawn } from 'node:child_process';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { listProfiles, getProfile } from '../registry.js';
11
+ import { getUsage, refreshAll, headroom, isStale } from '../usage.js';
12
+ import { isRunning } from '../launch.js';
13
+ import { authState } from '../oauth.js';
14
+ import os from 'node:os';
15
+ import { slugForPath, allSessions, listSessions, copySessionTo } from '../sessions.js';
16
+ import { listAccountServers, copyServer, resolveCopyTarget } from '../mcp.js';
17
+ import { collectDoctor } from '../doctor.js';
18
+ import { HUES } from '../tui/theme.js';
19
+
20
+ const PAGE = path.join(path.dirname(fileURLToPath(import.meta.url)), 'board.html');
21
+
22
+ function json(res, obj, code = 200) {
23
+ res.writeHead(code, { 'content-type': 'application/json; charset=utf-8' });
24
+ res.end(JSON.stringify(obj));
25
+ }
26
+
27
+ function readBody(req) {
28
+ return new Promise((resolve) => {
29
+ let data = '';
30
+ req.on('data', (c) => { data += c; if (data.length > 65536) req.destroy(); });
31
+ req.on('end', () => resolve(data));
32
+ });
33
+ }
34
+
35
+ function shortDir(dir) {
36
+ if (!dir) return null;
37
+ return dir.startsWith(os.homedir()) ? '~' + dir.slice(os.homedir().length) : dir;
38
+ }
39
+
40
+ async function stateJson(cwd, scope = 'here') {
41
+ const profiles = await Promise.all(listProfiles().map(async (p) => {
42
+ const usage = await getUsage(p.name).catch(() => null);
43
+ return {
44
+ name: p.name, email: p.email, plan: p.plan, organization: p.organization,
45
+ color: p.color, hue: HUES[p.color] ?? '#C3C2B7',
46
+ running: isRunning(p.name), loggedOut: authState(p.name) === 'logged-out', headroom: headroom(usage),
47
+ windows: usage?.windows ?? null, usageError: usage?.error ?? null,
48
+ stale: !!usage && (!!usage.staleError || isStale(usage)),
49
+ staleError: usage?.staleError ?? null,
50
+ fetchedAt: usage?.fetchedAt ?? null,
51
+ lastUsed: p.lastUsed,
52
+ };
53
+ }));
54
+ profiles.sort((a, b) => (b.headroom ?? -1) - (a.headroom ?? -1));
55
+ const mcp = listProfiles().map((p) => ({
56
+ name: p.name, hue: HUES[p.color] ?? '#C3C2B7',
57
+ servers: listAccountServers(p.name).map((s) => ({
58
+ name: s.name, scope: s.scope, project: s.project ?? null,
59
+ projectShort: s.project ? shortDir(s.project) : null,
60
+ })),
61
+ }));
62
+ const sessions = listSessions(scope === 'all' ? null : slugForPath(cwd)).slice(0, scope === 'all' ? 200 : 20)
63
+ .map((s) => ({
64
+ id: s.id, mtime: s.mtime, title: s.title,
65
+ dir: s.cwd, dirShort: shortDir(s.cwd) ?? s.slug,
66
+ source: { kind: s.source.kind, label: s.source.label, hue: HUES[s.source.color] ?? null },
67
+ }));
68
+ return { profiles, mcp, sessions, scope, cwd, now: Date.now() };
69
+ }
70
+
71
+ function openTerminal(args, dir = null) {
72
+ const child = spawn('wt.exe', dir ? ['-d', dir, ...args] : args, { detached: true, stdio: 'ignore' });
73
+ child.on('error', () => {
74
+ spawn('cmd.exe', ['/c', 'start', '', 'cmd', '/k', ...args], {
75
+ detached: true, stdio: 'ignore', windowsHide: false, cwd: dir ?? undefined,
76
+ }).unref();
77
+ });
78
+ child.unref();
79
+ }
80
+
81
+ export function startUi({ port = 7788, open = true, cwd = process.cwd() } = {}) {
82
+ const server = http.createServer(async (req, res) => {
83
+ try {
84
+ const host = req.headers.host ?? '';
85
+ if (!/^(localhost|127\.0\.0\.1)(:\d+)?$/i.test(host)) return json(res, { error: 'forbidden' }, 403);
86
+ const u = new URL(req.url, 'http://localhost');
87
+
88
+ if (req.method === 'GET' && u.pathname === '/') {
89
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
90
+ return res.end(fs.readFileSync(PAGE));
91
+ }
92
+ if (req.method === 'GET' && u.pathname === '/api/state') {
93
+ return json(res, await stateJson(cwd, u.searchParams.get('scope') === 'all' ? 'all' : 'here'));
94
+ }
95
+ if (req.method === 'GET' && u.pathname === '/api/doctor') return json(res, await collectDoctor());
96
+
97
+ if (req.method === 'POST') {
98
+ const body = JSON.parse(await readBody(req) || '{}');
99
+ if (u.pathname === '/api/refresh') {
100
+ await refreshAll(listProfiles().map((p) => p.name));
101
+ return json(res, await stateJson(cwd, body.scope === 'all' ? 'all' : 'here'));
102
+ }
103
+ if (u.pathname === '/api/launch') {
104
+ if (!getProfile(body.name)) return json(res, { error: `unknown profile "${body.name}"` }, 400);
105
+ // A logged-out account opens `ccm login <name>` (goes straight to the
106
+ // sign-in flow) instead of `ccm <name>` (which would refuse to launch).
107
+ const args = body.login ? ['ccm', 'login', body.name] : ['ccm', body.name];
108
+ if (!body.login && body.resume) args.push('--resume', String(body.resume));
109
+ openTerminal(args, body.dir && fs.existsSync(body.dir) ? body.dir : null);
110
+ return json(res, { ok: true });
111
+ }
112
+ if (u.pathname === '/api/mcp/copy') {
113
+ if (!getProfile(body.from)) return json(res, { error: `unknown profile "${body.from}"` }, 400);
114
+ if (!getProfile(body.to)) return json(res, { error: `unknown profile "${body.to}"` }, 400);
115
+ if (body.scope && body.scope !== 'user' && body.scope !== 'local') return json(res, { error: 'scope must be user or local' }, 400);
116
+ const t = resolveCopyTarget({ from: body.from, name: body.name, scope: body.scope, project: body.project }, cwd);
117
+ const r = copyServer({ from: body.from, name: body.name, sourceScope: t.sourceScope, sourceProject: t.sourceProject, to: body.to, targetScope: t.targetScope, targetProject: t.targetProject });
118
+ if (r.error) return json(res, { error: r.error }, 400);
119
+ return json(res, { ok: true, replaced: r.replaced, scope: r.scope, project: r.project });
120
+ }
121
+ if (u.pathname === '/api/move') {
122
+ if (!getProfile(body.to)) return json(res, { error: `unknown profile "${body.to}"` }, 400);
123
+ const found = allSessions(null).find((s) => s.id === body.id);
124
+ if (!found) return json(res, { error: 'session not found' }, 404);
125
+ copySessionTo(found, body.to, found.slug);
126
+ return json(res, { ok: true, resume: found.id, to: body.to });
127
+ }
128
+ }
129
+ json(res, { error: 'not found' }, 404);
130
+ } catch (e) {
131
+ json(res, { error: e.message }, 500);
132
+ }
133
+ });
134
+
135
+ server.listen(port, '127.0.0.1', () => {
136
+ const addr = `http://127.0.0.1:${port}`;
137
+ console.log(`ccm board: ${addr} (ctrl+c to stop)`);
138
+ if (open) spawn('cmd.exe', ['/c', 'start', '', addr], { detached: true, stdio: 'ignore' }).unref();
139
+ });
140
+ server.on('error', (e) => {
141
+ console.error(e.code === 'EADDRINUSE' ? `port ${port} is busy — try: ccm ui --port ${port + 1}` : e.message);
142
+ process.exitCode = 1;
143
+ });
144
+ return server;
145
+ }
package/src/usage.js ADDED
@@ -0,0 +1,144 @@
1
+ import { USAGE_CACHE } from './paths.js';
2
+ import { readJson, writeJson } from './util.js';
3
+ import { resolveToken, readOauth } from './oauth.js';
4
+
5
+ // Undocumented endpoint used by Claude Code's own /usage command. If Anthropic
6
+ // changes it, only the quota columns degrade — everything else keeps working.
7
+ const ENDPOINT = 'https://api.anthropic.com/api/oauth/usage';
8
+
9
+ // Stale cache shown after a failed refresh/fetch is marked, never presented as
10
+ // live: anything older than this reads as "as of …" instead of a fresh number.
11
+ export const STALE_MS = 15 * 60_000;
12
+
13
+ export const DEFAULT_MAX_AGE_MS = 5 * 60_000;
14
+
15
+ export { readOauth };
16
+
17
+ // Normalize the API response into [{label, percent, resetsAt, severity, active}].
18
+ export function parseWindows(data) {
19
+ const windows = [];
20
+ const limits = Array.isArray(data?.limits) ? data.limits.filter((l) => l?.percent != null) : [];
21
+ if (limits.length) {
22
+ for (const l of limits) {
23
+ let label;
24
+ if (l.kind === 'session') label = 'session (5h)';
25
+ else if (l.kind === 'weekly_all') label = 'week (all models)';
26
+ else if (l.kind === 'weekly_scoped') label = `week (${l.scope?.model?.display_name ?? 'model'})`;
27
+ else label = String(l.kind ?? 'limit').replace(/_/g, ' ');
28
+ windows.push({
29
+ label,
30
+ percent: Math.max(0, Math.min(100, l.percent)),
31
+ resetsAt: l.resets_at ?? null,
32
+ severity: l.severity ?? 'normal',
33
+ active: !!l.is_active,
34
+ });
35
+ }
36
+ return windows;
37
+ }
38
+ const legacy = {
39
+ five_hour: 'session (5h)',
40
+ seven_day: 'week (all models)',
41
+ seven_day_opus: 'week (Opus)',
42
+ seven_day_sonnet: 'week (Sonnet)',
43
+ };
44
+ for (const [key, val] of Object.entries(data ?? {})) {
45
+ if (val && typeof val === 'object' && val.utilization != null) {
46
+ windows.push({
47
+ label: legacy[key] ?? key.replace(/_/g, ' '),
48
+ percent: Math.max(0, Math.min(100, val.utilization)),
49
+ resetsAt: val.resets_at ?? null,
50
+ severity: 'normal',
51
+ active: false,
52
+ });
53
+ }
54
+ }
55
+ return windows;
56
+ }
57
+
58
+ export async function fetchUsage(name) {
59
+ // Get a usable token: the profile's own (refreshed if needed), or a valid
60
+ // token borrowed from another source on the same account. Usage is
61
+ // per-account, so a borrowed token returns the same live numbers.
62
+ const auth = await resolveToken(name);
63
+ if (auth.error) return { error: auth.error };
64
+ try {
65
+ const ctrl = new AbortController();
66
+ const timer = setTimeout(() => ctrl.abort(), 8000);
67
+ const res = await fetch(ENDPOINT, {
68
+ headers: { Authorization: `Bearer ${auth.accessToken}`, 'anthropic-beta': 'oauth-2025-04-20' },
69
+ signal: ctrl.signal,
70
+ });
71
+ clearTimeout(timer);
72
+ if (res.status === 401 || res.status === 403) return { error: 'unauthorized' };
73
+ if (!res.ok) return { error: `http-${res.status}` };
74
+ return { windows: parseWindows(await res.json()), fetchedAt: Date.now(), borrowedFrom: auth.borrowedFrom ?? null };
75
+ } catch (e) {
76
+ return { error: e.name === 'AbortError' ? 'timeout' : 'network' };
77
+ }
78
+ }
79
+
80
+ export function loadCache() {
81
+ return readJson(USAGE_CACHE, {});
82
+ }
83
+
84
+ export function saveCache(cache) {
85
+ writeJson(USAGE_CACHE, cache);
86
+ }
87
+
88
+ export function isFresh(entry, maxAgeMs = DEFAULT_MAX_AGE_MS, now = Date.now()) {
89
+ return !!entry?.fetchedAt && now - entry.fetchedAt < maxAgeMs;
90
+ }
91
+
92
+ // Is this entry too old to present as a live reading?
93
+ export function isStale(entry, now = Date.now()) {
94
+ return !!entry?.fetchedAt && now - entry.fetchedAt >= STALE_MS;
95
+ }
96
+
97
+ // Cached usage for a profile; fetches when stale unless cacheOnly.
98
+ // On fetch failure, the last cached windows are still returned — but tagged
99
+ // with `staleError` so the UI shows them as "as of …", never as live.
100
+ export async function getUsage(name, { maxAgeMs = DEFAULT_MAX_AGE_MS, cacheOnly = false } = {}) {
101
+ const cache = loadCache();
102
+ const hit = cache[name];
103
+ if (cacheOnly || isFresh(hit, maxAgeMs)) return hit ?? null;
104
+ const fresh = await fetchUsage(name);
105
+ if (fresh.error) return hit?.windows ? { ...hit, staleError: fresh.error } : fresh;
106
+ cache[name] = fresh;
107
+ saveCache(cache);
108
+ return fresh;
109
+ }
110
+
111
+ export async function refreshAll(names, maxAgeMs = 0) {
112
+ await Promise.all(names.map((n) => getUsage(n, { maxAgeMs }).catch(() => null)));
113
+ }
114
+
115
+ // Remaining room on the account's tightest limit: 100 - max(percent), or null.
116
+ export function headroom(entry) {
117
+ if (!entry?.windows?.length) return null;
118
+ return 100 - Math.max(...entry.windows.map((w) => w.percent));
119
+ }
120
+
121
+ // The other profile with the most headroom (from cache) — used to recommend
122
+ // where to move a session when the current account nears its limit.
123
+ export function bestAlternative(currentName, profileNames, cache = loadCache()) {
124
+ let best = null;
125
+ for (const name of profileNames) {
126
+ if (name === currentName) continue;
127
+ const h = headroom(cache[name]);
128
+ if (h != null && (!best || h > best.headroom)) best = { name, headroom: h };
129
+ }
130
+ return best;
131
+ }
132
+
133
+ export const ERROR_HINTS = {
134
+ 'no-credentials': 'not logged in — launch this profile and run /login',
135
+ 'token-expired': 'token expired — launch this profile once to refresh it',
136
+ 'no-refresh-token': 'no refresh token — launch this profile and run /login',
137
+ 'refresh-rejected': 'login expired — launch this profile to sign in again',
138
+ 'refresh-malformed': 'unexpected refresh response — launch this profile to re-auth',
139
+ 'refresh-timeout': 'timed out refreshing the login token',
140
+ 'refresh-network': 'network error refreshing the login token',
141
+ unauthorized: 'token rejected — launch this profile and run /login',
142
+ timeout: 'usage API timed out',
143
+ network: 'network error reaching usage API',
144
+ };
package/src/util.js ADDED
@@ -0,0 +1,107 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ export function readJson(file, fallback = null) {
5
+ try {
6
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
7
+ } catch {
8
+ return fallback;
9
+ }
10
+ }
11
+
12
+ export function writeJson(file, obj) {
13
+ fs.mkdirSync(path.dirname(file), { recursive: true });
14
+ fs.writeFileSync(file, JSON.stringify(obj, null, 2) + '\n');
15
+ }
16
+
17
+ const COLOR_CODES = {
18
+ cyan: '36', magenta: '35', yellow: '33', green: '32',
19
+ blue: '34', red: '31', gray: '90', white: '37',
20
+ };
21
+
22
+ function ansiEnabled() {
23
+ return !process.env.NO_COLOR;
24
+ }
25
+
26
+ export function paint(code, s) {
27
+ return ansiEnabled() ? `\x1b[${code}m${s}\x1b[0m` : String(s);
28
+ }
29
+
30
+ export const colorize = (color, s) => paint(COLOR_CODES[color] ?? '0', s);
31
+ export const bold = (s) => paint('1', s);
32
+ export const dim = (s) => paint('2', s);
33
+
34
+ export function bar(percent, width = 20) {
35
+ const pct = Math.max(0, Math.min(100, percent ?? 0));
36
+ const filled = Math.round((pct / 100) * width);
37
+ return '█'.repeat(filled) + '░'.repeat(width - filled);
38
+ }
39
+
40
+ export function severityColor(percent, severity = 'normal') {
41
+ if (severity === 'exceeded' || percent >= 90) return 'red';
42
+ if (severity === 'warning' || percent >= 70) return 'yellow';
43
+ return 'green';
44
+ }
45
+
46
+ // "in how long" for a future ISO timestamp: "2h 05m", "3d 4h", "12m", "now"
47
+ export function timeUntil(iso, now = Date.now()) {
48
+ if (!iso) return '?';
49
+ const ms = new Date(iso).getTime() - now;
50
+ if (Number.isNaN(ms)) return '?';
51
+ if (ms <= 0) return 'now';
52
+ const mins = Math.floor(ms / 60_000);
53
+ const d = Math.floor(mins / 1440);
54
+ const h = Math.floor((mins % 1440) / 60);
55
+ const m = mins % 60;
56
+ if (d > 0) return `${d}d ${h}h`;
57
+ if (h > 0) return `${h}h ${String(m).padStart(2, '0')}m`;
58
+ return `${m}m`;
59
+ }
60
+
61
+ // Plain objects merge recursively; arrays and scalars replace.
62
+ export function deepMerge(base, override) {
63
+ if (override === undefined) return base;
64
+ const isObj = (v) => v && typeof v === 'object' && !Array.isArray(v);
65
+ if (isObj(base) && isObj(override)) {
66
+ const out = { ...base };
67
+ for (const [k, v] of Object.entries(override)) out[k] = deepMerge(base[k], v);
68
+ return out;
69
+ }
70
+ return override;
71
+ }
72
+
73
+ // set/unset a dotted path like "env.FOO" on a plain object
74
+ export function setDotted(obj, dotted, value) {
75
+ const keys = dotted.split('.');
76
+ let cur = obj;
77
+ for (const k of keys.slice(0, -1)) {
78
+ if (!cur[k] || typeof cur[k] !== 'object') cur[k] = {};
79
+ cur = cur[k];
80
+ }
81
+ cur[keys.at(-1)] = value;
82
+ return obj;
83
+ }
84
+
85
+ export function unsetDotted(obj, dotted) {
86
+ const keys = dotted.split('.');
87
+ let cur = obj;
88
+ for (const k of keys.slice(0, -1)) {
89
+ if (!cur?.[k] || typeof cur[k] !== 'object') return obj;
90
+ cur = cur[k];
91
+ }
92
+ delete cur[keys.at(-1)];
93
+ return obj;
94
+ }
95
+
96
+ // "how long ago" for a past ISO timestamp: "5m ago", "3h ago", "2d ago", "—"
97
+ export function timeAgo(iso, now = Date.now()) {
98
+ if (!iso) return '—';
99
+ const ms = now - new Date(iso).getTime();
100
+ if (Number.isNaN(ms)) return '—';
101
+ if (ms < 60_000) return 'just now';
102
+ const mins = Math.floor(ms / 60_000);
103
+ if (mins < 60) return `${mins}m ago`;
104
+ const h = Math.floor(mins / 60);
105
+ if (h < 24) return `${h}h ago`;
106
+ return `${Math.floor(h / 24)}d ago`;
107
+ }
package/src/wt.js ADDED
@@ -0,0 +1,60 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { WT_FRAGMENT, WT_FRAGMENT_DIR } from './paths.js';
4
+ import { readJson, writeJson } from './util.js';
5
+ import { listProfiles } from './registry.js';
6
+
7
+ // Windows Terminal "fragments" are the sanctioned way for apps to contribute
8
+ // profiles without touching the user's settings.json (which allows comments
9
+ // and is risky to rewrite). WT picks fragments up on its next launch.
10
+
11
+ const COLOR_HEX = {
12
+ cyan: '#06B6D4', magenta: '#D946EF', yellow: '#EAB308',
13
+ green: '#22C55E', blue: '#3B82F6', red: '#EF4444',
14
+ };
15
+
16
+ export function buildFragment(profiles) {
17
+ return {
18
+ profiles: profiles.map((p) => ({
19
+ name: `Claude — ${p.name}`,
20
+ commandline: `ccm ${p.name}`,
21
+ tabColor: COLOR_HEX[p.color] ?? '#7C3AED',
22
+ suppressApplicationTitle: false,
23
+ })),
24
+ };
25
+ }
26
+
27
+ export function wtDetected() {
28
+ if (fs.existsSync(path.dirname(path.dirname(WT_FRAGMENT_DIR)))) return true;
29
+ // Store-packaged WT has no "Windows Terminal" folder under %LOCALAPPDATA%\Microsoft
30
+ // until first settings write — detect the package (or wt.exe shim) instead.
31
+ const local = path.dirname(path.dirname(path.dirname(WT_FRAGMENT_DIR)));
32
+ try {
33
+ if (fs.readdirSync(path.join(local, 'Packages')).some((n) => n.startsWith('Microsoft.WindowsTerminal_'))) return true;
34
+ } catch {}
35
+ return fs.existsSync(path.join(local, 'Microsoft', 'WindowsApps', 'wt.exe'));
36
+ }
37
+
38
+ export function installWt() {
39
+ writeJson(WT_FRAGMENT, buildFragment(listProfiles()));
40
+ return WT_FRAGMENT;
41
+ }
42
+
43
+ export function uninstallWt() {
44
+ if (!fs.existsSync(WT_FRAGMENT_DIR)) return false;
45
+ fs.rmSync(WT_FRAGMENT_DIR, { recursive: true, force: true });
46
+ return true;
47
+ }
48
+
49
+ export function wtInstalled() {
50
+ return fs.existsSync(WT_FRAGMENT);
51
+ }
52
+
53
+ export function wtInSync() {
54
+ return JSON.stringify(readJson(WT_FRAGMENT, null)) === JSON.stringify(buildFragment(listProfiles()));
55
+ }
56
+
57
+ // Keep the fragment current when profiles change, but only if it was installed.
58
+ export function refreshWtIfInstalled() {
59
+ if (wtInstalled()) installWt();
60
+ }