Skip to content

Commit 48c41aa

Browse files
authored
Merge pull request #33 from jellologic/feat/account-guards
Account model: dedup guard, orphan fix, setups rename, wizard subscriptions
2 parents 6f48aaf + 2b9b8e2 commit 48c41aa

53 files changed

Lines changed: 1707 additions & 361 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 83 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -126,14 +126,43 @@ need to pass a reserved token through literally, put it after `--`:
126126
swisscode -- --cc-profile # claude receives "--cc-profile"
127127
```
128128

129+
## The four things
130+
131+
Four concepts, and only the last one is a thing you launch.
132+
133+
| | What it is | Optional? |
134+
|---|---|---|
135+
| **Provider** | An endpoint dialect — base URL, which credential variable, default models, compatibility flags. Eight ship built in | No. Every account names one. *Adding your own* is the optional part |
136+
| **Account** | **Who pays.** One provider plus one credential: an API key, an env var, or a Claude subscription login | No |
137+
| **Setup** | **What runs.** Which CLI (`claude-code`, `kilo`, `opencode`), which model per tier, permissions, env | No. Every profile names one |
138+
| **Profile** | **The pairing.** One setup + one or more accounts + how to choose between them | No — this is what `swisscode <name>` launches |
139+
140+
```
141+
swisscode work
142+
└── profile "work"
143+
├── setup "cc" claude-code, opus/sonnet/haiku
144+
└── accounts ["personal", "team"] strategy: usage
145+
└── account "personal" → provider "anthropic" → subscription login
146+
```
147+
148+
Why split at all: they vary independently. One setup ("Claude Code, yolo, GLM on
149+
every tier") can be pointed at several accounts, and one account can back several
150+
setups. A profile naming more than one account is how rotation works.
151+
152+
Two mistakes the tool now catches for you, because both used to fail silently:
153+
an **account with no profile** cannot be launched (`swisscode <account-name>`
154+
selects a *profile*, so the name would go to the agent as a prompt), and **two
155+
accounts that are the same subscription** share one quota while looking like two.
156+
129157
## Profiles
130158

131-
A profile is a named provider + key + models. Name one after each account,
132-
client or experiment.
159+
Name one after each account, client or experiment.
133160

134161
```sh
135162
swisscode config work # create or edit the "work" profile
136163
swisscode config list # every profile (keys are never printed)
164+
swisscode config setups # what runs, and which profiles share each
165+
swisscode config accounts # who pays, and which profiles use each
137166
swisscode config default work # used when nothing else applies
138167
swisscode config rm old # deletes it, and any bindings to it
139168
```
@@ -148,6 +177,21 @@ If the first word isn't a profile name it's passed straight to `claude`, so
148177
`swisscode fix the login bug` still works. To be explicit either way, use
149178
`--cc-profile work` — an unknown name there is an error rather than a prompt.
150179

180+
One exception to that fallthrough: a first word that names an **account** or an
181+
**setup** is refused rather than sent as a prompt, because it is far
182+
likelier to be a mis-aimed selection than the start of a sentence.
183+
184+
```
185+
$ swisscode personal
186+
swisscode: "personal" is an account, not a profile — accounts say who pays, and
187+
a profile is the pairing you launch. Known profiles: work. Make one that uses it
188+
with `swisscode config <name>`, or send this word to the agent as a prompt with
189+
`swisscode -- personal …`.
190+
```
191+
192+
It only fires on an exact match against a name in your own config, so ordinary
193+
prompts are untouched — and `swisscode -- personal …` sends it through verbatim.
194+
151195
Profile names must start with a letter or digit and contain only letters,
152196
digits, `.`, `_` or `-`. Names that would collide with a subcommand, or with a
153197
word you're likely to start a prompt with (`fix`, `test`, `run`, …), are
@@ -354,15 +398,35 @@ key — it is a **login**, stored by Claude Code in your keychain and pointed at
354398
An account is one or the other, never both; a config naming a key *and* a
355399
session directory is refused rather than resolved by precedence.
356400

401+
The terminal wizard covers this too — pick **Anthropic (direct)** in
402+
`swisscode config <name>` and it asks how the account pays, offering a
403+
subscription kept separate from your other logins, the login you already use, or
404+
an API key. Or do it directly:
405+
357406
```sh
358407
swisscode config accounts login work # make a session dir, run /login inside
359408
swisscode config accounts login personal --dir ~/.claude # adopt the login you already have
360409
swisscode config accounts # who each account is, no keychain prompt
361410
```
362411

363-
`login` creates `~/.config/swisscode/accounts/<name>` at `0700`, then runs the
364-
agent there so you can complete `/login` once. After that the account is a
365-
normal thing profiles can reference.
412+
`login` creates `~/.config/swisscode/accounts/<name>` at `0700`, mints a profile
413+
of the same name so there is something to launch, then runs the agent there so
414+
you can complete `/login` once.
415+
416+
The profile matters: an account says *who pays*, and `swisscode <name>` selects
417+
a **profile**. Without one the account is unreachable — which is why `config
418+
accounts` and `config doctor` both flag an account no profile uses. Pass
419+
`--no-profile` if you mean to wire it into an existing multi-account profile
420+
yourself.
421+
422+
> **A new directory does not start logged out — it starts as a copy of the login
423+
> you already have.** Claude Code seeds a fresh `CLAUDE_CONFIG_DIR` from your
424+
> current session, so if you exit without running `/login` as a *different*
425+
> account, you end up with two names for one subscription: both report their own
426+
> email and plan, both work, and neither adds any capacity. `config accounts`
427+
> marks them `DUPLICATE` and `config doctor` fails the `distinct accounts` check,
428+
> because a `usage` profile would otherwise count that single quota twice and
429+
> rotate between two halves of the same thing.
366430
367431
> **Naming `~/.claude` means *unsetting* `CLAUDE_CONFIG_DIR`, not setting it to
368432
> that path.** Claude Code chooses its keychain item on whether the variable is
@@ -384,7 +448,7 @@ into each one to look. It caches them, and a profile with
384448
`"strategy": "usage"` then launches on whichever account has the most left:
385449

386450
```json
387-
{ "agentProfile": "default", "accounts": ["personal", "work"], "strategy": "usage" }
451+
{ "setup": "default", "accounts": ["personal", "work"], "strategy": "usage" }
388452
```
389453

390454
Ranking uses the **tighter of the two windows, never their average**. An account
@@ -569,12 +633,12 @@ holds an API key in plaintext.
569633

570634
```json
571635
{
572-
"version": 3,
636+
"version": 4,
573637
"providerAccounts": {
574638
"openrouter": { "provider": "openrouter", "apiKey": "sk-or-…" },
575639
"personal": { "provider": "anthropic", "configDir": "/Users/me/.claude" }
576640
},
577-
"agentProfiles": {
641+
"setups": {
578642
"default": {
579643
"agent": "claude-code",
580644
"models": {
@@ -590,7 +654,7 @@ holds an API key in plaintext.
590654
}
591655
},
592656
"profiles": {
593-
"work": { "agentProfile": "default", "accounts": ["openrouter"], "strategy": "single" }
657+
"work": { "setup": "default", "accounts": ["openrouter"], "strategy": "single" }
594658
},
595659
"defaultProfile": "work",
596660
"bindings": { "/Users/me/clients/acme": "acme" },
@@ -599,12 +663,16 @@ holds an API key in plaintext.
599663
```
600664

601665
Three separate things, because they vary independently. A **provider account**
602-
is who pays — a key, or a subscription login. An **agent profile** is what runs
603-
— which CLI, which model per tier, which flags. A **profile** pairs them and
604-
says how to choose when it names more than one account (`single`, `round-robin`,
605-
or `usage`). One agent profile can be shared by several profiles that bill
606-
different accounts, which is the arrangement the older flat shape could not
607-
express.
666+
is who pays — a key, or a subscription login. A **setup** is what runs — which
667+
CLI, which model per tier, which flags. A **profile** pairs them and says how to
668+
choose when it names more than one account (`single`, `round-robin`, or
669+
`usage`). One setup can be shared by several profiles that bill different
670+
accounts, which is the arrangement the older flat shape could not express.
671+
672+
> Setups were called `agentProfiles` before v4. Two things one word apart —
673+
> "agent profile" and "profile" — read backwards to almost everyone, so the one
674+
> nobody types got the new name. Existing configs migrate on first read, and
675+
> `config agents` still works as an alias for `config setups`.
608676
609677
`bindings` records absolute paths, which means client names and project layout.
610678
That's new non-credential information in this file — worth remembering before

src/adapters/claude-session/credentials.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -74,11 +74,21 @@ export type CredentialResult =
7474
* re-normalised spelling of it. Normalising here and not there would produce
7575
* a name that is right in every test and wrong on every machine.
7676
*
77-
* The unhashed branch is VERIFIED live: the real item on this machine is
78-
* `Claude Code-credentials`, matching exactly. The hashed branch follows the
79-
* rule above but cannot be confirmed without performing a real `/login` into a
80-
* custom directory, so `config doctor` reports what it finds rather than
81-
* asserting the name is right.
77+
* BOTH BRANCHES ARE NOW VERIFIED LIVE, against Claude Code v2.1.218:
78+
*
79+
* unset -> `Claude Code-credentials`
80+
* .../swisscode/accounts/ezra.spero -> `Claude Code-credentials-4e2d2019`
81+
*
82+
* and `sha256` of that path does begin `4e2d2019`, so the derivation above is
83+
* the real rule rather than a plausible reading of it.
84+
*
85+
* The hashed item appeared WITHOUT A `/login` — created the same minute the
86+
* agent first ran in that directory, while the default item's creation date was
87+
* months old and unchanged. A new config directory is therefore seeded from the
88+
* existing login rather than starting empty, which is why
89+
* `core/account.ts:identityCollisions` exists. `config doctor` still reports
90+
* what it finds rather than asserting a name, since one machine confirming a
91+
* rule is not the same as owning it.
8292
*/
8393
export function keychainService(configDir: string, env: ReadableEnv = process.env): string {
8494
if (isDefaultConfigDir(configDir, env)) return 'Claude Code-credentials'

src/adapters/claude-session/onboard.ts

Lines changed: 128 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { existsSync, mkdirSync, statSync } from 'node:fs'
1515
import { homedir } from 'node:os'
1616
import { isAbsolute, join, resolve } from 'node:path'
1717
import { describeIdentity, readSessionIdentity } from './identity.ts'
18+
import { validateProfileName } from '../../core/migrate.ts'
1819
import { isDefaultConfigDir } from '../agents/claude-code/env.ts'
1920
import type { ConfigStorePort, ProviderAccount, State } from '../../ports/config-store.ts'
2021
import type { AgentRegistryPort } from '../../ports/agent.ts'
@@ -29,6 +30,14 @@ export type LoginOptions = {
2930
dir?: string | undefined
3031
/** `--provider <id>`, defaulting to anthropic — the only one with this flow today */
3132
provider?: string | undefined
33+
/**
34+
* `--no-profile`: record the account and stop, leaving it unlaunchable.
35+
*
36+
* For the deliberate case — an account you are about to add to an existing
37+
* multi-account profile by hand — which is the only reason to want the state
38+
* this command used to leave behind by accident.
39+
*/
40+
noProfile?: boolean | undefined
3241
store: ConfigStorePort
3342
agents: AgentRegistryPort
3443
proc: ProcessPort
@@ -74,11 +83,86 @@ export function validateAccountName(name: string): { ok: true } | { ok: false; r
7483
return { ok: true }
7584
}
7685

86+
/**
87+
* What linking an account to a launchable profile did, or why it did not.
88+
*
89+
* `profile` is the name you can actually type at `swisscode <name>` afterwards.
90+
* `null` with a `reason` is a real outcome, not a failure: the account is still
91+
* recorded, and the reason is what the caller prints instead of a lie.
92+
*/
93+
export type LinkResult = { state: State; profile: string | null; reason: string | null }
94+
95+
/**
96+
* Make a freshly recorded account LAUNCHABLE.
97+
*
98+
* WHY THIS EXISTS. `config accounts login` used to record an account, print
99+
* "Nothing else to do — this account is ready to use", and stop. That sentence
100+
* was false: an account is not a thing you can launch, a profile is, and nothing
101+
* referenced the new account. The first thing anyone did next was type
102+
* `swisscode <account-name>` and watch the name go to the agent as a prompt.
103+
*
104+
* So this mints the same 1:1:1 shape the wizard already produces — an account, a
105+
* setup and a profile all sharing one name — which is also what the v2->v3
106+
* migration produces, so there is exactly one arrangement a new install can be
107+
* in rather than two.
108+
*
109+
* It REFUSES rather than improvises in the two cases where guessing would
110+
* silently change what a launch bills:
111+
*
112+
* - a profile of that name already exists and does not name this account.
113+
* Adding the account to it would change who pays for an existing setup.
114+
* - the name is not a legal profile name — an account may be called `fix`,
115+
* but a PROFILE called `fix` would swallow `swisscode fix the login bug`,
116+
* which is the exact hazard COMMON_WORD_GUARD exists to prevent.
117+
*
118+
* An existing setup of the same name is REUSED, never overwritten: setups are
119+
* shareable by design, and clobbering one would silently re-point every profile
120+
* that references it.
121+
*/
122+
export function linkAccount(state: State, name: string): LinkResult {
123+
const already = Object.entries(state.profiles ?? {}).find(([, p]) =>
124+
(p.accounts ?? []).includes(name),
125+
)
126+
if (already) return { state, profile: already[0], reason: null }
127+
128+
const existing = state.profiles?.[name]
129+
if (existing) {
130+
return {
131+
state,
132+
profile: null,
133+
reason:
134+
`a profile called "${name}" already exists and does not use this account — adding it ` +
135+
'would change who pays for that profile',
136+
}
137+
}
138+
const verdict = validateProfileName(name)
139+
if (!verdict.ok) {
140+
return { state, profile: null, reason: `"${name}" cannot be a profile name: ${verdict.reason}` }
141+
}
142+
143+
return {
144+
state: {
145+
...state,
146+
// Reused when it exists — a setup can back several profiles.
147+
setups: { ...(state.setups ?? {}), [name]: state.setups?.[name] ?? {} },
148+
profiles: {
149+
...(state.profiles ?? {}),
150+
[name]: { setup: name, accounts: [name], strategy: 'single' },
151+
},
152+
// First profile on the machine becomes the default, matching the wizard.
153+
defaultProfile: state.defaultProfile ?? name,
154+
},
155+
profile: name,
156+
reason: null,
157+
}
158+
}
159+
77160
/** @returns the process exit code, or does not return at all (execve). */
78161
export function accountLogin({
79162
name,
80163
dir,
81164
provider = 'anthropic',
165+
noProfile = false,
82166
store,
83167
agents,
84168
proc,
@@ -161,17 +245,41 @@ export function accountLogin({
161245
}
162246

163247
const account: ProviderAccount = { provider, configDir: target }
164-
const next: State = {
248+
const recorded: State = {
165249
...state,
166250
providerAccounts: { ...(state.providerAccounts ?? {}), [name]: account },
167251
}
252+
// An account on its own cannot be launched — only a profile can — so make one
253+
// unless the user asked not to. See `linkAccount`.
254+
const link = noProfile
255+
? { state: recorded, profile: null, reason: 'you passed `--no-profile`' }
256+
: linkAccount(recorded, name)
168257
try {
169-
store.save(next)
258+
store.save(link.state)
170259
} catch (e) {
171260
err(`swisscode: could not record the account: ${(e as { message?: string }).message ?? e}`)
172261
return 2
173262
}
174263

264+
/**
265+
* The one sentence that has to be true.
266+
*
267+
* Printed at every exit below, because the previous version's cheerful
268+
* "nothing else to do" was the whole bug: it said an account was ready when
269+
* nothing could launch it.
270+
*/
271+
const sayHowToLaunch = (): void => {
272+
if (link.profile) {
273+
out('')
274+
out(`Launch it with: swisscode ${link.profile}`)
275+
} else {
276+
out('')
277+
out(`This account cannot be launched yet — ${link.reason}.`)
278+
out('An account says who pays; a profile is the thing you launch. Make one with')
279+
out(' swisscode config <profile-name>')
280+
}
281+
}
282+
175283
const env = proc.env()
176284
const isDefault = isDefaultConfigDir(target, env)
177285
const already = readSessionIdentity(target, { env })
@@ -182,8 +290,9 @@ export function accountLogin({
182290
// are already using would be busywork that risks replacing it.
183291
out(`Account "${name}" adopted your existing login: ${describeIdentity(already)}.`)
184292
out(` ${target} (Claude Code's default directory)`)
293+
sayHowToLaunch()
185294
out('')
186-
out('Nothing else to do — this account is ready to use. Add a second one with')
295+
out('Add a second subscription with')
187296
out(` swisscode config accounts login <other-name>`)
188297
return 0
189298
}
@@ -197,9 +306,10 @@ export function accountLogin({
197306
} else {
198307
out(`Account "${name}" recorded, using ${target}.`)
199308
}
309+
sayHowToLaunch()
200310

201311
// Claude Code is the only agent with this flow — the login being adopted IS a
202-
// Claude subscription — so this does not consult the agent profile. Kilo and
312+
// Claude subscription — so this does not consult the setup. Kilo and
203313
// OpenCode declare `sessionDir: false` for exactly this reason.
204314
const agent = agents.byId('claude-code')
205315
if (!agent) {
@@ -221,6 +331,20 @@ export function accountLogin({
221331

222332
out('')
223333
out('Starting Claude Code in that directory. Run `/login` inside it, then exit.')
334+
// SAY THIS BEFORE IT HAPPENS, because afterwards there is nobody left to say
335+
// it — this process execve's away, and the surprise lands inside someone
336+
// else's UI. A new directory does NOT come up logged out: Claude Code seeds it
337+
// from the login you already have (measured — a fresh directory held a full
338+
// identity, and a Keychain item under its hashed service name, within a minute
339+
// of first use and with no `/login` performed). Exit without switching and you
340+
// have two names for one subscription. `config accounts` and `config doctor`
341+
// both catch that afterwards, but not being caught by it is better.
342+
if (!isDefault) {
343+
out('')
344+
out(' NOTE it will already show a login — a new directory starts out cloned from')
345+
out(' the account you are using now. `/login` as the OTHER account, or this')
346+
out(' one ends up a duplicate that shares the same quota.')
347+
}
224348
out('')
225349

226350
// Setting the variable to the default path would send the agent to a

src/adapters/providers/anthropic.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ export const anthropic = {
1717
baseUrl: null,
1818
credentialEnv: 'ANTHROPIC_API_KEY',
1919
credentialOptional: true,
20+
// The only provider a Claude subscription can authenticate against, because a
21+
// session directory holds a login to api.anthropic.com and nowhere else.
22+
sessionCapable: true,
2023
// No defaults: every tier variable is cleared so Claude Code uses its own.
2124
defaultModels: {},
2225
catalogId: null,

0 commit comments

Comments
 (0)