Account model: dedup guard, orphan fix, setups rename, wizard subscriptions#33
Merged
Conversation
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 <31935831+jellologic@users.noreply.github.com>
`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 <31935831+jellologic@users.noreply.github.com>
"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 <31935831+jellologic@users.noreply.github.com>
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 <31935831+jellologic@users.noreply.github.com>
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 <31935831+jellologic@users.noreply.github.com>
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 <31935831+jellologic@users.noreply.github.com>
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.
Started from a real trap: after
config accounts login ezra.spero, typingswisscode ezra.sperosent 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
agentProfile)swisscode <name>launchesCommits
CLAUDE_CONFIG_DIRcomes up already logged in as the login in use, sologin+ exit-without-/loginproduces two names for one quota.identityCollisionsincore/account.tsowns the rule; CLI listing, doctor and web all consult it. Also confirms both branches ofkeychainService()against Claude Code v2.1.218.swisscode ezra.speroerrors with the fix instead of firing the name as a prompt. Narrow: exact match against a name in the config only, soswisscode fix the login bugis untouched.accounts loginproduces something launchable. Mints the 1:1:1 account+setup+profile the wizard already produces. Refuses where guessing would change who pays;--no-profileopts out. Doctor grows anaccounts reachablecheck.agentProfiles→setups(schema v4). Only the one nobody types;profileskeeps its name. Migration ladder v1→v2→v3→v4, each step stamping its own version. Fixed two latent bugs it surfaced:fromV2stampingSUPPORTED_VERSION(would skip v3→v4), andfromV3rebuilding state (broke rule W3, dropping unknown top-level keys).ProviderDescriptor.sessionCapable, replacing an inference the browser had been making. Handoff prints the/logininstruction after Ink unmounts.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 agentsstill dispatches as a deprecated alias forconfig 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
configDirand no key in a0700directory with nothing written into it; the web panel'swhite-space: presurviving to rendered pixels and scrolling in its own container at 420px, both themes, no console output.