Skip to content

Account model: dedup guard, orphan fix, setups rename, wizard subscriptions#33

Merged
jellologic merged 6 commits into
mainfrom
feat/account-guards
Jul 24, 2026
Merged

Account model: dedup guard, orphan fix, setups rename, wizard subscriptions#33
jellologic merged 6 commits into
mainfrom
feat/account-guards

Conversation

@jellologic

Copy link
Copy Markdown
Owner

Started from a real trap: after config accounts login ezra.spero, typing swisscode ezra.spero sent the account name to Claude Code as a prompt. Asked to describe the model back, the user had three of the four concepts wrong. None of that was a user failure — it was the product carrying two mental models and calling orphaned accounts "ready to use". Six commits, each self-contained.

The model, after this

What it is
Provider an endpoint dialect 8 built in
Account who pays — provider + credential
Setup what runs — which CLI, models, perms (was agentProfile)
Profile the pairing — setup + accounts the only one swisscode <name> launches

Commits

  1. Detect accounts that are secretly one subscription. A fresh CLAUDE_CONFIG_DIR comes up already logged in as the login in use, so login + exit-without-/login produces two names for one quota. identityCollisions in core/account.ts owns the rule; CLI listing, doctor and web all consult it. Also confirms both branches of keychainService() against Claude Code v2.1.218.
  2. Refuse a positional that names an account or setup. swisscode ezra.spero errors with the fix instead of firing the name as a prompt. Narrow: exact match against a name in the config only, so swisscode fix the login bug is untouched.
  3. accounts login produces something launchable. Mints the 1:1:1 account+setup+profile the wizard already produces. Refuses where guessing would change who pays; --no-profile opts out. Doctor grows an accounts reachable check.
  4. Rename agentProfilessetups (schema v4). Only the one nobody types; profiles keeps its name. Migration ladder v1→v2→v3→v4, each step stamping its own version. Fixed two latent bugs it surfaced: fromV2 stamping SUPPORTED_VERSION (would skip v3→v4), and fromV3 rebuilding state (broke rule W3, dropping unknown top-level keys).
  5. Teach the wizard subscriptions. Anthropic asks how the account pays: subscription-kept-separate / existing-login / API key. Adds ProviderDescriptor.sessionCapable, replacing an inference the browser had been making. Handoff prints the /login instruction after Ink unmounts.
  6. Put the concept map in the product. Same table + tree in the README, config help, and a collapsed web panel — one wording so they cannot drift.

Compatibility

Schema v4 migrates v1/v2/v3 on first read and writes a config.v*.bak.json. config agents still dispatches as a deprecated alias for config setups.

Verification

806 tests, both typechecks clean. Beyond the suite, measured on real artifacts: a v3 file migrating on disk (setup preserved across both profiles, unknown key intact, backup written); the wizard's subscription branch storing configDir and no key in a 0700 directory with nothing written into it; the web panel's white-space: pre surviving to rendered pixels and scrolling in its own container at 420px, both themes, no console output.

Pointing Claude Code at a fresh CLAUDE_CONFIG_DIR does not start it logged
out: the directory comes up already authenticated as the login already in
use, and a Keychain item appears under that directory's hashed service name
at the same moment. Measured on a real machine — a directory created at
08:26 held a complete identity by 08:27 with no /login performed.

So `config accounts login <name>`, then exiting WITHOUT running /login,
produces a second account that reports its own email and plan and is in
every way a clone of the first. Nothing downstream could tell: the listing
showed two healthy Max accounts, and the `usage` strategy treated one quota
as two independent budgets and rotated between them for no benefit.

identityCollisions() in core/account.ts owns the rule; the CLI listing, the
doctor, `accounts login` and the web UI all consult it rather than deciding
separately — the divergence that module exists to end. Matching is by the
strongest available key: a shared configDir (which catches the mistake
before anyone has logged in, where no identity check can see it), then
accountUuid, then email. Accounts with no identity never collide with each
other, or the warning would fire on every freshly created pair.

`accounts login` now says the directory will look logged in BEFORE handing
off, since afterwards this process has execve'd away and the surprise lands
inside someone else's UI.

Also confirms both branches of keychainService() against Claude Code
v2.1.218 — the hashed branch was previously unverifiable without a real
/login into a custom directory, and the derivation turns out to be exact.

Signed-off-by: jellologic <[email protected]>
`swisscode ezra.spero`, run right after `config accounts login ezra.spero`,
sent the account name to Claude Code as the first word of a prompt and
billed a real request against whichever profile happened to be the default.
No error, no warning — the user just watched it happen.

The fallthrough itself is right and stays: an unknown positional is almost
always the start of a prompt, which is what makes `swisscode fix the login
bug` work. What was missing is that a token naming something the config
already knows is not unknown. It is a mis-aimed selection.

So the check is narrow by construction: it fires only on an exact match
against an account or agent-profile name in this config, which is a fact
about the config rather than a guess about the string. Ordinary prompts are
untouched unless their first word is literally the name of something the
user created, and the refusal names both ways forward — make a profile that
uses it, or send it through with `swisscode -- <word> …`.

Tested against the regression that would matter most: fix, why, refactor,
spares and z-ish all still reach the default profile and still reach claude.

Signed-off-by: jellologic <[email protected]>
"Agent profile" and "profile" were two names one word apart for two
different things, and users read the pair exactly backwards. Asked to
describe the model, one described agent profiles as "claude code settings
overrides… optional". They are neither: a setup picks WHICH CLI RUNS, and
every profile must name one.

One of the two had to lose the word, and it was not going to be the one
people type. `profiles` keeps its name — it is what `swisscode <name>`
selects, what --cc-profile names, what bindings point at, and what
config default/rm/use operate on. Renaming the other one costs half the
blast radius and buys all of the clarity, because once only one thing is
called a profile, "profile" is unambiguous.

  State.agentProfiles      -> State.setups
  Profile.agentProfile     -> Profile.setup
  type AgentProfile        -> type Setup
  ResolvedProfile.agentProfileName -> setupName
  config agents            -> config setups  (old name still dispatches)

The migration is a pure rename and the ladder grew a rung: v1 -> v2 -> v3
-> v4, each step stamping its OWN output version. fromV2 was stamping
SUPPORTED_VERSION, which fromV1's comment had warned about since before a
v4 existed — the moment the constant became 4, that would have skipped the
v3->v4 step entirely. It is a literal 3 now.

fromV3 spreads the source object rather than rebuilding it, because
rebuilding silently broke rule W3: a top-level key written by a future
swisscode vanished on the first save by this one, which is data loss
wearing a rename's clothes. Caught by the round-trip test, not by review.

Verified end to end on a real v3 file: version 4 on disk, agentProfiles
gone, both profiles still resolving one shared setup, the unknown
top-level key intact, and config.v3.bak.json holding the original.

Signed-off-by: jellologic <[email protected]>
The command recorded an account, printed "Nothing else to do — this account
is ready to use", and stopped. That sentence was false. An account is not a
thing you can launch; a profile is, and nothing referenced the new account.
The first thing anyone did next was type `swisscode <account-name>` and
watch the name go to the agent as a prompt — which is how this whole thread
started.

So login now mints the same 1:1:1 shape the wizard already produces: an
account, a setup and a profile sharing one name. That is also what the
v2->v3 migration produces, so a new install ends up in one arrangement
rather than two.

It refuses rather than improvises where guessing would change what a launch
bills: a profile of that name that does not already name this account is
left alone (adding the account would change who pays for it), and a name
that cannot legally be a profile is skipped with the reason — an account may
be called `fix`, but a PROFILE called `fix` would swallow `swisscode fix the
login bug`. An existing setup of the same name is reused, never overwritten,
because setups are shareable and clobbering one re-points every profile
using it.

`--no-profile` keeps the old behaviour for the one case that wants it.

Every remaining way to end up unreachable — hand-edited config,
--no-profile, accounts created before this — is now named where it can be
seen: `config accounts` says NOTHING CAN LAUNCH THIS instead of the far too
mild "used by — nothing", and the doctor grew an `accounts reachable` check.

Also fixes the flag parser this needed: it treated every `--flag` as
value-taking, so `login --no-profile personal` would have silently eaten the
account name.

Signed-off-by: jellologic <[email protected]>
The wizard wrote `apiKey` unconditionally, so it could not express a Claude
Pro/Max account at all. Subscription users were pushed onto the raw
four-noun path in the web UI — the harder one — which is a large part of why
the model felt confusing: the easy flow could only do the thing they were
not doing.

Picking a session-capable provider now asks how the account pays, and the
three answers are the point:

  A Claude subscription, kept separate    -> its own directory, so several
                                             logins can exist at once
  The Claude login I already use          -> whatever plain `claude` uses
  An API key                              -> the old path

The first two look identical from outside and are not, and collapsing them
is what made multi-account setup feel impossible. Naming both is the fix.

The handoff needed no new machinery: cli.ts already runs the wizard and then
launches, so an account carrying a fresh configDir lands the user inside
Claude Code in that directory — exactly where /login is run. The note saying
so is printed from runUi AFTER Ink unmounts, because anything written during
a render is cleared or interleaved by it.

Also adds ProviderDescriptor.sessionCapable and sets it on anthropic. The
browser had been inferring this as `baseUrl === null && !askBaseUrl`, which
is true of Anthropic by accident rather than by statement; the wizard would
have been a second copy of that guess and account validation a third, which
is the divergence core/account.ts exists to end.

The directory is created at 0700 by the wizard, matching `accounts login`,
rather than left for the agent to create with default permissions — it is
about to hold a login. Asserted in the new test, along with the thing this
design refuses to do: swisscode names the directory and writes no credential
into it.

Signed-off-by: jellologic <[email protected]>
Four concepts, and the relationship between them was written down nowhere.
Each screen carried its own one-liner — "who pays", "what runs", "the
pairing" — but no screen is in a position to say how they connect, and the
README's opening line still described the v2 model ("a profile is a named
provider + key + models"), which has not been true since v3.

The result was a user who could name all four and had three of them wrong:
providers read as "endpoints outside the standard ones… optional", setups as
"Claude Code setting overrides… optional". Neither is optional and neither
is what it sounded like.

So the same map now appears in all three places someone might look: a table
and a tree at the top of the README, four lines above the subcommand list in
`config help`, and a collapsed "How these fit together" panel on the web
Profiles screen. One wording, so they cannot drift.

The panel is collapsed by default because it is onboarding rather than
working state — needed once, in the way afterwards.

Verified by measuring rather than by typechecking, which this repo has been
caught by twice: white-space: pre survives to the rendered tree, it sits in
monospace, and at a 420px viewport the tree scrolls inside its own container
while the page does not scroll sideways. Both themes, no console output.

Signed-off-by: jellologic <[email protected]>
@jellologic
jellologic merged commit 48c41aa into main Jul 24, 2026
4 checks passed
@jellologic
jellologic deleted the feat/account-guards branch July 24, 2026 22:32
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