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/src/sessions.js
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { profileDir, DEFAULT_CLAUDE_DIR } from './paths.js';
|
|
4
|
+
import { listProfiles } from './registry.js';
|
|
5
|
+
|
|
6
|
+
// Claude Code names each project's transcript folder after the absolute path
|
|
7
|
+
// with every non-alphanumeric character replaced by "-".
|
|
8
|
+
export function slugForPath(p) {
|
|
9
|
+
return path.resolve(p).replace(/[^A-Za-z0-9]/g, '-');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Directories that hold per-session artifacts keyed by session UUID.
|
|
13
|
+
const ARTIFACT_DIRS = ['file-history', 'sessions', 'session-data', 'tasks', 'todos'];
|
|
14
|
+
|
|
15
|
+
export function listTranscripts(baseDir, slug) {
|
|
16
|
+
const dir = path.join(baseDir, 'projects', slug);
|
|
17
|
+
let names = [];
|
|
18
|
+
try { names = fs.readdirSync(dir); } catch { return []; }
|
|
19
|
+
return names.filter((n) => n.endsWith('.jsonl')).map((n) => {
|
|
20
|
+
const file = path.join(dir, n);
|
|
21
|
+
return { id: n.slice(0, -'.jsonl'.length), file, mtime: fs.statSync(file).mtimeMs };
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Find a session for this slug across all profiles (except the target) and
|
|
26
|
+
// the default ~/.claude. Exact id match wins; otherwise the most recent
|
|
27
|
+
// user-facing session (an id can still target any transcript, but the
|
|
28
|
+
// "latest" pick skips SDK/subagent transcripts — you resume real chats).
|
|
29
|
+
export function findSession(slug, id, excludeProfile) {
|
|
30
|
+
const sources = [
|
|
31
|
+
...listProfiles().filter((p) => p.name !== excludeProfile)
|
|
32
|
+
.map((p) => ({ kind: 'profile', label: p.name, dir: profileDir(p.name) })),
|
|
33
|
+
{ kind: 'default', label: '~/.claude (default)', dir: DEFAULT_CLAUDE_DIR },
|
|
34
|
+
];
|
|
35
|
+
const candidates = [];
|
|
36
|
+
for (const src of sources) {
|
|
37
|
+
for (const t of listTranscripts(src.dir, slug)) {
|
|
38
|
+
if (id && t.id !== id) continue;
|
|
39
|
+
candidates.push({ ...t, source: src });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (!candidates.length) return null;
|
|
43
|
+
// newest first; among equals prefer a profile over the default dir
|
|
44
|
+
candidates.sort((a, b) => (b.mtime - a.mtime) || (a.source.kind === 'profile' ? -1 : 1));
|
|
45
|
+
if (id) return candidates[0];
|
|
46
|
+
return candidates.find((c) => isUserFacing(sessionMeta(c.file).entrypoint)) ?? candidates[0];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Sessions across all profiles + default, newest first, deduped by id
|
|
50
|
+
// (a moved session keeps only its most recent copy). With a slug: only that
|
|
51
|
+
// project folder. With slug = null: every project folder, entries tagged
|
|
52
|
+
// with their slug so callers can transfer/resume into the right project.
|
|
53
|
+
export function allSessions(slug = null) {
|
|
54
|
+
const sources = [
|
|
55
|
+
...listProfiles().map((p) => ({ kind: 'profile', label: p.name, color: p.color, dir: profileDir(p.name) })),
|
|
56
|
+
{ kind: 'default', label: 'default', color: null, dir: DEFAULT_CLAUDE_DIR },
|
|
57
|
+
];
|
|
58
|
+
const byId = new Map();
|
|
59
|
+
for (const src of sources) {
|
|
60
|
+
let slugs = slug ? [slug] : [];
|
|
61
|
+
if (!slug) {
|
|
62
|
+
try { slugs = fs.readdirSync(path.join(src.dir, 'projects')); } catch {}
|
|
63
|
+
}
|
|
64
|
+
for (const s of slugs) {
|
|
65
|
+
for (const t of listTranscripts(src.dir, s)) {
|
|
66
|
+
const prev = byId.get(t.id);
|
|
67
|
+
if (!prev || t.mtime > prev.mtime || (t.mtime === prev.mtime && src.kind === 'profile' && prev.source.kind === 'default')) {
|
|
68
|
+
byId.set(t.id, { ...t, slug: s, source: src });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return [...byId.values()].sort((a, b) => b.mtime - a.mtime);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// A session is "user-facing" (shown by Claude Code's /resume) when it was
|
|
77
|
+
// started interactively — entrypoint "cli", "vscode", "desktop", etc. Sessions
|
|
78
|
+
// spawned programmatically (subagents, Task/workflow fan-out, the SDK) carry an
|
|
79
|
+
// "sdk*" entrypoint and are hidden from /resume; ccm hides them too.
|
|
80
|
+
export function isUserFacing(entrypoint) {
|
|
81
|
+
return !entrypoint || !/^sdk/i.test(entrypoint);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const MAX_LINE = 65536; // parse only reasonably-sized JSONL lines
|
|
85
|
+
const MAX_SCAN = 1 << 20; // stop scanning a transcript after 1 MB
|
|
86
|
+
|
|
87
|
+
// Claude Code records synthetic "user" messages that aren't real prompts: the
|
|
88
|
+
// caveat banner and stdout of local slash-commands (/resume, /compact, …), bash
|
|
89
|
+
// blocks, hook output, and injected system-reminders. Titling a session from
|
|
90
|
+
// one of these shows "<local-command-caveat>Caveat…" instead of the actual
|
|
91
|
+
// conversation — so we skip them and title from the first genuine prompt.
|
|
92
|
+
const SYNTHETIC_PROMPT = /^<(local-command-caveat|command-name|command-message|command-args|command-contents|local-command-stdout|bash-input|bash-stdout|bash-stderr|user-prompt-submit-hook|system-reminder)\b/i;
|
|
93
|
+
export function isSyntheticPrompt(text) {
|
|
94
|
+
const t = String(text ?? '').trim();
|
|
95
|
+
return !t || SYNTHETIC_PROMPT.test(t);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Pasted/attached images render as "[Image #1]" or "[Image: source: <path>]" in
|
|
99
|
+
// the transcript. The cache path is noise, so collapse any image token to a
|
|
100
|
+
// short "[img]" marker and keep whatever real text accompanied it.
|
|
101
|
+
const IMAGE_TOKEN = /\[image(?:\s*#\d+)?(?::[^\]]*)?\]/i;
|
|
102
|
+
const IMAGE_TOKEN_G = /\[image(?:\s*#\d+)?(?::[^\]]*)?\]/gi;
|
|
103
|
+
|
|
104
|
+
// The display title for a user message, or null when it carries no real prompt
|
|
105
|
+
// (synthetic slash-command noise, whitespace, or an image with no caption).
|
|
106
|
+
export function promptTitle(text) {
|
|
107
|
+
if (isSyntheticPrompt(text)) return null;
|
|
108
|
+
const hadImage = IMAGE_TOKEN.test(text);
|
|
109
|
+
const stripped = String(text).replace(IMAGE_TOKEN_G, ' ').replace(/\s+/g, ' ').trim();
|
|
110
|
+
if (stripped) return hadImage ? `[img] ${stripped}` : stripped;
|
|
111
|
+
return hadImage ? '[img]' : null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Best-effort metadata from the transcript head:
|
|
115
|
+
// title — a compaction summary if present, else the first user message;
|
|
116
|
+
// cwd — the directory the session belongs to (resume must run there);
|
|
117
|
+
// entrypoint — how the session was started (drives isUserFacing).
|
|
118
|
+
// Streams line-by-line and skips oversized lines (a big queue-operation blob
|
|
119
|
+
// can be the first line, pushing the entrypoint-bearing user line past a fixed
|
|
120
|
+
// read window — which would misclassify SDK subagent sessions as user-facing).
|
|
121
|
+
export function sessionMeta(file) {
|
|
122
|
+
let fd;
|
|
123
|
+
try { fd = fs.openSync(file, 'r'); } catch { return { title: null, cwd: null, entrypoint: null }; }
|
|
124
|
+
const clean = (s) => String(s).replace(/\s+/g, ' ').trim().slice(0, 80) || null;
|
|
125
|
+
let title = null;
|
|
126
|
+
let cwd = null;
|
|
127
|
+
let entrypoint = null;
|
|
128
|
+
const consider = (line) => {
|
|
129
|
+
if (!line || line.length > MAX_LINE) return; // skip blank/oversized lines
|
|
130
|
+
let o;
|
|
131
|
+
try { o = JSON.parse(line); } catch { return; }
|
|
132
|
+
if (!cwd && typeof o?.cwd === 'string') cwd = o.cwd;
|
|
133
|
+
if (!entrypoint && typeof o?.entrypoint === 'string') entrypoint = o.entrypoint;
|
|
134
|
+
if (!title && o?.type === 'summary' && o.summary) title = clean(o.summary);
|
|
135
|
+
const msg = o?.message;
|
|
136
|
+
if (!title && (o?.type === 'user' || msg?.role === 'user') && msg?.content) {
|
|
137
|
+
let text = '';
|
|
138
|
+
if (typeof msg.content === 'string') text = msg.content;
|
|
139
|
+
else if (Array.isArray(msg.content)) text = msg.content.find?.((c) => c?.type === 'text')?.text ?? '';
|
|
140
|
+
const t = promptTitle(text);
|
|
141
|
+
if (t) title = clean(t);
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
const CH = 65536;
|
|
145
|
+
const buf = Buffer.alloc(CH);
|
|
146
|
+
let carry = '';
|
|
147
|
+
let read = 0;
|
|
148
|
+
try {
|
|
149
|
+
while (read < MAX_SCAN) {
|
|
150
|
+
const n = fs.readSync(fd, buf, 0, CH, read);
|
|
151
|
+
if (n <= 0) break;
|
|
152
|
+
read += n;
|
|
153
|
+
carry += buf.toString('utf8', 0, n);
|
|
154
|
+
let nl;
|
|
155
|
+
while ((nl = carry.indexOf('\n')) >= 0) {
|
|
156
|
+
consider(carry.slice(0, nl));
|
|
157
|
+
carry = carry.slice(nl + 1);
|
|
158
|
+
if (title && cwd && entrypoint) return { title, cwd, entrypoint };
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
consider(carry);
|
|
162
|
+
} catch { /* fall through with whatever we found */ } finally {
|
|
163
|
+
try { fs.closeSync(fd); } catch {}
|
|
164
|
+
}
|
|
165
|
+
return { title, cwd, entrypoint };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function sessionTitle(file) {
|
|
169
|
+
return sessionMeta(file).title;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// User-facing sessions for a slug (or all folders when slug is null), each with
|
|
173
|
+
// metadata attached, newest first. Reads every candidate's head to classify by
|
|
174
|
+
// entrypoint and filter out subagent/SDK transcripts (matching /resume) and
|
|
175
|
+
// empty resume-shells — transcripts with no real prompt or summary, which would
|
|
176
|
+
// otherwise pad the board as rows of "untitled".
|
|
177
|
+
export function listSessions(slug = null, { includeSdk = false, includeEmpty = false } = {}) {
|
|
178
|
+
return allSessions(slug)
|
|
179
|
+
.map((s) => ({ ...s, ...sessionMeta(s.file) }))
|
|
180
|
+
.filter((s) => includeSdk || isUserFacing(s.entrypoint))
|
|
181
|
+
.filter((s) => includeEmpty || s.title);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Copy a session's transcript + artifacts into the target profile.
|
|
185
|
+
// The original is left in place on the source account.
|
|
186
|
+
export function copySessionTo(found, toName, slug) {
|
|
187
|
+
const destBase = profileDir(toName);
|
|
188
|
+
const destDir = path.join(destBase, 'projects', slug);
|
|
189
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
190
|
+
fs.copyFileSync(found.file, path.join(destDir, `${found.id}.jsonl`));
|
|
191
|
+
let artifacts = 0;
|
|
192
|
+
for (const base of ARTIFACT_DIRS) {
|
|
193
|
+
const srcBase = path.join(found.source.dir, base);
|
|
194
|
+
let entries = [];
|
|
195
|
+
try { entries = fs.readdirSync(srcBase); } catch { continue; }
|
|
196
|
+
for (const entry of entries.filter((e) => e.includes(found.id))) {
|
|
197
|
+
try {
|
|
198
|
+
fs.cpSync(path.join(srcBase, entry), path.join(destBase, base, entry), { recursive: true, force: true });
|
|
199
|
+
artifacts++;
|
|
200
|
+
} catch {}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return { artifacts };
|
|
204
|
+
}
|
package/src/shared.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { SHARED_DIR, SHARED_MCP, DEFAULT_CLAUDE_DIR } from './paths.js';
|
|
4
|
+
|
|
5
|
+
// Directories junction-linked into every profile (configure once, all accounts see it).
|
|
6
|
+
export const SHARED_SUBDIRS = ['agents', 'skills', 'commands', 'hooks'];
|
|
7
|
+
// Files composed into each profile at launch (see compose.js): shared base +
|
|
8
|
+
// per-profile overrides. File symlinks need admin on Windows; copies don't.
|
|
9
|
+
const SEED_FILES = ['settings.json', 'CLAUDE.md'];
|
|
10
|
+
|
|
11
|
+
// Create ~/.ccm/shared, seeding each piece from ~/.claude the first time.
|
|
12
|
+
export function ensureShared() {
|
|
13
|
+
fs.mkdirSync(SHARED_DIR, { recursive: true });
|
|
14
|
+
for (const f of SEED_FILES) {
|
|
15
|
+
const src = path.join(DEFAULT_CLAUDE_DIR, f);
|
|
16
|
+
const dst = path.join(SHARED_DIR, f);
|
|
17
|
+
if (!fs.existsSync(dst) && fs.existsSync(src)) fs.copyFileSync(src, dst);
|
|
18
|
+
}
|
|
19
|
+
const mcpSrc = path.join(DEFAULT_CLAUDE_DIR, '.mcp.json');
|
|
20
|
+
if (!fs.existsSync(SHARED_MCP) && fs.existsSync(mcpSrc)) fs.copyFileSync(mcpSrc, SHARED_MCP);
|
|
21
|
+
for (const d of SHARED_SUBDIRS) {
|
|
22
|
+
const src = path.join(DEFAULT_CLAUDE_DIR, d);
|
|
23
|
+
const dst = path.join(SHARED_DIR, d);
|
|
24
|
+
if (fs.existsSync(dst)) continue;
|
|
25
|
+
if (fs.existsSync(src)) fs.cpSync(src, dst, { recursive: true });
|
|
26
|
+
else fs.mkdirSync(dst, { recursive: true });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Junction-link shared dirs into a profile. Anything already present is left alone.
|
|
31
|
+
export function linkIntoProfile(dir) {
|
|
32
|
+
const warnings = [];
|
|
33
|
+
for (const d of SHARED_SUBDIRS) {
|
|
34
|
+
const link = path.join(dir, d);
|
|
35
|
+
try {
|
|
36
|
+
fs.lstatSync(link);
|
|
37
|
+
continue;
|
|
38
|
+
} catch {}
|
|
39
|
+
const target = path.join(SHARED_DIR, d);
|
|
40
|
+
try {
|
|
41
|
+
fs.symlinkSync(target, link, 'junction');
|
|
42
|
+
} catch (e) {
|
|
43
|
+
try {
|
|
44
|
+
fs.cpSync(target, link, { recursive: true });
|
|
45
|
+
warnings.push(`could not junction ${d} (${e.code ?? e.message}); copied instead — edits won't be shared`);
|
|
46
|
+
} catch (e2) {
|
|
47
|
+
warnings.push(`could not provide ${d}: ${e2.message}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return warnings;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Remove junctions before deleting a profile so rm can't touch shared content.
|
|
55
|
+
export function unlinkShared(dir) {
|
|
56
|
+
for (const d of SHARED_SUBDIRS) {
|
|
57
|
+
const link = path.join(dir, d);
|
|
58
|
+
try {
|
|
59
|
+
if (fs.lstatSync(link).isSymbolicLink()) fs.unlinkSync(link);
|
|
60
|
+
} catch {}
|
|
61
|
+
}
|
|
62
|
+
}
|
package/src/status.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { listProfiles } from './registry.js';
|
|
2
|
+
import { isRunning } from './launch.js';
|
|
3
|
+
import { getUsage, ERROR_HINTS, DEFAULT_MAX_AGE_MS, isStale } from './usage.js';
|
|
4
|
+
import { bar, bold, dim, colorize, severityColor, timeUntil, timeAgo } from './util.js';
|
|
5
|
+
|
|
6
|
+
export async function gatherStatus({ maxAgeMs = DEFAULT_MAX_AGE_MS, cacheOnly = false } = {}) {
|
|
7
|
+
const profiles = listProfiles();
|
|
8
|
+
return Promise.all(profiles.map(async (p) => ({
|
|
9
|
+
...p,
|
|
10
|
+
running: isRunning(p.name),
|
|
11
|
+
usage: await getUsage(p.name, { maxAgeMs, cacheOnly }),
|
|
12
|
+
})));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function renderStatus(rows) {
|
|
16
|
+
if (!rows.length) {
|
|
17
|
+
return `No profiles yet.\n ${dim('ccm import <name> adopt your current ~/.claude login')}\n ${dim('ccm add <name> log in to another account')}`;
|
|
18
|
+
}
|
|
19
|
+
const out = [];
|
|
20
|
+
for (const p of rows) {
|
|
21
|
+
const head = [
|
|
22
|
+
colorize(p.color, '●'),
|
|
23
|
+
bold(p.name),
|
|
24
|
+
p.email ?? dim('(email unknown)'),
|
|
25
|
+
];
|
|
26
|
+
if (p.plan) head.push(dim(p.plan));
|
|
27
|
+
if (p.organization) head.push(dim(p.organization));
|
|
28
|
+
if (p.running) head.push(colorize('green', 'RUNNING'));
|
|
29
|
+
else if (p.lastUsed) head.push(dim(`last used ${timeAgo(p.lastUsed)}`));
|
|
30
|
+
out.push(head.join(' '));
|
|
31
|
+
|
|
32
|
+
const u = p.usage;
|
|
33
|
+
const stale = u && (u.staleError || isStale(u));
|
|
34
|
+
if (u?.windows?.length) {
|
|
35
|
+
const labelWidth = Math.max(...u.windows.map((w) => w.label.length));
|
|
36
|
+
for (const w of u.windows) {
|
|
37
|
+
// Don't paint severity on stale numbers — they may no longer be true.
|
|
38
|
+
const color = stale ? 'gray' : severityColor(w.percent, w.severity);
|
|
39
|
+
out.push(
|
|
40
|
+
` ${w.label.padEnd(labelWidth)} ${colorize(color, bar(w.percent))} ` +
|
|
41
|
+
`${String(Math.round(w.percent)).padStart(3)}% ${dim(`resets in ${timeUntil(w.resetsAt)}`)}`
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
const asOf = timeAgo(u.fetchedAt ? new Date(u.fetchedAt).toISOString() : null);
|
|
45
|
+
if (stale) {
|
|
46
|
+
const hint = u.staleError ? ERROR_HINTS[u.staleError] ?? u.staleError : 'could not refresh';
|
|
47
|
+
out.push(colorize('yellow', ` stale — as of ${asOf} · ${hint}`));
|
|
48
|
+
} else {
|
|
49
|
+
out.push(dim(` fetched ${asOf}`));
|
|
50
|
+
}
|
|
51
|
+
} else if (u?.error) {
|
|
52
|
+
out.push(` ${colorize('yellow', ERROR_HINTS[u.error] ?? `usage unavailable (${u.error})`)}`);
|
|
53
|
+
} else {
|
|
54
|
+
out.push(dim(' usage: no data yet — run ccm status again'));
|
|
55
|
+
}
|
|
56
|
+
out.push('');
|
|
57
|
+
}
|
|
58
|
+
return out.join('\n').trimEnd();
|
|
59
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { PROFILES_DIR, SHARED_DIR } from './paths.js';
|
|
4
|
+
import { readJson, writeJson, colorize, dim } from './util.js';
|
|
5
|
+
import { getProfile, listProfiles } from './registry.js';
|
|
6
|
+
import { loadCache, isFresh, bestAlternative } from './usage.js';
|
|
7
|
+
import { ensureShared } from './shared.js';
|
|
8
|
+
|
|
9
|
+
const STALE_MS = 10 * 60_000;
|
|
10
|
+
|
|
11
|
+
// Which profile does this Claude Code session belong to? The statusline process
|
|
12
|
+
// inherits CLAUDE_CONFIG_DIR from the session that spawned it.
|
|
13
|
+
export function detectProfile(env = process.env) {
|
|
14
|
+
const dir = env.CLAUDE_CONFIG_DIR;
|
|
15
|
+
if (!dir) return null;
|
|
16
|
+
const rel = path.relative(PROFILES_DIR, path.resolve(dir));
|
|
17
|
+
if (rel.startsWith('..') || path.isAbsolute(rel) || !rel) return null;
|
|
18
|
+
return rel.split(path.sep)[0];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function buildLine(name, profile, usage, hint = null) {
|
|
22
|
+
if (!name) return dim('○ default account (not a ccm profile)');
|
|
23
|
+
const parts = [colorize(profile?.color ?? 'white', `● ${name}`)];
|
|
24
|
+
if (profile?.email) parts.push(dim(profile.email));
|
|
25
|
+
for (const w of usage?.windows ?? []) {
|
|
26
|
+
const short = w.label.startsWith('session') ? '5h'
|
|
27
|
+
: w.label === 'week (all models)' ? 'wk'
|
|
28
|
+
: w.label.replace('week (', 'wk·').replace(')', '');
|
|
29
|
+
const pct = Math.round(w.percent);
|
|
30
|
+
const color = pct >= 90 ? 'red' : pct >= 70 ? 'yellow' : 'green';
|
|
31
|
+
parts.push(colorize(color, `${short} ${pct}%`));
|
|
32
|
+
}
|
|
33
|
+
if (!usage?.windows?.length) parts.push(dim('usage: n/a'));
|
|
34
|
+
else if (usage.staleError || (usage.fetchedAt && Date.now() - usage.fetchedAt >= STALE_MS)) parts.push(dim('stale'));
|
|
35
|
+
if (hint) parts.push(colorize('yellow', hint));
|
|
36
|
+
return parts.join(dim(' · '));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// When the session nears its limit and another account has real headroom,
|
|
40
|
+
// surface the escape hatch right where the user is looking.
|
|
41
|
+
export function limitHint(name, usage, profileNames, cache) {
|
|
42
|
+
const worst = Math.max(0, ...(usage?.windows ?? []).map((w) => w.percent));
|
|
43
|
+
if (worst < 90) return null;
|
|
44
|
+
const alt = bestAlternative(name, profileNames, cache);
|
|
45
|
+
if (!alt || alt.headroom < 30) return null;
|
|
46
|
+
return `→ ccm move-session ${alt.name}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Entry for `ccm statusline`: must be fast and never throw. Cache only;
|
|
50
|
+
// if the cache is stale, kick off a detached background refresh for next time.
|
|
51
|
+
export async function statuslineMain() {
|
|
52
|
+
try {
|
|
53
|
+
const name = detectProfile();
|
|
54
|
+
const profile = name ? getProfile(name) : null;
|
|
55
|
+
const cache = loadCache();
|
|
56
|
+
const usage = name ? cache[name] : null;
|
|
57
|
+
const hint = name ? limitHint(name, usage, listProfiles().map((p) => p.name), cache) : null;
|
|
58
|
+
console.log(buildLine(name, profile, usage, hint));
|
|
59
|
+
if (name && !isFresh(usage, STALE_MS)) {
|
|
60
|
+
spawn(process.execPath, [process.argv[1], 'refresh'], { detached: true, stdio: 'ignore' }).unref();
|
|
61
|
+
}
|
|
62
|
+
} catch {
|
|
63
|
+
console.log('ccm');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function installStatusline() {
|
|
68
|
+
ensureShared();
|
|
69
|
+
const file = path.join(SHARED_DIR, 'settings.json');
|
|
70
|
+
const settings = readJson(file, {});
|
|
71
|
+
settings.statusLine = { type: 'command', command: 'ccm statusline', padding: 0 };
|
|
72
|
+
writeJson(file, settings);
|
|
73
|
+
return file;
|
|
74
|
+
}
|