SLOPSHOPPER

cswap

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

newpanecommandtoaststatusprocess
v0.3.0no licenseupdated 2026-10-15deadmade/dead-claude-skills/plugins/cswap
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cswap
│ ┃ Accounts ✕ › fix the failing auth test and add an audit log call │ ┃ No accounts yet: log into Claude Code, then │ ┃ run `cswap add` in a terminal. ⏺ Read(src/auth.ts) │ ┃ [ Refresh ] auto-switch off (/plugin configu ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /swap │ ⎿ cswap: Accounts pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Accounts
No accounts yet: log into Claude Code, then run `cswap add` in a terminal. [ Refresh ] auto-switch off (/plugin configure cswap)
README

dead-claude-skills

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.

Source 3 files
hooks/register.tsx 195 lines
1// 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};
195
hooks/cswap.ts 143 lines
1// 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}
143
types/index.d.ts 25 lines
1export 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