Claude subscription accounts: select, measure and swap — plus a web UI and a half-size artifact#25
Merged
Merged
Conversation
`save()` was last-writer-wins, and until now that was indistinguishable from correct: every writer was a single short-lived command, so two of them could not realistically overlap. A long-lived editor breaks that assumption. A browser tab left open while `swisscode config work` runs in a terminal makes silent clobbering ordinary rather than theoretical -- and the field it clobbers could be an API key. `revision()` is a hash of the file's CONTENT, not its mtime. mtime granularity is coarse and platform-dependent, so two writes inside one tick can share a timestamp; a lost update would then slip through exactly when writers are most concurrent. Hashing bytes cannot have that failure. Optional on the port, in the same genuine sense as `modes`: a store that cannot derive one is still a store, and callers degrade rather than break. Honest about what it is -- a check, not a lock. It catches the interleaving people actually hit (read, think, write minutes later) and does not pretend to serialize concurrent writers, which would need an exclusive lock this project does not otherwise want. Signed-off-by: jellologic <[email protected]>
`swisscode config web` serves a config UI on 127.0.0.1. The server is the
singleton: the port bind IS the mutex, so there is no lockfile and no
stale-PID reasoning — the OS already refuses to bind twice and cleans up
when the process dies.
Built the backend before the interface deliberately. This is the part a
wrong decision is expensive in, it needs no new dependencies, and it
leaves the UI framework a replaceable detail on a tested contract.
THE HEXAGON PAID OFF. The API is thin because every operation was already
a port operation: ConfigStorePort, ProviderRegistryPort, AgentRegistryPort
are exactly what a config UI needs, and the Ink wizard consumes the same
ones. Two UI adapters over one unchanged core, with no change to core/.
SECURITY IS THE POINT HERE, not the dependency budget. This server reads
and writes a file holding plaintext keys, and a browser will talk to
127.0.0.1 on behalf of any page the user has open. "It only listens on
localhost" is why a model is needed, not a substitute for one:
* Host allowlist, exact on host:port. This is the DNS-rebinding defence
and nothing else can be: the connection genuinely comes from loopback,
so no socket-level check can tell an attack from a real request.
* The token is injected into the HTML and echoed in a CUSTOM header,
never set as a cookie. A cookie is attached automatically on
cross-origin requests and could be ridden; a value the client must
read out of our response cannot be, because same-origin policy
forbids reading it. The custom header also forces a preflight we
never answer.
* Keys are WRITE-ONLY. The browser gets `hasKey`, never the key. An
omitted apiKey preserves the stored one and only an explicit null
clears it, so "I did not touch this field" and "delete my credential"
stop being the same request.
* Loopback-only bind, strict CSP, path-traversal check on the resolved
path, 256KB body cap, whitelisted profile fields so nothing unknown
reaches config.json.
Writes carry the revision they were based on; a stale one is a 409 rather
than a merge, because swisscode cannot know which of two divergent edits
was meant.
Verified end to end in a real browser, not just with curl — which matters,
because the first version of the fallback page was blocked by our own CSP
(inline script under `script-src 'self'`) and curl executes nothing, so it
could not have noticed. A hostile origin on another port was confirmed
blocked by CORS and by the unanswered preflight, a planted key canary
appeared in neither the payload nor the DOM, and the 409/400 paths were
confirmed to write nothing to disk.
The page served today is a fallback that proves the handshake; the SPA
replaces it via a token substitution that is already wired.
Signed-off-by: jellologic <[email protected]>
Providers were shipped constants in source. They can now also come from
config.json — which moves a guarantee rather than adding a feature.
test/registry.test.ts proves things about SHIPPED descriptors: no /v1
suffix, no hand-typed [1m], compat flags that are real, no ''-as-sentinel
in env. None of it can reach a provider typed into a browser. Offering a
form to create one does not make those checks unnecessary; it makes them
RUNTIME checks, so core/provider-def.ts is their twin, rule for rule, each
saying which failure it prevents.
extendedContext and catalogId are refused OUTRIGHT rather than ignored.
Silently dropping a field the user typed would leave them believing a
capability is active, and [1m] is precisely the claim that must be
verified — an id the endpoint does not recognise fails hard, one that
ignores the suffix is a 200K window wearing a 1M label.
THE HEXAGON ABSORBED THIS. `withCustomProviders` is a second implementation
of ProviderRegistryPort and that is the whole mechanism: core/ is unchanged,
no consumer changed, and neither the launch path nor the doctor learned
that providers can come from a file. It wraps the INJECTED registry rather
than the shipped constant, so test fakes still govern, and returns the base
unchanged when there are none, so a normal launch pays nothing.
Two invariants earned their keep while building this:
* The architecture test rejected the first draft — core/provider-def.ts
named ANTHROPIC_AUTH_TOKEN, leaking dialect into the pure layer. The
fix was the pattern already present: `knownCompatFlags` is injected for
that reason, so `credentialEnvs` is injected too and the runtime list
lives in the Claude Code adapter.
* Live testing caught `config list` reporting "not in this build" for a
provider `config doctor` resolved fine. The registry was composed in
planLaunch and doctor-root but not config-root — three call sites, one
forgotten. Composed once in runConfigCommand now, with a test asserting
that every surface agrees rather than asserting the fix.
Also exposes the full CLI surface to the API: the compat-flag catalogue
with each flag's variable and consequence, credential spellings, tiers,
agent capabilities, settings, and contextWindows (integers only — it feeds
CLAUDE_CODE_AUTO_COMPACT_WINDOW, where a wrong value overflows the
conversation instead of compacting it).
Installed agents are resolved through the SAME ProcessPort the launcher
uses, so "installed" means what it means at launch, overrides and
self-alias guard included. With no process port it reports null, never
`installed: false`, which would be a claim nobody checked. Deleting a
provider reports orphaned profiles rather than repairing them: only the
user knows where a profile should point next.
Signed-off-by: jellologic <[email protected]>
…dget `swisscode config web` now serves a real interface instead of the handshake-proving fallback: profiles, providers, agents, all four model tiers, compat flags and settings — everything the CLI can express. The whole toolchain is a devDependency and NONE of it ships. `files` is bin/dist/README, so users get emitted assets and nothing that produced them; runtime dependencies are still the same four, all UI-only. `npm audit --omit=dev` reports zero: the four moderate advisories are inside Panda's bundled MCP server, which never leaves this repo. Panda earns its place by being build-time only — it emits static CSS and ships no runtime, so a styling system could be added to a project whose pitch is four runtime dependencies without changing that number. The look is mostly restraint: a near-black surface ramp, hairline borders doing the structural work instead of shadows, three text steps, colour reserved for status. The UI carries no vocabulary of its own. Compat flags, tiers, credential spellings and agent capabilities all arrive from /api/bootstrap, so the page cannot drift from the adapter's tables and offer a flag that does nothing. A flag with a `consequence` renders what it costs, in the UI as well as on stderr. SIZE IS NOW A BUDGET, NOT A DRIFT. Shipping the SPA means every user downloads a React bundle they may never open — a deliberate trade, made once, and scripts/size-budget.js is what stops it being re-made a few kilobytes at a time by changes nobody weighed. It measures `npm pack` rather than the source tree, because what matters is what users download. 188 kB packed today against a 260 kB ceiling; raising it should be a visible line in a diff with a reason attached. Two build details that fail silently if you get them wrong, so both are commented where they live: Panda's codegen must run BEFORE vite (the generated styled-system is what the app imports), and its PostCSS plugin is what fills the @layer declarations — without it every build step reports success and the page renders completely unstyled. It did, once. Verified in a real browser: the SPA renders with zero console errors, the computed background is the exact token value, a planted key canary appears nowhere in the DOM, an invalid provider is refused with the validator's own reason, a valid one is created and merged into the picker, and the CLI then lists what the browser made. esbuild moved 0.25 -> 0.28 to satisfy Vite's peer range. It is a devDependency used only to bundle the Ink UI, and the three wizard suites drive that bundle, so a regression would have surfaced immediately. Signed-off-by: jellologic <[email protected]>
Completes CLI parity for the browser: the doctor runs on demand, the model picker browses the same catalogs the Ink wizard does, and the flag that claimed to control opening a browser now actually does. THE DOCTOR DEFAULTS TO OFFLINE, inverting the CLI deliberately. Running `config doctor` in a terminal is an explicit act; in a UI it is a button, and the probes are real billable inference requests. Opting IN to spending money is the only defensible default for something you click, so the live run is a separate button that says what it costs. The two routes that do I/O live in api-async.ts rather than api.ts. Widening the pure handler's signature to `ApiResponse | Promise<...>` so two endpoints could await would have pushed asynchrony into ~20 branches with no reason to know about it, and those branches being synchronous and pure is why every refusal in them is testable without a socket. The async module answers null for routes it does not own; the seam is one `if`. The picker keeps the two rules that belong to the DATA rather than to the widget: `tools` stays tri-state, so only a confirmed absence hides a row and UNKNOWN stays visible; and nothing missing is rendered as a number, because "$0.00" reads as free. Picking a model also captures its measured context window into contextWindows — that is the only moment the number is known, and it is what later lets swisscode set an auto-compact window without ever guessing one. Verified in a browser end to end: the doctor rendered 24 real checks with zero console errors, the picker showed 271 of 342 OpenRouter models (the 71 hidden are exactly the ones the README documents as lacking tool calling), picking claude-fable-5 stored both the id and its 1M window, and `config doctor` then read that profile back from the CLI. Also caught while testing: opening the picker on the `local` profile showed "could not fetch the catalog: catalog returned no usable models" — which is correct. Ollama was stopped, so its /api/tags catalog had nothing to serve, and the UI said why instead of showing an empty list. TWO TYPE ERRORS WERE HIDING IN web/. It has its own tsconfig, because Panda's generated styled-system must not enter the strict main program — which also meant `npm test` could not see it, and a missing @types/react-dom plus an invalid Vite option sat there undetected until it was run by hand. `typecheck:web` is now part of the gate. tsconfig.build.json's comment claimed the catalog/net/clock cluster was reached ONLY by the Ink wizard. web-root imports the same catalogs now, and an `exclude` cannot stop that: it filters the `include` globs, and a module reached through an import rejoins the program regardless. The comment says so rather than describing a world that ended. Signed-off-by: jellologic <[email protected]>
…le (v3) A Profile conflated three unrelated things: who pays (provider + credential + endpoint), what runs (agent + models + permissions + compat), and the pairing of the two. Every field sat flat on one object. The conflation already had a visible cost. `retargetProvider` existed purely to work around it: switching provider meant SCAVENGING every other profile for one that happened to hold a key for the target, then copying the credential, endpoint and models back out. A lookup written as a search, because there was nothing to look up. Now: providerAccounts who pays provider + credential + endpoint agentProfiles what runs agent + models + permissions + compat profiles the pairing references, plus a selection strategy `retargetProvider` is a filter over providerAccounts, and the credential-safety rule became STRUCTURAL rather than a copying discipline: an account names exactly one provider, so a key cannot reach a host it was not entered for without someone rewriting the account. THE SEAM: `ResolvedProfile` is deliberately the shape v2 stored, so everything downstream of resolution — the agent adapters, buildEnvPlan, buildIntent — consumes exactly what it always did. test/golden.test.ts passes COMPLETELY UNCHANGED, which is the proof the cut landed in the right place: the environment every provider hands to Claude Code is byte-identical. Migration is chained (v1 -> v2 -> v3) so a 0.1.0 file reaches v3 in one read with one implementation of each step. Lossless — unknown keys ride along on the agent profile, which is where v2's non-credential extras belonged — idempotent, and bindings/defaultProfile keep pointing at profile names so `swisscode <name>` and every directory binding survive untouched. Accounts are NOT deduped even when two obviously share a key: merging is irreversible and wrong the first time two accounts hold the same value for different reasons. SELECTION RESOLVES ONCE, BEFORE execve, and the strategies say so. `single` is the default and what migration produces. `round-robin` advances per launch via a cursor kept OUTSIDE config.json — the launch path writes no config, and a rotation counter is not configuration. `usage` reads a cached snapshot refreshed at configuration time, because the launch path may not reach the network; with no snapshot it falls back to `single` AND SAYS SO. Every degradation warns, because which account pays is the one thing that must never change quietly. Two invariants earned their keep. The architecture test rejected the first draft of core/provider-def.ts for naming ANTHROPIC_AUTH_TOKEN — dialect leaking into the pure layer — and the fix was the pattern already there: inject the list, as `knownCompatFlags` already was. And end-to-end testing caught a v2 file being backed up as `config.v1.bak.json`; the name is parameterised now, since it is the one moment that name matters. 598 tests pass, typecheck clean across src, test and web. Signed-off-by: jellologic <[email protected]>
Completes the v3 core: round-robin now actually rotates, the two new concepts are addressable from the CLI, and the module that decides which account pays finally has tests. THE CURSOR LIVES OUTSIDE config.json, in the XDG state directory. The launch path writes no config — test/core/overrides.test.ts asserts zero store.save calls — and a rotation counter is not configuration: nobody edits it, nobody backs it up, and losing it costs one repeated account rather than a broken setup. Keeping it out also keeps config.json free of a field that would churn on every launch and appear in every diff a user pastes into a bug report. Every operation in that adapter is best effort, and the consequence is stated rather than hidden: an unwritable state directory stops rotation advancing, which the profile banner makes visible because it names the account. A torn write is not guarded against because the worst outcome is an unparseable file that reads as "no cursor" and is overwritten next launch — unlike the config store, where a torn write would destroy an API key and every write is therefore atomic. A hand-edited negative or fractional cursor restarts the rotation rather than indexing an account list out of range. `config accounts` and `config agents` exist mostly for their REVERSE INDEX. A profile lists its accounts; nothing else could say which profiles an account backs, and that is the question you have before deleting one or rotating a key. `config agents` marks a shared agent profile as shared, because sharing is the capability the split bought and a listing that hid it would leave two identical setups indistinguishable from one. test/core/resolve.test.ts covers the happy path and, at more length, every way resolution degrades: a missing agent profile, an empty account list, a dangling reference (skipped with a warning, not fatal — a profile with three accounts and one stale reference should still launch), rotation with no cursor store, and `usage` with no snapshot. Both degradations warn, because silently always-first looks identical to a working rotation right up until a bill arrives. Verified across four real launches: or-main, or-spare, or-main, or-spare, with the cursor in the state dir and config.json untouched. 625 tests pass; tarball 210.7 kB against the 260 kB ceiling. Signed-off-by: jellologic <[email protected]>
…ve API
The "tools the AI provider gives us" half of the `usage` selection
strategy. CONFIGURATION-TIME ONLY: the launch path may not reach the
network, so nothing here is ever called during a launch — the doctor and
the web UI call it, and core/resolve.ts reads what they cached.
Verified against the live service before writing the adapter: OpenRouter
serves GET {baseUrl}/v1/key with limit / limit_remaining / usage. The URL
composes from the descriptor's own base URL so the two cannot drift.
It is the ONLY usage adapter shipped, and that is deliberate rather than
unfinished. No other preset was confirmed to publish anything equivalent,
and a provider that reports nothing must report nothing rather than a
plausible zero — the standard REJECTED_PROVIDERS and the Ollama work were
held to. A test asserts exactly one provider declares `usageId`, so adding
a speculative second one fails.
The nullability is load-bearing. OpenRouter uses `null` for "no limit", so
coercing it to 0 would make an UNCAPPED key rank LAST under a strategy
that picks the account with the most remaining — exactly backwards. A
payload with nothing usable returns null rather than a row of nulls,
because "this provider does not publish usage" and "this account has no
cap" are different answers and only one is worth rendering.
test/architecture.test.ts now names adapters/usage alongside adapters/ui
and adapters/catalog in the launch-path ban. An adapter that crept onto
that path would quietly turn every launch into an HTTP request to a
billing endpoint.
632 tests pass.
Signed-off-by: jellologic <[email protected]>
The browser UI now expresses all three concepts instead of the two-tab shape that predated the split. Tabs are ordered as the concepts compose: who pays, what runs, then the pairing. ACCOUNTS is the only screen that touches a credential, which is itself a benefit of the split — there is one type to get right rather than one field on a type carrying everything else. Write-only as before: the server sends `hasKey` and never the key, blank leaves the stored one alone, and clearing is a separate explicit action. AGENT PROFILES has no password field and no redaction to think about, because it holds no credential. It is the thing that can be SHARED, so the listing marks a shared setup and the editor warns before you change one that several profiles depend on. Its model picker borrows a provider from a profile that uses the setup — and when nothing does, it says there is no catalog to browse rather than offering a picker over a list it cannot obtain. PROFILES is now the pairing, and the only screen expressing MULTIPLE accounts. The strategy selector appears only once a second account is attached, and each option states its ceiling in the UI: round-robin advances per LAUNCH, not per request, because swisscode hands off and exits; `usage` reads the last measurement because it cannot check at launch. Better said in the place someone is choosing than in a doc they will not open. All three listings carry the REVERSE INDEX — which profiles use this account, which use this setup. A profile lists its references; only these views can answer the question you actually have before deleting one or rotating a key. Deleting reports what it breaks and repairs nothing: only the user knows where a profile should point next. Verified in a browser end to end: the three screens render, an account and an agent profile are created through the new routes, a profile naming a missing agent profile is refused with `no agent profile named "nope"`, a valid pairing is created, deleting an account reports the two profiles it affects, and a planted key canary appears in neither the payload nor the DOM. Also fixed while testing: `config list` counted a DANGLING account reference as "+1 more", promising a rotation partner that does not exist — confidently wrong about billing, which is the one thing that listing must not be. It now counts live accounts and prints the resolution warning, so a stale reference is visible instead of waiting to be found in the JSON. 632 tests pass; tarball 213.8 kB against the 260 kB ceiling. Signed-off-by: jellologic <[email protected]>
…ready has
An account has authenticated with a key up to now. A Claude Code subscription
has no key to carry, only a directory where the official OAuth flow already ran,
so `ProviderAccount` gains a third mode: `configDir`, mutually exclusive with
`apiKey`/`apiKeyFromEnv` and refused rather than silently preferred when both
are present.
The neutral vocabulary is a SESSION DIRECTORY, not CLAUDE_CONFIG_DIR — core/ and
ports/ may not name an agent's variables, and the v3 refactor learned that once
already with `credentialEnvs`. `LaunchIntent.sessionDir` carries it;
`adapters/agents/claude-code/env.ts` is the only place that lowers it. Kilo and
OpenCode declare `sessionDir: false` and warn at `high` rather than launching
unauthenticated — the capability-gap-never-silently-dropped rule that
`collapsedTierWarning` already follows.
A session launch clears BOTH ANTHROPIC_API_KEY and ANTHROPIC_AUTH_TOKEN. The
key-mode path only ever cleared the first, so a stale ANTHROPIC_AUTH_TOKEN in
the ambient environment survived into the child and could override the very
subscription the account names — a launch that silently bills elsewhere. That
earns a golden entry, since the golden maps exist for exactly this class of bug.
No new I/O: nothing here reads a credential, a keychain, or the network.
node bin/swisscode.js config accounts # two subscriptions, no keys
launch 1 -> personal | CLAUDE_CONFIG_DIR=personal | API_KEY cleared: true
launch 2 -> work | CLAUDE_CONFIG_DIR=work | API_KEY cleared: true
641 tests pass.
Signed-off-by: jellologic <[email protected]>
…h `login`
Phase 2. Two surfaces, no credential access anywhere in them.
`adapters/claude-session/identity.ts` reads `oauthAccount` out of `.claude.json`
— the agent's own bookkeeping — so listing every account's identity costs one
file read apiece and raises no Keychain prompt. Two things here were MEASURED
against the real file rather than assumed, and both assumptions were wrong:
- The plan tier is NOT `seatTier` or `userRateLimitTier`. Both are null on a
live Max 20x account. It is `organizationRateLimitTier`
("default_claude_max_20x"). Trusting the plausible-sounding fields would
have shown every user a blank plan.
- The config-file path asymmetry is real, and confirmed by running the agent
against a throwaway dir: CLAUDE_CONFIG_DIR unset puts it at ~/.claude.json,
a SIBLING of ~/.claude; set, it goes INSIDE at <dir>/.claude.json. There is
deliberately no fallback between them — reading the home file for a custom
directory would report the default account's identity for a directory that
is not it.
`config accounts login <name>` creates a 0700 directory, records the account,
and execve's the real binary at it. Swisscode never touches the OAuth flow: no
browser, no code, no token — it makes an empty directory and hands over the
terminal. It hands the agent a cleaned environment for the same reason a launch
does, since either credential variable would authenticate the login flow as
somebody else and make `/login` appear to do nothing. `--dir` adopts an existing
directory, so the default ~/.claude becomes account #1 with no re-login.
Mostly it refuses: path-escaping names, retargeting an account that already has
a login, and giving a key-mode account a session as well.
`test/architecture.test.ts` now holds adapters/claude-session off the launch
path, beside the catalogs and usage adapters — a launch lowers a session
directory to an env var without ever opening it.
Verified live against the real account:
config accounts login default --dir ~/.claude
warning — /Users/…/.claude is readable by other users (mode 755)
Account "default" already logged in as [email protected] · Max 20x
config accounts
default login [email protected] · Max 20x
spare login not logged in → names its own fix
api key stored in config.json (0600) ← key itself never printed
665 tests pass; launch path unchanged at 220.5 kB packed.
Signed-off-by: jellologic <[email protected]>
…IR, not write it The plan claimed the default ~/.claude "becomes account #1 with no re-login". That was false, and testing it rather than trusting it is the only reason this was caught before it shipped. Claude Code picks which Keychain item holds the credential from WHETHER CLAUDE_CONFIG_DIR IS SET — it hashes the path into the service name whenever it is — so the default directory has two different credentials depending on how you arrive at it. Measured on this machine: claude config ls -> logged in CLAUDE_CONFIG_DIR=$HOME/.claude claude config ls -> "Not logged in" Same directory, same file, different login. And the failure is quiet in the worst way: identity comes from `.claude.json`, which really IS shared, so adopting ~/.claude reported the correct email — "already logged in as [email protected] · Max 20x" — while producing a session that could not authenticate at all. So an account naming the default directory now lowers to CLEARING the variable (including a stale ambient one), which is what makes adopting an existing login actually work. `config accounts login --dir ~/.claude` returns immediately with nothing to do rather than sending the user through a second /login that would replace the login being adopted. `isDefaultConfigDir` lives in claude-code/env.ts rather than in a module of its own: it is an env-lowering question, and the launch path is held under 40 modules so it stays auditable in a sitting — a budget the first attempt at this tripped, which is the budget working. Also drops the permissions warning for the default directory. Claude Code creates ~/.claude at 0755 itself; warning about something swisscode neither made nor worsened, on a stock install, teaches people to skip warnings. `config doctor` is where a pre-existing permissions problem belongs. config accounts login default --dir ~/.claude adopted your existing login: [email protected] · Max 20x resulting launch: CLAUDE_CONFIG_DIR set to: (not set — correct) unset: CLAUDE_CONFIG_DIR, ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN 670 tests pass. Signed-off-by: jellologic <[email protected]>
Phase 3. Three modules, each verified against the live service rather than
against the plan's description of it.
`claude-session/credentials.ts` reads the OAuth token for a session directory
and does nothing else with it: never refreshes (that needs Anthropic's own
client id — the impersonation line this design stays behind), never writes, and
never carries the refresh token at all, so nothing downstream is even tempted.
`/usr/bin/security` by absolute path, since PATH is attacker-influenced on a
shared machine and this command's whole job is handling a secret. A dismissed
Keychain prompt reports as `denied`, distinct from `absent` — they send the user
to fix completely different things. Verified live: the derived service name
matched the real item exactly, and read back subscriptionType 'max'.
`usage/anthropic-subscription.ts` asks GET /api/oauth/usage. Ranks on the WORSE
of the two windows, never the average: an account 10% into its 5-hour window and
95% into its weekly one has almost nothing left, and averaging to 52% would send
work straight at it. Verified live — 37% / 73% used, ranked as 27% remaining.
Two things the real payload corrected:
- It is `extra_usage.is_enabled`, not `.enabled`. The wrong name fails
SILENTLY and permanently: it yields null, indistinguishable from a field the
endpoint did not publish, so the feature would simply never have worked.
- There are per-model weekly windows (`seven_day_opus`, `seven_day_sonnet`),
both null on this account. They are reported but NOT ranked on — an
exhausted Opus window should not disqualify an account for a profile that
runs Sonnet, and ranking that properly needs a fact this adapter does not
have.
`store/fs-usage-store.ts` CLOSES A LOOP THAT WAS OPEN: `core/resolve.ts` has
read a UsageSnapshot since the v3 split, but nothing ever wrote one, so the
`usage` strategy always took its documented fallback. This fixes OpenRouter's
`usage` selection too — the gap was never provider-specific. 12-hour TTL,
because a figure older than that describes a 5-hour window that has since rolled
over; an expired snapshot reads as ABSENT rather than as zero.
Writing the port's own contract down in a test caught the adapter violating it:
"published nothing usable" must return null, not an all-null object that every
caller's null check would read as a successful measurement.
707 tests pass; launch path unchanged at 228.2 kB packed, well inside budget.
Signed-off-by: jellologic <[email protected]>
Phase 3's modules existed but nothing called them, so the `usage` strategy still
could not work. This connects the two ends.
`swisscode config accounts usage` measures every account once and caches the
result — the command that replaces switching into each account to run `/usage`.
Reads are SEQUENTIAL, not parallel: each subscription read can raise a Keychain
prompt, and three dialogs at once is worse than waiting. A key-mode account is
reported as having no subscription window rather than being silently skipped,
because "nothing shown" and "nothing to show" look identical otherwise.
`planLaunch` now reads the snapshot. Reads only — refreshing means the network,
which the launch path is banned from reaching, so a stale or missing snapshot
degrades to the first account and says so.
The module-count ceiling went 40 -> 42 for the usage store. Stated deliberately
in the test: it is the `usage` strategy's exact counterpart to the rotation
cursor already on that path, and selection cannot honour a strategy whose input
it cannot see. The budget is doing its job either way — it turned away a module
earlier this phase, which is how `isDefaultConfigDir` ended up in the env
lowering that uses it instead of getting a file of its own.
Verified live, end to end:
config accounts usage
default ([email protected] · Max 20x)
remaining 27% (the tighter of the two windows)
5-hour 0% used, resets 7/22 4:10 PM
7-day 73% used, resets 7/25 1:00 PM
spare (not logged in) — could not be measured
key — key account, no subscription window
planLaunch with strategy: usage
selected account: default
session dir : (unset — the default login)
[medium] selected "default" by remaining capacity, measured 1 minute(s) ago
707 tests pass.
Signed-off-by: jellologic <[email protected]>
…e snapshot `core/resolve.ts` has been telling users "Run `swisscode config doctor` to refresh usage" since the usage strategy shipped. The doctor had no idea what a snapshot was, so that advice sent them in a circle. It now measures and writes one. Two checks: - `session login` — who a config dir is logged in as, or a warning that distinguishes "never used" from "used but logged out". Costs a file read, so it runs under --offline. This is the one failure the launch path cannot catch by design: it never opens the directory it points at, so a dir nobody has logged into surfaces as the agent's own login prompt AFTER execve, with swisscode gone and unable to explain. - `usage snapshot` — measures the accounts a `usage`-strategy profile names, writes the snapshot, and names any account it could not measure. Scoped to those accounts rather than every account on the machine because each measurement can raise a Keychain prompt, and prompting for figures nothing reads is a cost with no answer attached. Nothing measurable leaves the cached snapshot alone rather than clearing it. The measurement loop moves to adapters/usage/measure.ts, shared with `config accounts usage`. It looks trivial enough to copy, which is why it is not: sequential-not-parallel, key accounts reported rather than failed, and identity read once are all invisible in the shape of the code and would drift apart between two copies. `runDoctor` gains `usageFetch`, injected in tests. Unlike `probe` and `ollama` this is not a convenience — the refresh reaches api.anthropic.com by default, so without it a doctor test would make a real request against whatever credential the machine holds. Verified live against a temp config: session login read as "<email> · Max 20x", the measurable account written to the snapshot at 26%, the nonexistent one named as unmeasured, and the endpoint probe skipped — so no inference was billed. 722 tests, 232.0 kB packed. Signed-off-by: jellologic <[email protected]>
The Accounts screen could only express an API key, so a session-mode account created from the CLI rendered as "no key" — a working account that looked broken. - Bootstrap carries `logins`: who each session account is logged in as, read from `.claude.json`. A file read apiece, no credential and no Keychain, so it is safe on every cold start. Null when unwired rather than an empty map, which would be indistinguishable from "every account is logged out" and would send a user to re-run `/login` on accounts that are fine. - `POST /api/usage` measures every account and caches the snapshot. POST rather than GET because each measurement can raise a Keychain unlock dialog on macOS, and a GET is something a browser may prefetch, retry or replay on its own initiative. Measuring is a button for the same reason — a screen that measured on load would pop dialogs for opening it. - The credential panel becomes a two-mode switch. Switching to session mode sends an explicit `apiKey: null`, because the server refuses an account holding both — correctly — so clearing the old key is what makes the switch expressible at all. - The screen says the one thing it cannot do: `/login` is an interactive flow in the agent's own TUI and a browser tab cannot drive it. Pointing an account at an empty directory is legal here and silently useless, so the terminal command is named rather than implied. The status dot for a session account tracks the LOGIN, not the path. A directory nobody has logged into is indistinguishable in config.json from one that works, and fails only after execve. Verified live via Playwright against a temp config with a planted `sk-ant-secret-canary` key: absent from the bootstrap payload and the DOM, still present in config.json. Measured default at 26% (5h 6%, 7d 74%), "spare" reported as unmeasurable rather than zero, the key account reported as having no window, and the snapshot written to the state dir. 727 tests. Signed-off-by: jellologic <[email protected]>
…tory
`/login` writes a single global slot that every running Claude Code re-reads
within 30s, so switching accounts for one terminal switches all of them, and an
artifact created after the switch lands on whichever account happened to win.
`swisscode config accounts swap --into <account-or-dir> <account>` writes one
directory's slot. Sessions on other directories are untouched.
THE KEYCHAIN WRITE DOES NOT WORK, AND FINDING OUT REQUIRED A REAL MACHINE.
`security add-generic-password` takes a secret two ways and both are unusable:
-w <value> puts the token in argv, visible via `ps` to every user on the
box for the life of the process.
-w (stdin) prompts, asks twice, and TRUNCATES AT 128 BYTES. Measured: 500
bytes in, 128 stored, exit 0, no warning.
The first draft shipped the stdin form. Its unit tests passed — a fake
`security` has no buffer — and a live swap wrote a 128-byte fragment of a
3.9 kB credential. So the credential is written as a 0600 file in a 0700
directory instead, on every platform. That path is not a fallback: verified on
macOS by pointing the agent at a directory containing only that file, which
authenticated, against an empty one, which printed "Not logged in". Any
competing keychain item for the target is dropped afterwards, so exactly one
stored credential remains and it is the one just written.
The blob moves verbatim as opaque bytes. `SessionCredential` drops
`refreshToken` by design, but a swap carrying only the access token would hand
the target a login that dies at the next refresh — hours later, far from the
cause. The regression test uses a 2 kB fixture precisely because the
128-byte-fixture version of it passed against broken code.
Also copies the source's `oauthAccount` into the target's `.claude.json`,
merging rather than replacing: without it the token is the new account's while
`/status` still names the old one, which is the silently-wrong-account
confusion this feature exists to end. Reported when it fails rather than
swallowed.
Overwriting a DIFFERENT login requires --yes, and prints both identities.
Verified live: 3902 bytes in, 3902 out, byte-identical, refreshToken preserved,
`Claude Code-credentials` modification date unchanged throughout, target 0700 /
file 0600, and the swapped directory measured against Anthropic's usage
endpoint reporting the same window as the source — proof the moved credential
authenticates. Every scratch credential written during verification has been
deleted.
740 tests, 236.0 kB packed.
Signed-off-by: jellologic <[email protected]>
… rules The docs had no idea session mode existed, and the config example was still v2 — a flat profile carrying its own key and models, a shape the code stopped writing two releases ago. README gains a Claude subscription accounts section: login, adopt, measure, select by remaining capacity, swap. It leads with the trap, because the trap is silent — naming `~/.claude` must UNSET `CLAUDE_CONFIG_DIR` rather than write it, since Claude Code branches on whether the variable is set rather than on its value, and identity comes from a shared file, so getting it wrong reports the correct email for a session that cannot authenticate. It also states plainly where this sits with Anthropic, which the plan committed to and which users are entitled to decide for themselves: rotating between subscriptions you pay for is fine and sharing one is not; swisscode is structurally incapable of the second because it never sits in the request path; per-launch selection is well-trodden while `swap` is not explicitly blessed; and `claude -p` bills a credit pool, so the 5-hour figure describes interactive sessions. SECURITY.md gains the two properties that matter most about the new code: which module may read a credential and which single module may write one, and why a moved credential is a 0600 file rather than a keychain item — `security add-generic-password` either puts the secret in argv, where `ps` shows it to every user on the machine, or truncates stdin at 128 bytes against a ~3.9 kB credential. ARCHITECTURE and AGENTS were stale on two facts unrelated to this work: four composition roots when there are five, and a 40-module launch ceiling that is now 42. Both corrected, along with the launch-path exclusion list. The config example is now v3, with a note on why it is three things rather than one — an agent profile shared by profiles that bill different accounts is exactly what the flat shape could not express. Signed-off-by: jellologic <[email protected]>
AGENTS.md checklist item 3, violated by the doctor session tests I just added. `makeState` / `makeAccount` / `makeAgentProfile` / `makeProfileRefs` exist so a fixture that drifts from the type is a compile error rather than a cast that keeps compiling while the shape underneath it moves. The deps() cast turned out to be unnecessary too — the object structurally satisfies LaunchDeps, so it is gone rather than narrowed. Signed-off-by: jellologic <[email protected]>
Three independent cuts, each measured before and after. **Comments stripped from emitted JS** (128 kB of 508 kB in dist/). This codebase carries a heavy comment load on purpose and was shipping every one of them compiled — prose nobody reads in that form, a `git clone` away from anyone who wants it. The property build.js actually claims is untouched: dist/ is still a tree of individual modules, one per source file, with no bundler in the way. Auditing structure is unaffected; only the prose is gone. **dist/ui.js minified** (74.5 → 38.2 kB). It is already a single opaque blob that nothing on the launch path may reach and nobody reads module by module, so the auditability argument that keeps dist/ unbundled does not apply to it. **react-dom → preact/compat in the browser bundle** (237.9 → 69.8 kB, the largest single win). The SPA is seven screens using useState/useEffect/useMemo/ useCallback — no concurrent rendering, no suspense, no server components — so 168 kB of react-dom bought almost nothing. The alias lives in vite.config.ts: sources still import `react` and still typecheck against @types/react, and reverting is deleting one block. Unrelated to the `react` in `dependencies`, which is Ink's and renders the terminal wizard. Verified in a browser rather than assumed, since preact/compat differs from React exactly where this app lives: navigation, the usage measurement round trip, session-mode detection on the account form, controlled inputs, and a save that persisted the edit while preserving configDir and leaving the other account's key alone. Also dropped @tanstack/react-router and @tanstack/react-start, which no file has ever imported — they shipped nothing but implied an architecture that does not exist. Size ceiling lowered 260 → 150 kB. A budget with more slack beneath it than artifact above it is not a budget. 740 tests. Signed-off-by: jellologic <[email protected]>
…0.4 does **Bun.** `bunx swisscode` and `bun install -g swisscode` both work, and nothing had to change for them: swisscode is plain ESM with no native addons, and `process.execve` — the call that makes handoff free — exists on Bun as well as Node. Verified rather than assumed, by resolving a full launch plan under both runtimes and diffing the result: identical profile, agent, provider and `ANTHROPIC_*`/`CLAUDE_CODE_*` set. The only differences were in the ambient environment each runtime happened to be started with (`_`, `NODE_OPTIONS`), which swisscode observes rather than decides. `config list` and `config doctor --offline` also run clean under Bun. Deno is untested and said to be untested. **Metadata.** Both descriptions predated the entire subscription feature — they sold a provider switcher, which is now half of what this is. The npm description was also 313 characters, so npm's ~250-char cut was eating the differentiators off the end; it is 255 now with the strongest terms first. npm keywords gain claude-max, claude-pro, subscription, multiple-accounts, account-switcher, usage-limits, rate-limits, claude-usage and bunx, and lose seven weak ones (ai, agentic, self-hosted, offline-ai, local-ai, session, anthropic-api) — 39 keywords dilute the strong ones rather than adding reach. GitHub topics are capped at 20 and were already at 20, so this is a swap: kimi, qwen and agentic-coding out (the first two are reachable through OpenRouter rather than presets) for claude-max, account-switcher and usage-limits. Signed-off-by: jellologic <[email protected]>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds Claude Pro/Max subscriptions as a first-class credential mode, alongside the API keys swisscode already handled. You can hold several logins, see how much of each is left, launch on whichever has the most capacity, and move one into a running session's directory without disturbing the others.
Also halves the published artifact on the way past.
What this adds
A profile with
"strategy": "usage"then launches on whichever account has the most left, ranking on the tighter of the two windows — an account at 5% of its 5-hour window and 95% of its weekly one has almost nothing left, and averaging would send work straight at it.config doctorgains asession logincheck and now actually refreshes the usage snapshot. That was a promise the code already made:core/resolve.tshas been printing "Runswisscode config doctorto refresh usage" since the usage strategy shipped, and the doctor had no idea what a snapshot was.The web UI's Accounts screen gains session mode and a measure button.
Three plan assumptions that measurement falsified
Each would have shipped a silent failure:
~/.claudecould not be adopted as designed. Claude Code picks its keychain item on whetherCLAUDE_CONFIG_DIRis set, not its value — soCLAUDE_CONFIG_DIR="$HOME/.claude"is a different, empty login. Verified: plainclaudeis logged in, the same command with that variable set reports "Not logged in". It failed quietly, because identity comes from a shared~/.claude.json: the first build printed the correct email for a session that could not authenticate. Naming the default now unsets the variable.organizationRateLimitTier, notseatTieroruserRateLimitTier— bothnullon a live Max 20x account. The plausible names would have blanked the plan for every user.extra_usage.is_enabled, not.enabled. The wrong name yieldsnullforever, indistinguishable from a field the endpoint never published.The bug only a real machine could find
The swap first wrote the credential to the keychain via
security add-generic-password, secret on stdin to keep it out ofps. Unit tests passed. The live swap wrote 128 bytes of a 3902-byte credential — the interactive prompt truncates at 128 and exits 0 with no warning. A fakesecurityhas no buffer, so the tests were structurally incapable of catching it.securityoffers no safe alternative (-w <value>puts the token in argv). So the credential is a0600file, which Claude Code demonstrably reads on macOS — verified by pointing the agent at a directory containing only that file (authenticated) against an empty one ("Not logged in"). Any competing keychain item is dropped so exactly one credential remains. The regression test uses a 2 kB fixture, because a small one passes against the broken code.Size: 239.9 kB → 118.4 kB packed
react-dom→preact/compatin the browser bundledist/ui.jsminified@tanstack/*devDepsThe preact swap is the only one that could break something, so it was verified in a browser: navigation, the usage round trip, session-mode detection, controlled inputs, and a save that persisted while preserving
configDir. The alias is one block invite.config.ts; reverting is a deletion. Size ceiling lowered 260 → 150 kB, since a budget with more slack than artifact guards nothing.Verification
Verified live against a real Max 20x account: identity, both windows,
planLaunchselecting on the measured figure, the doctor writing the snapshot, and a swap moving 3902 bytes byte-identically withrefreshTokenintact whileClaude Code-credentialswent untouched throughout. Every scratch credential written during verification was deleted. The web UI was driven through Playwright with a plantedsk-ant-*canary, absent from both payload and DOM.740 tests. Docs updated: README (subscription accounts, the v3 config shape), SECURITY (which module may read a credential and which single one may write), ARCHITECTURE and AGENTS (five composition roots, not four; 42-module launch ceiling, not 40).
Known gaps
/logininto a second directory.swapis the one part not explicitly blessed by Anthropic. It does programmatically what/logindoes by hand, with accounts you pay for, and the README says so plainly rather than leaving users to infer it.Correction (added after merge). This PR body originally stated that the v3
config schema had already shipped in 0.3.0 and that therefore no config could
break. That was wrong:
v0.3.0contains no reference toproviderAccounts—the v3 schema ships in 0.4.0, in this PR.
Upgrades are still safe and 0.4.0 is still the right version: the v2→v3
migration runs automatically on first read and keeps the original as
config.v2.bak.json. What is not true is the claim that downgrading cannotbreak. A 0.3.0 binary reading a v3 config reports
provider undefined. Itrefuses to write over the newer file (
core/migrate.ts:368), so no data islost, but the tool looks broken.
This is not hypothetical — it is reproducible today via
bunx swisscode, whichserves a stale cached version while
npx swisscoderesolves the latest. See theBun note in the README.