claude-swap inside Claude Code: an accounts pane, /swap, a usage status line and opt-in auto-switching. Needs the cswap binary.

My personal Claude Code setup as a plugin marketplace, so every machine (Linux, Windows) gets the same skills. It's built for me; use it if it fits, but expect it to change without notice.
The default bundle, dead-skills, pulls in code-review, claude-code-setup, claude-md-management, security-guidance, claude-security, superpowers, ponytail, mcp-basic (context7) and my global rules. Everything else is opt-in: language servers, language and database skills (rust, csharp, postgres), integrations (GitHub, Azure, Atlassian), skill packs like hallmark, paper-lookup, self-review, graphify and the mattpocock/pstack/Trail of Bits picks, plus tools such as tui-review, cswap for multi-account switching, notify for desktop notifications when a turn ends and night-city, a toned-down synthwave theme.
npx --allow-git=root github:deadmade/dead-claude-skills#semver:*
The installer (Node standard library only) shows one page per category where you pick what to install, remove or update, then applies only the changes. It pins the marketplace to its own release so plugins match the installer, and it always denies Claude reading .env files and credential dirs and keeps session transcripts (Claude Code deletes them after 30 days unless cleanupPeriodDays is set). Optional toggles add ccstatusline, the Bash sandbox and dcg; the danger zone can strip all foreign plugins or reset ~/.claude to a clean slate (after a backup). Without the installer, /plugin marketplace add deadmade/dead-claude-skills and /plugin install dead-skills@dead-claude-skills work too, but track main.
Everything ships as one version, cut with npm run release -- patch and git push --follow-tags origin main; the tag builds the binaries and publishes the GitHub release the installer downloads from.
hooks/register.tsx 195 lines1// claude-swap front end: runs the cswap binary and reads its --json output. All account logic stays in cswap.
2import { atom, read, update } from 'claude-code';
3import type { EngineInterface, Register } from 'claude-code';
4
5import type { View } from '../types';
6import {
7 autoOutcome,
8 bar,
9 errorOf,
10 lastJson,
11 parseAccounts,
12 statusLine,
13 statusText,
14 summary,
15 swapArgv,
16 switchedTo,
17} from './cswap';
18
19const PANE = 'cswap';
20const TICK_MS = 120_000;
21const INSTALL_HINT =
22 'cswap not found. Windows: rerun `npx --allow-git=root github:deadmade/dead-claude-skills#semver:*` with cswap ticked. ' +
23 'Linux: `uv tool install claude-swap`. Or set the path with /plugin configure cswap.';
24
25const view = atom({ plugin: 'cswap', key: 'view' } as const, {
26 accounts: [],
27 error: null,
28 missing: false,
29 updatedAt: 0,
30});
31
32type $ = EngineInterface;
33type Run = { exitCode: number; stdout: string } | { exitCode: -1; stdout: ''; failed: string };
34
35// Module state: a reload starts it over, which is fine (it's a cache and two flags).
36let autoSwitch = false;
37let configured = '';
38let bin: string | null = null;
39let busy = false;
40let blocked = false;
41
42async function candidates($: $): Promise<string[]> {
43 if (configured) return [configured];
44 const local = await $.env.get('LOCALAPPDATA');
45 return local ? ['cswap', `${local}\\Programs\\cswap\\cswap.exe`] : ['cswap'];
46}
47
48// First candidate that answers --version; re-probed while missing, so an install mid-session is picked up.
49async function resolve($: $): Promise<string | null> {
50 if (bin) return bin;
51 for (const c of await candidates($)) {
52 try {
53 if ((await $.process.run([c, '--version'], { timeoutMs: 15_000 })).exitCode === 0) return (bin = c);
54 } catch {
55 // not startable: try the next one
56 }
57 }
58 return null;
59}
60
61async function cswap($: $, args: string[], timeoutMs = 60_000): Promise<Run> {
62 const exe = await resolve($);
63 if (!exe) return { exitCode: -1, stdout: '', failed: INSTALL_HINT };
64 try {
65 const r = await $.process.run([exe, ...args], { timeoutMs });
66 return { exitCode: r.exitCode, stdout: r.stdout };
67 } catch (err) {
68 bin = null;
69 return {
70 exitCode: -1,
71 stdout: '',
72 failed: `cswap ${args[0]} failed: ${err instanceof Error ? err.message : String(err)}`,
73 };
74 }
75}
76
77async function refresh($: $): Promise<View> {
78 const r = await cswap($, ['list', '--json']);
79 const doc = 'failed' in r ? null : lastJson(r.stdout);
80 const next: View = {
81 accounts: parseAccounts(doc),
82 error: 'failed' in r ? r.failed : r.exitCode === 0 ? null : (errorOf(doc) ?? `cswap list exited ${r.exitCode}`),
83 missing: !bin,
84 updatedAt: await $.clock.now(),
85 };
86 await update($, view, () => next);
87 $.ui.status(next.missing ? undefined : statusLine(next.accounts));
88 return next;
89}
90
91async function autoTick($: $) {
92 const r = await cswap($, ['auto', '--once', '--json']);
93 if ('failed' in r) return;
94 const out = autoOutcome(r.exitCode, r.stdout);
95 if (out.kind === 'switched') $.ui.toast(`cswap switched to ${out.to ?? 'another account'}`);
96 if (out.kind === 'blocked' && !blocked) $.ui.toast(`cswap can't switch: ${out.detail}`);
97 blocked = out.kind === 'blocked';
98}
99
100async function tick($: $) {
101 if (busy) return;
102 busy = true;
103 try {
104 if (autoSwitch) await autoTick($);
105 await refresh($);
106 } finally {
107 busy = false;
108 }
109}
110
111async function swap($: $, args: string[]): Promise<string> {
112 const r = await cswap($, args, 30_000);
113 if ('failed' in r) return r.failed;
114 const doc = lastJson(r.stdout);
115 const err = r.exitCode === 0 ? null : (errorOf(doc) ?? `cswap exited ${r.exitCode}`);
116 await refresh($);
117 return err ? `cswap: ${err}` : `Switched to ${switchedTo(doc) ?? 'the next account'}.`;
118}
119
120function pressSwitch($: $, n: number) {
121 void swap($, ['switch', String(n), '--json']).then((text) => $.ui.toast(text));
122}
123
124export const register: Register = (on, options) => {
125 autoSwitch = options.auto_switch === true;
126 configured = typeof options.cswap_path === 'string' ? options.cswap_path.trim() : '';
127 bin = null;
128
129 on('session.start', async ($, e, next) => {
130 await $.command.register({
131 name: 'swap',
132 description: 'claude-swap: open the accounts pane, or switch (next, best, a number, email or alias)',
133 argumentHint: '[next|best|<num|email|alias>]',
134 });
135 $.clock.every(TICK_MS, () => void tick($).catch(() => {}));
136 void tick($).catch(() => {});
137
138 return next(e);
139 });
140
141 on('command.run', { command: 'swap' }, async ($, e) => {
142 const argv = swapArgv(e.args);
143 if (argv) return { text: await swap($, argv) };
144 const placed = await $.ui.open({ id: PANE, title: 'Accounts' });
145 const now = await refresh($);
146 if (now.missing || now.error) return { text: now.error ?? INSTALL_HINT };
147 return { text: placed.isPlaced ? 'Accounts pane opened.' : summary(now.accounts) };
148 });
149
150 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
151 const { Box, Button, Text } = $.ui.resolve(e);
152 const v = await read($, view);
153
154 return (
155 <Box flexDirection="column">
156 {v.missing && <Text color="yellow">{INSTALL_HINT}</Text>}
157 {!v.missing && v.error && <Text color="red">{v.error}</Text>}
158 {!v.missing && !v.error && v.accounts.length === 0 && <Text dimColor>{summary([])}</Text>}
159 {v.accounts.map((a) => (
160 <Box key={`acc-${a.number}`} flexDirection="column" marginBottom={1}>
161 <Box gap={1}>
162 <Text bold={a.active}>
163 {a.active ? '●' : '○'} #{a.number} {a.label}
164 </Text>
165 {a.disabled && <Text dimColor>(disabled)</Text>}
166 {statusText(a) && <Text color="yellow">{statusText(a)}</Text>}
167 </Box>
168 <Text dimColor={!a.active}>
169 {' 5h '}
170 {bar(a.fiveHour)}
171 {a.fiveHour?.countdown ? ` resets ${a.fiveHour.countdown}` : ''}
172 </Text>
173 <Text dimColor={!a.active}>
174 {' 7d '}
175 {bar(a.sevenDay)}
176 {a.sevenDay?.countdown ? ` resets ${a.sevenDay.countdown}` : ''}
177 </Text>
178 {!a.active && (
179 <Button
180 key={`switch-${a.number}`}
181 label={`Switch to #${a.number}`}
182 onPress={() => pressSwitch($, a.number)}
183 />
184 )}
185 </Box>
186 ))}
187 <Box gap={1}>
188 <Button key="refresh" label="Refresh" hotkey="r" onPress={() => void refresh($)} />
189 <Text dimColor>{autoSwitch ? 'auto-switch on' : 'auto-switch off (/plugin configure cswap)'}</Text>
190 </Box>
191 </Box>
192 );
193 });
194};
195hooks/cswap.ts 143 lines1// Pure helpers over cswap's `--json` output (schema v1); register.tsx does all I/O.
2import type { Account, Window } from '../types';
3
4type Json = Record<string, unknown>;
5
6const isObj = (v: unknown): v is Json => typeof v === 'object' && v !== null && !Array.isArray(v);
7
8// cswap prints one JSON document (list/status/switch) or one per line (auto); the last parseable one wins.
9export function lastJson(stdout: string): Json | null {
10 const whole = tryParse(stdout);
11 if (whole) return whole;
12 const lines = stdout
13 .split('\n')
14 .map(tryParse)
15 .filter((v): v is Json => v !== null);
16 return lines.at(-1) ?? null;
17}
18
19export function jsonLines(stdout: string): Json[] {
20 return stdout
21 .split('\n')
22 .map(tryParse)
23 .filter((v): v is Json => v !== null);
24}
25
26function tryParse(text: string): Json | null {
27 try {
28 const v: unknown = JSON.parse(text);
29 return isObj(v) ? v : null;
30 } catch {
31 return null;
32 }
33}
34
35export function errorOf(doc: Json | null): string | null {
36 if (!doc) return 'cswap printed no JSON';
37 const err = doc.error;
38 return isObj(err) && typeof err.message === 'string' ? err.message : null;
39}
40
41function windowOf(v: unknown): Window | null {
42 if (!isObj(v) || typeof v.pct !== 'number') return null;
43 return { pct: v.pct, countdown: typeof v.countdown === 'string' ? v.countdown : null };
44}
45
46export function parseAccounts(doc: Json | null): Account[] {
47 const rows = doc && Array.isArray(doc.accounts) ? doc.accounts : [];
48 return rows.filter(isObj).map((row) => {
49 const usage = isObj(row.usage) ? row.usage : isObj(row.lastGoodUsage) ? row.lastGoodUsage : {};
50 const email = typeof row.email === 'string' ? row.email : '?';
51 return {
52 number: typeof row.number === 'number' ? row.number : 0,
53 label: typeof row.alias === 'string' && row.alias ? `${row.alias} (${email})` : email,
54 active: row.active === true,
55 disabled: row.disabled === true,
56 status: typeof row.usageStatus === 'string' ? row.usageStatus : 'unavailable',
57 fiveHour: windowOf(usage.fiveHour),
58 sevenDay: windowOf(usage.sevenDay),
59 };
60 });
61}
62
63const STATUS_TEXT: Record<string, string> = {
64 token_expired: 'token expired',
65 relogin_required: 'log in again',
66 no_credentials: 'no credentials',
67 api_key: 'API key',
68 unavailable: 'usage unavailable',
69 foreign_credential: 'login drifted, switch to repair',
70 keychain_unavailable: 'keychain unavailable',
71};
72
73export const statusText = (a: Account): string | null =>
74 a.status === 'ok' ? null : (STATUS_TEXT[a.status] ?? a.status);
75
76const pct = (w: Window | null) => (w ? `${Math.round(w.pct)}%` : '–');
77
78export function statusLine(accounts: readonly Account[]): string | undefined {
79 const a = accounts.find((x) => x.active);
80 if (!a) return accounts.length ? 'cswap: no managed account active' : undefined;
81 const note = statusText(a);
82 const usage = a.fiveHour || a.sevenDay ? `5h ${pct(a.fiveHour)} · 7d ${pct(a.sevenDay)}` : null;
83 return [`cswap #${a.number} ${a.label}`, usage, note].filter(Boolean).join(' · ');
84}
85
86export function bar(w: Window | null, width = 10): string {
87 if (!w) return `${'·'.repeat(width)} –`;
88 const filled = Math.max(0, Math.min(width, Math.round((w.pct / 100) * width)));
89 return `${'█'.repeat(filled)}${'░'.repeat(width - filled)} ${pct(w).padStart(4)}`;
90}
91
92export function summary(accounts: readonly Account[]): string {
93 if (!accounts.length) return 'No accounts yet: log into Claude Code, then run `cswap add` in a terminal.';
94 return accounts
95 .map((a) => {
96 const flags = [a.disabled && 'disabled', statusText(a)].filter(Boolean).join(', ');
97 const reset = a.fiveHour?.countdown ? ` (resets ${a.fiveHour.countdown})` : '';
98 return `${a.active ? '●' : '○'} #${a.number} ${a.label}: 5h ${pct(a.fiveHour)}${reset} · 7d ${pct(a.sevenDay)}${flags ? ` [${flags}]` : ''}`;
99 })
100 .join('\n');
101}
102
103// `/swap` arguments → cswap argv (without the binary); null opens the pane.
104export function swapArgv(args: string): string[] | null {
105 const target = args.trim();
106 if (!target) return null;
107 if (target === 'next') return ['switch', '--json'];
108 if (target === 'best') return ['switch', '--strategy', 'best', '--json'];
109 return ['switch', target, '--json'];
110}
111
112export function switchedTo(doc: Json | null): string | null {
113 const to = doc && isObj(doc.to) ? doc.to : null;
114 if (!to) return null;
115 return `#${String(to.number)} ${String(to.email)}`;
116}
117
118// `cswap auto --once` exit codes: 0 switched, 1 error, 2 nothing to do, 3 blocked (no viable target).
119export type AutoOutcome =
120 | { kind: 'switched'; to: string | null }
121 | { kind: 'idle' }
122 | { kind: 'blocked'; detail: string }
123 | { kind: 'error'; detail: string };
124
125export function autoOutcome(exitCode: number, stdout: string): AutoOutcome {
126 const events = jsonLines(stdout);
127 const detail = (kind: string) => {
128 const ev = events.findLast((e) => e.event === kind);
129 return String(ev?.detail ?? ev?.reason ?? ev?.message ?? '');
130 };
131 if (exitCode === 0) return { kind: 'switched', to: switchedTo(events.findLast((e) => e.event === 'switch') ?? null) };
132 if (exitCode === 2) return { kind: 'idle' };
133 if (exitCode === 3) {
134 const exhausted = events.findLast((e) => e.event === 'all-exhausted');
135 if (exhausted) {
136 const reset = typeof exhausted.earliestResetAt === 'string' ? `, next reset ${exhausted.earliestResetAt}` : '';
137 return { kind: 'blocked', detail: `all accounts exhausted${reset}` };
138 }
139 return { kind: 'blocked', detail: detail('no-switch') || 'no viable account' };
140 }
141 return { kind: 'error', detail: detail('error') || `exit ${exitCode}` };
142}
143types/index.d.ts 25 lines1export type Window = { pct: number; countdown: string | null };
2
3export type Account = {
4 number: number;
5 label: string;
6 active: boolean;
7 disabled: boolean;
8 status: string;
9 fiveHour: Window | null;
10 sevenDay: Window | null;
11};
12
13export type View = {
14 accounts: Account[];
15 error: string | null;
16 missing: boolean;
17 updatedAt: number;
18};
19
20declare module 'claude-code' {
21 interface PluginState {
22 cswap: { view: View };
23 }
24}
25