Skip to content

Claude subscription accounts: select, measure and swap — plus a web UI and a half-size artifact#25

Merged
jellologic merged 21 commits into
mainfrom
feat/web-ui-backend
Jul 22, 2026
Merged

Claude subscription accounts: select, measure and swap — plus a web UI and a half-size artifact#25
jellologic merged 21 commits into
mainfrom
feat/web-ui-backend

Conversation

@jellologic

@jellologic jellologic commented Jul 22, 2026

Copy link
Copy Markdown
Owner

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

swisscode config accounts login work        # make a session dir, /login once inside
swisscode config accounts                   # who each account is — no keychain prompt
swisscode config accounts usage             # every subscription's real 5h/7d windows
swisscode config accounts swap --into work personal

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 doctor gains a session login check and now actually refreshes the usage snapshot. That was a promise the code already made: core/resolve.ts has been printing "Run swisscode config doctor to 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:

  1. The default ~/.claude could not be adopted as designed. Claude Code picks its keychain item on whether CLAUDE_CONFIG_DIR is set, not its value — so CLAUDE_CONFIG_DIR="$HOME/.claude" is a different, empty login. Verified: plain claude is 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.
  2. The plan tier is organizationRateLimitTier, not seatTier or userRateLimitTier — both null on a live Max 20x account. The plausible names would have blanked the plan for every user.
  3. It is extra_usage.is_enabled, not .enabled. The wrong name yields null forever, 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 of ps. 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 fake security has no buffer, so the tests were structurally incapable of catching it.

security offers no safe alternative (-w <value> puts the token in argv). So the credential is a 0600 file, 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

Cut Saving
react-dompreact/compat in the browser bundle 168 kB
Comments stripped from emitted JS 128 kB
dist/ui.js minified 36 kB
Dropped never-imported @tanstack/* devDeps 0 shipped

The 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 in vite.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, planLaunch selecting on the measured figure, the doctor writing the snapshot, and a swap moving 3902 bytes byte-identically with refreshToken intact while Claude Code-credentials went untouched throughout. Every scratch credential written during verification was deleted. The web UI was driven through Playwright with a planted sk-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

  • The hashed-keychain branch is unexercised. It follows Claude Code's rule and matters less now that swaps write files, but confirming it needs a real /login into a second directory.
  • Live verification was macOS-only. The Linux file paths are tested through injected fakes but have never run against a real Claude Code install — this PR is the first time CI exercises them.
  • swap is the one part not explicitly blessed by Anthropic. It does programmatically what /login does 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.0 contains no reference to providerAccounts
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 cannot
break. A 0.3.0 binary reading a v3 config reports provider undefined. It
refuses to write over the newer file (core/migrate.ts:368), so no data is
lost, but the tool looks broken.

This is not hypothetical — it is reproducible today via bunx swisscode, which
serves a stale cached version while npx swisscode resolves the latest. See the
Bun note in the README.

`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]>
@jellologic
jellologic merged commit 2d7ba92 into main Jul 22, 2026
2 of 4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant