From d56fad8dd06f647e8e6254f15ef1284f1eab8ef0 Mon Sep 17 00:00:00 2001 From: jellologic <31935831+jellologic@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:29:13 -0400 Subject: [PATCH 1/6] Detect accounts that are secretly one subscription MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `, 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> --- README.md | 9 ++ src/adapters/claude-session/credentials.ts | 20 +++- src/adapters/claude-session/onboard.ts | 14 +++ src/adapters/web/api.ts | 21 +++- src/composition/config-root.ts | 69 +++++++++++++- src/composition/doctor-root.ts | 62 +++++++++++- src/composition/web-root.ts | 17 +++- src/core/account.ts | 90 +++++++++++++++++ test/adapters/web.test.ts | 28 +++++- test/config-commands.test.ts | 56 ++++++++++- test/core/account.test.ts | 106 +++++++++++++++++++++ web/src/api.ts | 17 ++++ web/src/routes/Accounts.tsx | 29 +++++- 13 files changed, 521 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index ad14eac..2643ed9 100644 --- a/README.md +++ b/README.md @@ -364,6 +364,15 @@ swisscode config accounts # who each account is, no keychain agent there so you can complete `/login` once. After that the account is a normal thing profiles can reference. +> **A new directory does not start logged out — it starts as a copy of the login +> you already have.** Claude Code seeds a fresh `CLAUDE_CONFIG_DIR` from your +> current session, so if you exit without running `/login` as a *different* +> account, you end up with two names for one subscription: both report their own +> email and plan, both work, and neither adds any capacity. `config accounts` +> marks them `DUPLICATE` and `config doctor` fails the `distinct accounts` check, +> because a `usage` profile would otherwise count that single quota twice and +> rotate between two halves of the same thing. + > **Naming `~/.claude` means *unsetting* `CLAUDE_CONFIG_DIR`, not setting it to > that path.** Claude Code chooses its keychain item on whether the variable is > *set*, not on its value — so `CLAUDE_CONFIG_DIR="$HOME/.claude"` is a diff --git a/src/adapters/claude-session/credentials.ts b/src/adapters/claude-session/credentials.ts index 030e997..bf93685 100644 --- a/src/adapters/claude-session/credentials.ts +++ b/src/adapters/claude-session/credentials.ts @@ -74,11 +74,21 @@ export type CredentialResult = * re-normalised spelling of it. Normalising here and not there would produce * a name that is right in every test and wrong on every machine. * - * The unhashed branch is VERIFIED live: the real item on this machine is - * `Claude Code-credentials`, matching exactly. The hashed branch follows the - * rule above but cannot be confirmed without performing a real `/login` into a - * custom directory, so `config doctor` reports what it finds rather than - * asserting the name is right. + * BOTH BRANCHES ARE NOW VERIFIED LIVE, against Claude Code v2.1.218: + * + * unset -> `Claude Code-credentials` + * .../swisscode/accounts/ezra.spero -> `Claude Code-credentials-4e2d2019` + * + * and `sha256` of that path does begin `4e2d2019`, so the derivation above is + * the real rule rather than a plausible reading of it. + * + * The hashed item appeared WITHOUT A `/login` — created the same minute the + * agent first ran in that directory, while the default item's creation date was + * months old and unchanged. A new config directory is therefore seeded from the + * existing login rather than starting empty, which is why + * `core/account.ts:identityCollisions` exists. `config doctor` still reports + * what it finds rather than asserting a name, since one machine confirming a + * rule is not the same as owning it. */ export function keychainService(configDir: string, env: ReadableEnv = process.env): string { if (isDefaultConfigDir(configDir, env)) return 'Claude Code-credentials' diff --git a/src/adapters/claude-session/onboard.ts b/src/adapters/claude-session/onboard.ts index 2ab502e..714b663 100644 --- a/src/adapters/claude-session/onboard.ts +++ b/src/adapters/claude-session/onboard.ts @@ -221,6 +221,20 @@ export function accountLogin({ out('') out('Starting Claude Code in that directory. Run `/login` inside it, then exit.') + // SAY THIS BEFORE IT HAPPENS, because afterwards there is nobody left to say + // it — this process execve's away, and the surprise lands inside someone + // else's UI. A new directory does NOT come up logged out: Claude Code seeds it + // from the login you already have (measured — a fresh directory held a full + // identity, and a Keychain item under its hashed service name, within a minute + // of first use and with no `/login` performed). Exit without switching and you + // have two names for one subscription. `config accounts` and `config doctor` + // both catch that afterwards, but not being caught by it is better. + if (!isDefault) { + out('') + out(' NOTE it will already show a login — a new directory starts out cloned from') + out(' the account you are using now. `/login` as the OTHER account, or this') + out(' one ends up a duplicate that shares the same quota.') + } out('') // Setting the variable to the default path would send the agent to a diff --git a/src/adapters/web/api.ts b/src/adapters/web/api.ts index 14793e2..f28b08e 100644 --- a/src/adapters/web/api.ts +++ b/src/adapters/web/api.ts @@ -13,6 +13,7 @@ import { validateProfileName } from '../../core/migrate.ts' import { toCustomProvider, validateCustomProvider } from '../../core/provider-def.ts' import { TIERS } from '../../core/tiers.ts' import { accountsUsedBy, validateAccount } from '../../core/account.ts' +import type { IdentityCollision } from '../../core/account.ts' import { COMPAT_ENV, CREDENTIAL_ENVS } from '../agents/claude-code/env.ts' import { CATALOG_SOURCE, CLAUDE_ENV_CATALOG } from '../agents/claude-code/env-catalog.ts' import type { @@ -66,7 +67,16 @@ export type ApiDeps = { * Optional, and absent rather than empty when unwired: `{}` would be * indistinguishable from "every account is logged out". */ - identities?: () => Record + identities?: () => { + logins: Record + /** + * Accounts that are really one subscription. Computed where the store is + * read, from the SAME `core/account.ts` rule the CLI and the doctor use — + * the browser must not re-derive it by comparing the `logins` strings, which + * is the private-fourth-copy failure that module was written to end. + */ + collisions: IdentityCollision[] + } } /** One agent CLI, as found (or not) on this machine. */ @@ -309,6 +319,10 @@ export function handleApi(req: ApiRequest, deps: ApiDeps): ApiResponse { // subsequent write must quote back. if (resource === 'bootstrap' && req.method === 'GET') { const loaded = store.load() + // ONCE. Each call reads every session account's `.claude.json`, which is a + // 200 kB file apiece on a well-used account, and both fields below come from + // the same walk. + const identities = deps.identities ? deps.identities() : null return json(200, { state: redactState(loaded.state), revision: store.revision ? store.revision() : null, @@ -349,7 +363,10 @@ export function handleApi(req: ApiRequest, deps: ApiDeps): ApiResponse { // Who each session account is logged in as. Same "never faked" rule as // `installedAgents`: null when unwired, never an empty map that would read // as "all logged out". - logins: deps.identities ? deps.identities() : null, + logins: identities ? identities.logins : null, + // Absent-vs-empty matters here too: `[]` is a real answer ("checked, all + // distinct"), so null has to mean "nobody looked". + loginCollisions: identities ? identities.collisions : null, // Custom providers are returned SEPARATELY from `providers` even though // the registry already merges them: the UI has to know which ones it may // edit, and a merged list cannot say. diff --git a/src/composition/config-root.ts b/src/composition/config-root.ts index ff45236..219e798 100644 --- a/src/composition/config-root.ts +++ b/src/composition/config-root.ts @@ -32,12 +32,20 @@ import { TIERS } from '../core/tiers.ts' import { DEFAULT_AGENT_ID } from '../adapters/agents/registry.ts' import { withCustomProviders } from '../adapters/providers/composite.ts' import { describeIdentity, readSessionIdentity } from '../adapters/claude-session/identity.ts' +import type { SessionIdentity } from '../adapters/claude-session/identity.ts' +import type { IdentityCollision } from '../core/account.ts' import { accountLogin } from '../adapters/claude-session/onboard.ts' import { swapCredential } from '../adapters/claude-session/swap.ts' import { measureAccounts, remainingMap } from '../adapters/usage/measure.ts' import { execFileSync } from 'node:child_process' import { fileURLToPath } from 'node:url' -import { CONFLICT_REASON, accountsUsedBy, credentialSource } from '../core/account.ts' +import { + COLLISION_REASON, + CONFLICT_REASON, + accountsUsedBy, + credentialSource, + identityCollisions, +} from '../core/account.ts' import { isNewer } from '../core/version.ts' import { createFsVersionStore, @@ -1206,6 +1214,31 @@ function listAccounts({ deps, out }: { deps: LaunchDeps; out: Emit }): number { return 0 } + // Identities are read UP FRONT rather than inside the loop, because the + // duplicate-subscription check below needs every account's identity before the + // first one is printed. Still exactly one read apiece — `.claude.json` is a + // 200 kB file on a well-used account and this command runs constantly. + const identities = new Map() + for (const name of names) { + const dir = state.providerAccounts[name]!.configDir + if (dir) identities.set(name, readSessionIdentity(dir)) + } + const collisions = identityCollisions( + names.map((name) => { + const identity = identities.get(name) + return { + name, + ...(state.providerAccounts[name]!.configDir + ? { configDir: state.providerAccounts[name]!.configDir } + : {}), + ...(identity?.accountUuid ? { accountUuid: identity.accountUuid } : {}), + ...(identity?.email ? { email: identity.email } : {}), + } + }), + ) + const collidingWith = (name: string): string[] => + collisions.flatMap((c) => (c.names.includes(name) ? c.names.filter((n) => n !== name) : [])) + for (const name of names) { // `!` — read off Object.keys of this very object. const a = state.providerAccounts[name]! @@ -1229,13 +1262,18 @@ function listAccounts({ deps, out }: { deps: LaunchDeps; out: Emit }): number { // instead. The email is the thing the user recognises — "which of my // three accounts is this?" is the question, and a path does not answer it. // Reads `.claude.json` only; no credential, no prompt, no network. - // Read ONCE — `.claude.json` is a 200 kB file on a well-used account. - const identity = readSessionIdentity(a.configDir) + const identity = identities.get(name) ?? null out(` login ${describeIdentity(identity)}`) out(` session ${a.configDir}`) if (!identity) { out(` run \`swisscode config accounts login ${name}\` and \`/login\` inside`) } + // Named on the account itself as well as in the summary below, because + // this is read one account at a time and a footnote is easy to scroll past. + const twins = collidingWith(name) + if (twins.length > 0) { + out(` DUPLICATE same subscription as ${twins.join(', ')} — see below`) + } } else { // Presence and ORIGIN only, exactly as `config list` does: a masked key is // still a fingerprint and this output gets pasted into bug reports. @@ -1243,9 +1281,34 @@ function listAccounts({ deps, out }: { deps: LaunchDeps; out: Emit }): number { } out(` used by ${usedBy.length > 0 ? usedBy.join(', ') : '— nothing'}`) } + + // The explanation goes ONCE, at the end, with the fix. Repeating the full + // reason under every colliding account would bury the listing it belongs to. + for (const c of collisions) { + out('') + out(` PROBLEM ${c.names.join(' and ')} ${WHY_MATCHED[c.matchedOn]} ${c.value}`) + out(` ${COLLISION_REASON}.`) + if (c.matchedOn === 'configDir') { + out(' Point one at a directory of its own, then `/login` there as the other account.') + } else { + // `?? c.names[0]` only to satisfy noUncheckedIndexedAccess — a collision + // always names at least two accounts. + const second = c.names[1] ?? c.names[0] + out(` Run \`swisscode config accounts login ${second}\` and \`/login\` as a`) + out(' DIFFERENT account — a new session directory starts out cloned from the') + out(' login you already have, so skipping `/login` leaves you with a copy.') + } + } return 0 } +/** How each key reads in a sentence, so the three call sites cannot drift. */ +const WHY_MATCHED: Record = { + configDir: 'share one session directory:', + accountUuid: 'are the same Anthropic account:', + email: 'are both logged in as', +} + /** * `swisscode config agents` — what runs, and who uses it. * diff --git a/src/composition/doctor-root.ts b/src/composition/doctor-root.ts index a679803..275ad33 100644 --- a/src/composition/doctor-root.ts +++ b/src/composition/doctor-root.ts @@ -41,7 +41,12 @@ import { sessionDirLooksInitialised, } from '../adapters/claude-session/identity.ts' import { measureAccounts, remainingMap } from '../adapters/usage/measure.ts' -import { CONFLICT_REASON, credentialSource } from '../core/account.ts' +import { + COLLISION_REASON, + CONFLICT_REASON, + credentialSource, + identityCollisions, +} from '../core/account.ts' import type { MeasureOptions } from '../adapters/usage/measure.ts' import type { LaunchDeps } from './launch-root.ts' import { createOllamaIntrospect, interpretOllamaContext } from '../adapters/doctor/ollama.ts' @@ -392,6 +397,61 @@ export async function runDoctor({ } } + // Accounts that are secretly one subscription. + // + // CONFIG-WIDE, unlike the session check above, which can only see the profile + // being diagnosed. Being the same account as another one is not a property an + // account has on its own, so no per-profile check could ever find it. + // + // Costs one `.claude.json` read per session account — no credential, no + // Keychain, no network — so it runs under `--offline` with the static checks. + const sessionAccounts = Object.entries(loaded.state.providerAccounts ?? {}).filter( + ([, a]) => a.configDir, + ) + if (sessionAccounts.length < 2) { + checks.push( + makeCheck( + 'account-duplicate', + 'distinct accounts', + SKIP, + 'skipped: fewer than two subscription accounts to compare', + ), + ) + } else { + const collisions = identityCollisions( + sessionAccounts.map(([name, a]) => { + const identity = a.configDir ? readSessionIdentity(a.configDir) : null + return { + name, + ...(a.configDir ? { configDir: a.configDir } : {}), + ...(identity?.accountUuid ? { accountUuid: identity.accountUuid } : {}), + ...(identity?.email ? { email: identity.email } : {}), + } + }), + ) + if (collisions.length === 0) { + checks.push( + makeCheck( + 'account-duplicate', + 'distinct accounts', + OK, + `${sessionAccounts.length} subscription accounts, all different`, + ), + ) + } else { + for (const c of collisions) { + checks.push( + makeCheck('account-duplicate', 'distinct accounts', WARN, `${c.names.join(' and ')} — ${COLLISION_REASON}`, { + fix: + c.matchedOn === 'configDir' + ? `they share the session directory ${c.value} — give one its own` + : `\`swisscode config accounts login ${c.names[1] ?? c.names[0]}\`, then \`/login\` as a different account`, + }), + ) + } + } + } + // Refresh the usage snapshot. // // The doctor is where `core/resolve.ts` ALREADY SENDS PEOPLE: when a `usage` diff --git a/src/composition/web-root.ts b/src/composition/web-root.ts index 3d93f7a..ebe4e57 100644 --- a/src/composition/web-root.ts +++ b/src/composition/web-root.ts @@ -20,6 +20,8 @@ import { fetchNet } from '../adapters/net/fetch-net.ts' import { systemClock } from '../adapters/clock/system-clock.ts' import { configDir } from '../adapters/store/fs-config-store.ts' import { describeIdentity, readSessionIdentity } from '../adapters/claude-session/identity.ts' +import { identityCollisions } from '../core/account.ts' +import type { SessionAccountIdentity } from '../core/account.ts' import type { LaunchDeps } from './launch-root.ts' export type RunWebOptions = { @@ -99,11 +101,22 @@ export async function runWeb({ identities: () => { const { state } = deps.store.load() const logins: Record = {} + // Read once, used twice: the description the UI prints, and the raw + // fields the collision rule needs. Re-reading for the second would + // double the cost of a cold start for nothing. + const seen: SessionAccountIdentity[] = [] for (const [name, account] of Object.entries(state.providerAccounts ?? {})) { if (!account.configDir) continue - logins[name] = describeIdentity(readSessionIdentity(account.configDir)) + const identity = readSessionIdentity(account.configDir) + logins[name] = describeIdentity(identity) + seen.push({ + name, + configDir: account.configDir, + ...(identity?.accountUuid ? { accountUuid: identity.accountUuid } : {}), + ...(identity?.email ? { email: identity.email } : {}), + }) } - return logins + return { logins, collisions: identityCollisions(seen) } }, // Lazy for the same reason as the doctor, and one step further: this one // can raise a Keychain prompt, so it loads only when someone asks for it. diff --git a/src/core/account.ts b/src/core/account.ts index a469dbe..53f27f4 100644 --- a/src/core/account.ts +++ b/src/core/account.ts @@ -83,6 +83,96 @@ export function validateAccount( return null } +/** + * A session account, reduced to what "is this the same subscription?" needs. + * + * Session accounts only. A key account's identity is its key, which the redacted + * shape deliberately does not carry and which nothing here should ever compare — + * so duplicate keys are out of scope rather than silently half-covered. + */ +export type SessionAccountIdentity = { + name: string + /** the session directory. Trailing slashes are tolerated; see the note below. */ + configDir?: string | undefined + /** stable across email changes, so it is the honest key */ + accountUuid?: string | undefined + email?: string | undefined +} + +/** Two or more accounts that are really one subscription. */ +export type IdentityCollision = { + /** the colliding account names, sorted */ + names: string[] + /** what proved they are the same, strongest first */ + matchedOn: 'configDir' | 'accountUuid' | 'email' + /** the shared value. Safe to print — a path or an email, never a credential. */ + value: string +} + +/** The one sentence every surface uses for a duplicated subscription. */ +export const COLLISION_REASON = + 'these accounts are the same subscription, so they share one quota — rotating between them ' + + 'buys nothing, and a `usage` profile will count that single quota twice' + +/** + * Find accounts that are secretly the same subscription. + * + * WHY THIS EXISTS, and it is a measured behaviour rather than a hypothetical: + * pointing Claude Code at a FRESH config directory does not start it logged out. + * The directory comes up already authenticated as the existing login, and a + * Keychain item appears under that directory's hashed service name at the same + * moment. Verified 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 `, followed by 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. That is the + * silently-wrong-account failure this whole feature exists to end, reappearing + * one level up — which is why it gets a check rather than a docs note. + * + * Matching is by the STRONGEST available key, and all three passes run: + * + * configDir same directory -> the same login by construction, even when + * nobody has ever logged in, which no identity check can see + * accountUuid Anthropic's own id, stable when an email changes + * email the last resort, and what the user actually recognises + * + * A group whose members are already covered by a stronger pass is dropped, so + * the ordinary case (two accounts, distinct directories, one identity) reports + * once rather than twice. + * + * Accounts with NO identity never collide with each other: "nobody has logged in + * here yet" is not evidence that two directories are the same account, and + * saying so would fire on every freshly created pair. + */ +export function identityCollisions(accounts: SessionAccountIdentity[]): IdentityCollision[] { + // Trailing slashes only. Anything stronger — symlinks, `..`, case-folding — + // is the caller's job, because it needs a filesystem and this module has none. + const normalise = (v: string): string => v.replace(/\/+$/, '') || '/' + const keys = ['configDir', 'accountUuid', 'email'] as const + + const found: IdentityCollision[] = [] + for (const key of keys) { + const groups = new Map() + for (const account of accounts) { + const raw = account[key] + if (!raw) continue + const value = key === 'configDir' ? normalise(raw) : raw + groups.set(value, [...(groups.get(value) ?? []), account.name]) + } + for (const [value, names] of groups) { + if (names.length < 2) continue + const sorted = [...names].sort() + // Already explained by a stronger key — same accounts, weaker evidence. + if (found.some((c) => sorted.every((n) => c.names.includes(n)))) continue + found.push({ names: sorted, matchedOn: key, value }) + } + } + return found +} + /** * Which profiles name this account — the reverse index. * diff --git a/test/adapters/web.test.ts b/test/adapters/web.test.ts index 3c514c1..9ea5f99 100644 --- a/test/adapters/web.test.ts +++ b/test/adapters/web.test.ts @@ -431,14 +431,40 @@ test('session logins are reported as unknown rather than faked when unwired', () const without = handleApi({ method: 'GET', path: '/api/bootstrap', body: null }, deps(s)) assert.equal((without.body as Record).logins, null) + assert.equal((without.body as Record).loginCollisions, null) + const withIdentities = handleApi({ method: 'GET', path: '/api/bootstrap', body: null }, { ...deps(s), - identities: () => ({ personal: 'a@b.c · Max 20x', spare: null }), + identities: () => ({ + logins: { personal: 'a@b.c · Max 20x', spare: null }, + collisions: [], + }), }) assert.deepEqual((withIdentities.body as Record).logins, { personal: 'a@b.c · Max 20x', spare: null, }) + // `[]` is a real answer — "we compared them, they are different" — and must + // survive as itself rather than collapsing into the `null` above. + assert.deepEqual((withIdentities.body as Record).loginCollisions, []) +}) + +test('duplicate subscriptions reach the browser as data, not as a string to re-parse', () => { + // The browser must never rediscover this by comparing the `logins` strings: + // two accounts can share one subscription and still describe differently, and + // re-deriving the rule in a fourth place is exactly what core/account.ts + // exists to prevent. + const s = store(baseState()) + const res = handleApi({ method: 'GET', path: '/api/bootstrap', body: null }, { + ...deps(s), + identities: () => ({ + logins: { personal: 'a@b.c · Max 20x', spare: 'a@b.c · Max 20x' }, + collisions: [{ names: ['personal', 'spare'], matchedOn: 'accountUuid', value: 'uuid-1' }], + }), + }) + assert.deepEqual((res.body as Record).loginCollisions, [ + { names: ['personal', 'spare'], matchedOn: 'accountUuid', value: 'uuid-1' }, + ]) }) test('a custom provider round-trips through the API and becomes launchable', () => { diff --git a/test/config-commands.test.ts b/test/config-commands.test.ts index 91575de..14c3877 100644 --- a/test/config-commands.test.ts +++ b/test/config-commands.test.ts @@ -5,7 +5,7 @@ // most of what is asserted here is about what they write and what they refuse. import test from 'node:test' import assert from 'node:assert/strict' -import { mkdtempSync, mkdirSync, rmSync } from 'node:fs' +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { runConfigCommand } from '../src/composition/config-root.ts' @@ -13,7 +13,7 @@ import { registry } from '../src/adapters/providers/registry.ts' import { registry as agents } from '../src/adapters/agents/registry.ts' import type { OpenUi } from '../src/composition/config-root.ts' import type { State } from '../src/ports/config-store.ts' -import { makeProfile } from './support/fixtures.ts' +import { makeAccount, makeProfile } from './support/fixtures.ts' // Annotated `State` rather than left to inference: the tests below read // profiles these commands CREATE — `.fix`, `.old`, `.third` — which a literal @@ -480,6 +480,58 @@ test('config accounts lists who pays, and the reverse index of who uses them', a assert.ok(!text.includes('sk-or-B'), 'an account key reached the terminal') }) +test('config accounts names two accounts that are secretly one subscription', async (t) => { + // REAL DIRECTORIES, because the identity comes off disk. The situation is the + // measured one: `config accounts login spare` creates a directory, Claude Code + // seeds it from the login already in use, and exiting without `/login` leaves + // two names for one quota. Both directories exist and differ; only the + // `.claude.json` inside them shows it. + const root = mkdtempSync(join(tmpdir(), 'swisscode-dup-')) + t.after(() => rmSync(root, { recursive: true, force: true })) + const seed = (name: string, uuid: string, email: string): string => { + const dir = join(root, name) + mkdirSync(dir, { recursive: true }) + writeFileSync( + join(dir, '.claude.json'), + JSON.stringify({ oauthAccount: { accountUuid: uuid, emailAddress: email } }), + ) + return dir + } + + const state = STATE() + // personal and spare are the SAME login in two directories; work is genuinely + // separate and must stay unflagged. + state.providerAccounts.personal = makeAccount({ + provider: 'anthropic', + configDir: seed('personal', 'u-1', 'me@x.com'), + }) + state.providerAccounts.spare = makeAccount({ + provider: 'anthropic', + configDir: seed('spare', 'u-1', 'me@x.com'), + }) + state.providerAccounts.work = makeAccount({ + provider: 'anthropic', + configDir: seed('work', 'u-2', 'work@x.com'), + }) + + const h = harness({ state }) + assert.equal(await h.run(['accounts']), 0) + const text = h.text() + + assert.match(text, /DUPLICATE\s+same subscription as spare/) + assert.match(text, /DUPLICATE\s+same subscription as personal/) + assert.match(text, /personal and spare are the same Anthropic account/) + // The fix has to name `/login`, since "delete one" is the wrong advice — + // the user wanted two subscriptions and still has one. + assert.match(text, /`\/login` as a\n\s+DIFFERENT account/) + // A genuinely separate account must NOT be swept in. Firing on real setups is + // how a warning gets trained out of existence. + assert.ok( + !/work.*DUPLICATE/s.test(text.slice(text.indexOf(' work'), text.indexOf('PROBLEM'))), + 'a distinct account was reported as a duplicate', + ) +}) + test('config agents marks a shared agent profile as shared', async () => { // Sharing is the capability the split bought; a listing that did not show it // would leave the user unable to tell one setup from two identical ones. diff --git a/test/core/account.test.ts b/test/core/account.test.ts index 4f77efe..f1a5310 100644 --- a/test/core/account.test.ts +++ b/test/core/account.test.ts @@ -10,6 +10,7 @@ import { CONFLICT_REASON, accountsUsedBy, credentialSource, + identityCollisions, validateAccount, } from '../../src/core/account.ts' @@ -88,3 +89,108 @@ test('a missing or empty profile map is not a crash', () => { assert.deepEqual(accountsUsedBy(null, 'x'), []) assert.deepEqual(accountsUsedBy({}, 'x'), []) }) + +// Duplicate subscriptions. +// +// THE CASE THAT MOTIVATED THIS IS THE FIRST ONE, and it is a measured +// behaviour: a fresh session directory comes up already logged in as the +// account you were using, so `config accounts login spare` followed by exiting +// without `/login` produces two names for one quota. Both directories are real +// and different; only the identity inside them gives it away. + +test('two directories logged in as one account are reported as one subscription', () => { + const found = identityCollisions([ + { name: 'personal', configDir: '/accounts/personal', accountUuid: 'uuid-1', email: 'a@b.c' }, + { name: 'spare', configDir: '/accounts/spare', accountUuid: 'uuid-1', email: 'a@b.c' }, + ]) + assert.equal(found.length, 1) + // accountUuid over email: it survives an email change, so it is the honest key. + assert.deepEqual(found[0], { + names: ['personal', 'spare'], + matchedOn: 'accountUuid', + value: 'uuid-1', + }) +}) + +test('genuinely separate subscriptions collide with nothing', () => { + assert.deepEqual( + identityCollisions([ + { name: 'personal', configDir: '/accounts/personal', accountUuid: 'uuid-1', email: 'a@b.c' }, + { name: 'work', configDir: '/accounts/work', accountUuid: 'uuid-2', email: 'd@e.f' }, + ]), + [], + ) +}) + +test('accounts nobody has logged into yet never collide with each other', () => { + // THE REGRESSION THAT WOULD MAKE THIS FEATURE USELESS. Two fresh directories + // both read back as "no identity", and treating absent-equals-absent as a + // match would fire on every pair the moment they were created — training + // people to ignore the one warning that matters. + assert.deepEqual( + identityCollisions([ + { name: 'a', configDir: '/accounts/a' }, + { name: 'b', configDir: '/accounts/b' }, + ]), + [], + ) +}) + +test('one shared directory is caught before anyone has logged in', () => { + // No identity to compare, but the same directory IS the same login by + // construction — the only variant an identity check cannot see. + const found = identityCollisions([ + { name: 'a', configDir: '/accounts/shared' }, + // Trailing slash: a hand-edited config is exactly how this arises. + { name: 'b', configDir: '/accounts/shared/' }, + ]) + assert.equal(found.length, 1) + assert.deepEqual(found[0], { + names: ['a', 'b'], + matchedOn: 'configDir', + value: '/accounts/shared', + }) +}) + +test('one pair is reported once, by its strongest evidence', () => { + // Same directory AND same identity: three passes all match, but the user has + // one problem and gets one line. Reporting it three ways would read as three + // separate faults. + const found = identityCollisions([ + { name: 'a', configDir: '/shared', accountUuid: 'uuid-1', email: 'a@b.c' }, + { name: 'b', configDir: '/shared', accountUuid: 'uuid-1', email: 'a@b.c' }, + ]) + assert.equal(found.length, 1) + assert.equal(found[0]?.matchedOn, 'configDir') +}) + +test('email catches a duplicate that predates the uuid being recorded', () => { + const found = identityCollisions([ + { name: 'a', configDir: '/a', email: 'a@b.c' }, + { name: 'b', configDir: '/b', email: 'a@b.c' }, + ]) + assert.deepEqual(found[0], { names: ['a', 'b'], matchedOn: 'email', value: 'a@b.c' }) +}) + +test('a wider identity group is still reported when a narrower one exists', () => { + // a+b share a directory; all three are the same Anthropic account. Both facts + // are true and separately actionable — c is not fixed by splitting a and b. + const found = identityCollisions([ + { name: 'a', configDir: '/shared', accountUuid: 'uuid-1' }, + { name: 'b', configDir: '/shared', accountUuid: 'uuid-1' }, + { name: 'c', configDir: '/c', accountUuid: 'uuid-1' }, + ]) + assert.equal(found.length, 2) + assert.deepEqual(found.map((f) => f.matchedOn), ['configDir', 'accountUuid']) + assert.deepEqual(found[1]?.names, ['a', 'b', 'c']) +}) + +test('names come back sorted, and one account alone is never a collision', () => { + const found = identityCollisions([ + { name: 'zebra', configDir: '/z', accountUuid: 'uuid-1' }, + { name: 'alpha', configDir: '/a', accountUuid: 'uuid-1' }, + ]) + assert.deepEqual(found[0]?.names, ['alpha', 'zebra']) + assert.deepEqual(identityCollisions([{ name: 'only', configDir: '/o', email: 'a@b.c' }]), []) + assert.deepEqual(identityCollisions([]), []) +}) diff --git a/web/src/api.ts b/web/src/api.ts index 185daba..fbf1f18 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -139,10 +139,27 @@ export type Bootstrap = { * accounts are simply absent from it. */ logins: Record | null + /** + * Accounts that are really one subscription. + * + * COMPUTED ON THE SERVER, by the same `core/account.ts` rule the CLI and the + * doctor use. Do not rediscover it here by comparing `logins` strings: two + * accounts can share a subscription and still describe differently, and a + * fourth private copy of the rule is the exact failure that module exists to + * prevent. Null means nobody looked; `[]` means they were compared and differ. + */ + loginCollisions: IdentityCollision[] | null customProviders: Record reservedProviderIds: string[] } +/** Mirrors `IdentityCollision` in src/core/account.ts. */ +export type IdentityCollision = { + names: string[] + matchedOn: 'configDir' | 'accountUuid' | 'email' + value: string +} + /** One window of a subscription, as the endpoint publishes it. */ export type UsageWindow = { utilization: number | null; resetsAt: string | null } diff --git a/web/src/routes/Accounts.tsx b/web/src/routes/Accounts.tsx index 791de27..1014ca4 100644 --- a/web/src/routes/Accounts.tsx +++ b/web/src/routes/Accounts.tsx @@ -28,7 +28,7 @@ import { EmptyState } from '../Brand' // The SAME decisions the CLI and the API make, imported rather than restated. // core/ is pure — no I/O, no node builtins — so it bundles into the browser as // happily as it compiles for the launch path. -import { accountsUsedBy, credentialSource } from '../../../src/core/account' +import { COLLISION_REASON, accountsUsedBy, credentialSource } from '../../../src/core/account' import { formatWindow } from '../../../src/core/format' /** @@ -471,6 +471,25 @@ export function Accounts({ data, reload }: { data: Bootstrap; reload: () => Prom ) : undefined } > + {/* + Duplicated subscriptions, named ONCE at the top with the fix, and + badged per row below. The list is read one account at a time, so a + badge without an explanation is a puzzle and an explanation without a + badge is a footnote — the CLI listing pairs them for the same reason. + */} + {(data.loginCollisions ?? []).map((c) => ( + + {c.names.join(' and ')}{' '} + {c.matchedOn === 'configDir' + ? 'share one session directory' + : 'are the same account'} + {' — '} + {COLLISION_REASON}.{' '} + {c.matchedOn === 'configDir' + ? 'Give one a directory of its own, then log in there as the other account.' + : 'A new session directory starts out cloned from the login you already have, so it stays a copy until you run /login as a different account inside it.'} + + ))} {accounts.length === 0 ? ( No accounts yet. An account is a provider plus the credential that pays for it. ) : ( @@ -483,6 +502,7 @@ export function Accounts({ data, reload }: { data: Bootstrap; reload: () => Prom const login = a.configDir ? (data.logins?.[name] ?? null) : null const measured = usage?.accounts.find((m) => m.name === name) const conflict = credentialSource(a) === 'conflict' + const duplicate = (data.loginCollisions ?? []).some((c) => c.names.includes(name)) return ( Prom {name} {a.label ? {a.label} : null} {conflict ? conflict : null} + {/* + `warn`, not `danger`: a duplicate account works + perfectly — every launch through it authenticates. What + it does not do is add capacity, which is why it is worth + saying and why it is not an error. + */} + {duplicate ? duplicate : null} } meta={ From a263c36ec4217eb9addbda7b052a4699d9354675 Mon Sep 17 00:00:00 2001 From: jellologic <31935831+jellologic@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:29:29 -0400 Subject: [PATCH 2/6] Refuse a positional that names an account or agent profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 -- …`. 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> --- README.md | 15 +++++++++ src/core/profile.ts | 33 ++++++++++++++++++++ test/core/profile.test.ts | 66 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 114 insertions(+) diff --git a/README.md b/README.md index 2643ed9..857453d 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,21 @@ If the first word isn't a profile name it's passed straight to `claude`, so `swisscode fix the login bug` still works. To be explicit either way, use `--cc-profile work` — an unknown name there is an error rather than a prompt. +One exception to that fallthrough: a first word that names an **account** or an +**agent profile** is refused rather than sent as a prompt, because it is far +likelier to be a mis-aimed selection than the start of a sentence. + +``` +$ swisscode personal +swisscode: "personal" is an account, not a profile — accounts say who pays, and +a profile is the pairing you launch. Known profiles: work. Make one that uses it +with `swisscode config `, or send this word to the agent as a prompt with +`swisscode -- personal …`. +``` + +It only fires on an exact match against a name in your own config, so ordinary +prompts are untouched — and `swisscode -- personal …` sends it through verbatim. + Profile names must start with a letter or digit and contain only letters, digits, `.`, `_` or `-`. Names that would collide with a subcommand, or with a word you're likely to start a prompt with (`fix`, `test`, `run`, …), are diff --git a/src/core/profile.ts b/src/core/profile.ts index 30744fd..86d49d1 100644 --- a/src/core/profile.ts +++ b/src/core/profile.ts @@ -119,6 +119,39 @@ export function resolveProfile( // tier 1a: the positional. if (positionalHit) return hit(positionalHit, 'positional', { consumedPositional: true }) + // A name that IS a config concept, just not the one this selects. + // + // The fallthrough above is right for `swisscode fix this bug` and wrong for + // `swisscode ezra.spero`, and until this existed nothing distinguished them: + // the account name became the first word of a prompt, and the launch fired it + // at whichever profile happened to be the default. No error, no warning, a + // real request billed — the user just watched it happen. + // + // Narrow on purpose. It fires only when the token EXACTLY matches an account + // or agent-profile name, which is a fact about this config rather than a + // guess about the string, so an ordinary prompt is untouched unless its first + // word is literally the name of something in the config. When it is, refusing + // loudly beats guessing: the whole point of tier 1 is that which account pays + // is never decided by inference. + if (positional) { + const kind = has(state?.providerAccounts ?? {}, positional) + ? ({ what: 'an account', does: 'accounts say who pays' } as const) + : has(state?.agentProfiles ?? {}, positional) + ? ({ what: 'an agent profile', does: 'agent profiles say what runs' } as const) + : null + if (kind) { + return { + ...none, + error: + `"${positional}" is ${kind.what}, not a profile — ${kind.does}, and a profile is the ` + + `pairing you launch. ` + + (names.length > 0 ? `Known profiles: ${names.join(', ')}. ` : '') + + `Make one that uses it with \`swisscode config \`, or send this word to the ` + + `agent as a prompt with \`swisscode -- ${positional} …\`.`, + } + } + } + if (names.length === 0) return none // tier 2: the binding. diff --git a/test/core/profile.test.ts b/test/core/profile.test.ts index 2894ac0..46bfe8a 100644 --- a/test/core/profile.test.ts +++ b/test/core/profile.test.ts @@ -162,3 +162,69 @@ test('a profile named after a subcommand is still selectable by flag', () => { const shadowed = makeState({ providerAccounts: { list: { provider: 'zai' } }, agentProfiles: { list: {} }, profiles: { list: { agentProfile: 'list', accounts: ['list'] } }, defaultProfile: null }) assert.equal(resolveProfile(shadowed, { profileFlag: 'list' }).name, 'list') }) + +// A name that is a config concept, but not the one the positional selects. +// +// `orphan` has an account and an agent profile that NO profile pairs — the +// state you are in immediately after `config accounts login`, and the one that +// made `swisscode ezra.spero` fire the account name at the default profile as +// a prompt. + +const orphan = { + version: 2, + providerAccounts: { z: { provider: 'zai' }, spare: { provider: 'anthropic' } }, + agentProfiles: { z: {}, solo: {} }, + profiles: { z: { agentProfile: 'z', accounts: ['z'] } }, + defaultProfile: 'z', + bindings: {}, + settings: {}, +} + +test('an account name given positionally is refused, not sent as a prompt', () => { + const sel = resolveProfile(orphan, { cwd: '/x', positional: 'spare' }) + assert.equal(sel.profile, null) + assert.equal(sel.name, null, 'nothing may launch') + assert.match(sel.error!, /"spare" is an account, not a profile/) + assert.match(sel.error!, /accounts say who pays/) + // Both ways forward, because the user meant one of exactly two things. + assert.match(sel.error!, /Known profiles: z\./) + assert.match(sel.error!, /swisscode config /) + assert.match(sel.error!, /swisscode -- spare/) +}) + +test('an agent-profile name is refused the same way, in its own words', () => { + const sel = resolveProfile(orphan, { cwd: '/x', positional: 'solo' }) + assert.match(sel.error!, /"solo" is an agent profile, not a profile/) + assert.match(sel.error!, /agent profiles say what runs/) +}) + +test('an ordinary prompt word is UNTOUCHED by the account check', () => { + // THE REGRESSION THAT WOULD BREAK THE PRODUCT. `swisscode fix this bug` is a + // supported invocation; the check above must fire on an exact match against + // this config's own names and on nothing else. + for (const word of ['fix', 'why', 'refactor', 'spares', 'z-ish']) { + const sel = resolveProfile(orphan, { cwd: '/x', positional: word }) + assert.equal(sel.error, null, `"${word}" was refused`) + assert.equal(sel.name, 'z', `"${word}" must still reach the default profile`) + assert.equal(sel.consumedPositional, false, `"${word}" must still reach claude`) + } +}) + +test('a name that is BOTH a profile and an account still selects the profile', () => { + // `z` is an account, an agent profile and a profile. Tier 1a matches first, so + // the check never sees it — a launch that already worked must keep working. + const sel = resolveProfile(orphan, { cwd: '/x', positional: 'z' }) + assert.equal(sel.error, null) + assert.equal(sel.name, 'z') + assert.equal(sel.source, 'positional') + assert.equal(sel.consumedPositional, true) +}) + +test('the escape hatch works because `--` leaves no positional at all', () => { + // `swisscode -- spare …` parses to positional: null (argv[0] starts with `-`), + // so the words reach claude verbatim. Asserted here as the contract the error + // message promises, since a suggested fix that does not work is worse than none. + const sel = resolveProfile(orphan, { cwd: '/x', positional: null }) + assert.equal(sel.error, null) + assert.equal(sel.name, 'z') +}) From e5d746b6739a56388a71a82a8da54c9b2d4f3d1e Mon Sep 17 00:00:00 2001 From: jellologic <31935831+jellologic@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:38:03 -0400 Subject: [PATCH 3/6] Rename agentProfiles to setups (schema v4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "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 ` 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> --- README.md | 26 +++-- src/adapters/claude-session/onboard.ts | 2 +- src/adapters/ui/index.tsx | 14 +-- src/adapters/web/api.ts | 60 +++++----- src/composition/config-root.ts | 54 +++++---- src/composition/launch-root.ts | 4 +- src/core/migrate.ts | 110 ++++++++++++++++-- src/core/overrides.ts | 2 +- src/core/profile.ts | 4 +- src/core/resolve.ts | 38 +++--- src/ports/config-store.ts | 30 +++-- test/adapters/claude-session-onboard.test.ts | 2 +- test/adapters/composite-providers.test.ts | 4 +- test/adapters/doctor-probe.test.ts | 20 ++-- test/adapters/doctor-session.test.ts | 18 +-- test/adapters/fs-config-store.test.ts | 35 +++--- test/adapters/ollama-doctor.test.ts | 8 +- test/adapters/version-check.test.ts | 4 +- test/adapters/web.test.ts | 8 +- test/config-commands.test.ts | 34 +++--- test/core/binding.test.ts | 6 +- test/core/doctor.test.ts | 6 +- test/core/migrate.test.ts | 100 ++++++++++++---- test/core/overrides.test.ts | 2 +- test/core/profile.test.ts | 22 ++-- test/core/resolve.test.ts | 30 ++--- test/core/session-mode.test.ts | 4 +- test/e2e/agents.e2e.ts | 4 +- test/e2e/errors.e2e.ts | 4 +- test/e2e/harness.ts | 10 +- test/e2e/launch.e2e.ts | 6 +- test/e2e/overrides.e2e.ts | 6 +- test/e2e/real-handoff.real.ts | 4 +- test/e2e/session.e2e.ts | 4 +- test/launch-overrides.test.ts | 8 +- test/picker.test.ts | 8 +- test/profiles-ui.test.ts | 6 +- test/support/fixtures.ts | 18 +-- test/ui-notice.test.ts | 6 +- test/ui.test.ts | 13 ++- web/src/App.tsx | 8 +- web/src/api.ts | 10 +- web/src/routes/Profiles.tsx | 34 +++--- .../routes/{AgentProfiles.tsx => Setups.tsx} | 34 +++--- 44 files changed, 498 insertions(+), 332 deletions(-) rename web/src/routes/{AgentProfiles.tsx => Setups.tsx} (94%) diff --git a/README.md b/README.md index 857453d..515c790 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,7 @@ If the first word isn't a profile name it's passed straight to `claude`, so `--cc-profile work` — an unknown name there is an error rather than a prompt. One exception to that fallthrough: a first word that names an **account** or an -**agent profile** is refused rather than sent as a prompt, because it is far +**setup** is refused rather than sent as a prompt, because it is far likelier to be a mis-aimed selection than the start of a sentence. ``` @@ -408,7 +408,7 @@ into each one to look. It caches them, and a profile with `"strategy": "usage"` then launches on whichever account has the most left: ```json -{ "agentProfile": "default", "accounts": ["personal", "work"], "strategy": "usage" } +{ "setup": "default", "accounts": ["personal", "work"], "strategy": "usage" } ``` Ranking uses the **tighter of the two windows, never their average**. An account @@ -593,12 +593,12 @@ holds an API key in plaintext. ```json { - "version": 3, + "version": 4, "providerAccounts": { "openrouter": { "provider": "openrouter", "apiKey": "sk-or-…" }, "personal": { "provider": "anthropic", "configDir": "/Users/me/.claude" } }, - "agentProfiles": { + "setups": { "default": { "agent": "claude-code", "models": { @@ -614,7 +614,7 @@ holds an API key in plaintext. } }, "profiles": { - "work": { "agentProfile": "default", "accounts": ["openrouter"], "strategy": "single" } + "work": { "setup": "default", "accounts": ["openrouter"], "strategy": "single" } }, "defaultProfile": "work", "bindings": { "/Users/me/clients/acme": "acme" }, @@ -623,12 +623,16 @@ holds an API key in plaintext. ``` Three separate things, because they vary independently. A **provider account** -is who pays — a key, or a subscription login. An **agent profile** is what runs -— which CLI, which model per tier, which flags. A **profile** pairs them and -says how to choose when it names more than one account (`single`, `round-robin`, -or `usage`). One agent profile can be shared by several profiles that bill -different accounts, which is the arrangement the older flat shape could not -express. +is who pays — a key, or a subscription login. A **setup** is what runs — which +CLI, which model per tier, which flags. A **profile** pairs them and says how to +choose when it names more than one account (`single`, `round-robin`, or +`usage`). One setup can be shared by several profiles that bill different +accounts, which is the arrangement the older flat shape could not express. + +> Setups were called `agentProfiles` before v4. Two things one word apart — +> "agent profile" and "profile" — read backwards to almost everyone, so the one +> nobody types got the new name. Existing configs migrate on first read, and +> `config agents` still works as an alias for `config setups`. `bindings` records absolute paths, which means client names and project layout. That's new non-credential information in this file — worth remembering before diff --git a/src/adapters/claude-session/onboard.ts b/src/adapters/claude-session/onboard.ts index 714b663..3f129cc 100644 --- a/src/adapters/claude-session/onboard.ts +++ b/src/adapters/claude-session/onboard.ts @@ -199,7 +199,7 @@ export function accountLogin({ } // Claude Code is the only agent with this flow — the login being adopted IS a - // Claude subscription — so this does not consult the agent profile. Kilo and + // Claude subscription — so this does not consult the setup. Kilo and // OpenCode declare `sessionDir: false` for exactly this reason. const agent = agents.byId('claude-code') if (!agent) { diff --git a/src/adapters/ui/index.tsx b/src/adapters/ui/index.tsx index 7ccb5ce..ccfad79 100644 --- a/src/adapters/ui/index.tsx +++ b/src/adapters/ui/index.tsx @@ -18,7 +18,7 @@ import { frameBorder, Select, tone } from './theme.tsx' import type { ProfileAction } from './ProfilePicker.tsx' import type { Tier, TierRecord, ProviderDescriptor, ProviderRegistryPort } from '../../ports/provider.ts' import type { - AgentProfile, + Setup, ConfigStorePort, Profile, ProviderAccount, @@ -447,7 +447,7 @@ export function App({ ...(provider!.askBaseUrl ? { baseUrl: baseUrl.trim() } : {}), apiKey: apiKey.trim(), } - const agentProfile: AgentProfile = { + const setup: Setup = { models, ...(Object.keys(keptWindows).length > 0 ? { contextWindows: keptWindows } : {}), skipPermissions, @@ -456,20 +456,20 @@ export function App({ // the provider, exactly as before profiles existed. const name = editingName ?? profileNameFor(doc, providerId) const profile: Profile = { - agentProfile: name, + setup: name, accounts: [name], strategy: 'single', } // The existing agent selection is preserved: `config agent` writes to the - // agent profile, and re-running the wizard must not silently reset a + // setup, and re-running the wizard must not silently reset a // profile back to Claude Code. - const existingAgent = doc.agentProfiles?.[name]?.agent - if (existingAgent !== undefined) agentProfile.agent = existingAgent + const existingAgent = doc.setups?.[name]?.agent + if (existingAgent !== undefined) setup.agent = existingAgent const next: State = { ...doc, providerAccounts: { ...(doc.providerAccounts ?? {}), [name]: account }, - agentProfiles: { ...(doc.agentProfiles ?? {}), [name]: agentProfile }, + setups: { ...(doc.setups ?? {}), [name]: setup }, profiles: { ...(doc.profiles ?? {}), [name]: profile }, defaultProfile: doc.defaultProfile ?? name, } diff --git a/src/adapters/web/api.ts b/src/adapters/web/api.ts index f28b08e..849513a 100644 --- a/src/adapters/web/api.ts +++ b/src/adapters/web/api.ts @@ -17,7 +17,7 @@ import type { IdentityCollision } from '../../core/account.ts' import { COMPAT_ENV, CREDENTIAL_ENVS } from '../agents/claude-code/env.ts' import { CATALOG_SOURCE, CLAUDE_ENV_CATALOG } from '../agents/claude-code/env-catalog.ts' import type { - AgentProfile, + Setup, ConfigStorePort, Profile, ProviderAccount, @@ -123,10 +123,10 @@ export function redactState(state: State): unknown { providerAccounts: Object.fromEntries( Object.entries(state.providerAccounts ?? {}).map(([n, a]) => [n, redactAccount(a)]), ), - // Agent profiles and profiles hold no credential at all now, so they pass + // Setups and profiles hold no credential at all now, so they pass // through whole. That is the split paying off: only one of the three shapes // is security-sensitive, and it is obvious which. - agentProfiles: state.agentProfiles ?? {}, + setups: state.setups ?? {}, profiles: state.profiles ?? {}, } } @@ -230,18 +230,18 @@ export function parseAccount( return account } -/** An agent profile submitted by the browser. Holds no credential. */ +/** A setup submitted by the browser. Holds no credential. */ export function parseAgentProfile( input: unknown, - existing: AgentProfile | undefined, -): AgentProfile | string { - if (!isObjectLike(input)) return 'agent profile must be an object' - const agentProfile: AgentProfile = { ...(existing ?? {}) } + existing: Setup | undefined, +): Setup | string { + if (!isObjectLike(input)) return 'setup must be an object' + const setup: Setup = { ...(existing ?? {}) } - if (typeof input.label === 'string') agentProfile.label = input.label - if (typeof input.agent === 'string') agentProfile.agent = input.agent + if (typeof input.label === 'string') setup.label = input.label + if (typeof input.agent === 'string') setup.agent = input.agent if (typeof input.skipPermissions === 'boolean') { - agentProfile.skipPermissions = input.skipPermissions + setup.skipPermissions = input.skipPermissions } if (isObjectLike(input.models)) { @@ -250,7 +250,7 @@ export function parseAgentProfile( const v = input.models[tier] if (typeof v === 'string') models[tier] = v } - agentProfile.models = models + setup.models = models } if (isObjectLike(input.compat)) { @@ -258,7 +258,7 @@ export function parseAgentProfile( for (const [k, v] of Object.entries(input.compat)) { if (typeof v === 'boolean') compat[k] = v } - agentProfile.compat = compat as NonNullable + setup.compat = compat as NonNullable } if (isObjectLike(input.env)) { @@ -266,7 +266,7 @@ export function parseAgentProfile( for (const [k, v] of Object.entries(input.env)) { if (typeof v === 'string') env[k] = v } - agentProfile.env = env + setup.env = env } // Measured windows only. A non-integer or non-positive entry is dropped @@ -277,10 +277,10 @@ export function parseAgentProfile( for (const [model, v] of Object.entries(input.contextWindows)) { if (typeof v === 'number' && Number.isInteger(v) && v > 0) windows[model] = v } - agentProfile.contextWindows = windows + setup.contextWindows = windows } - return agentProfile + return setup } /** @@ -293,15 +293,15 @@ export function parseAgentProfile( */ export function parseProfile(input: unknown, existing: Profile | undefined): Profile | string { if (!isObjectLike(input)) return 'profile must be an object' - const agentProfile = str(input.agentProfile) ?? existing?.agentProfile - if (!agentProfile) return 'agentProfile is required' + const setup = str(input.setup) ?? existing?.setup + if (!setup) return 'setup is required' const accounts = Array.isArray(input.accounts) ? input.accounts.filter((a): a is string => typeof a === 'string' && a.length > 0) : (existing?.accounts ?? []) if (accounts.length === 0) return 'a profile needs at least one provider account' - const profile: Profile = { ...(existing ?? {}), agentProfile, accounts } + const profile: Profile = { ...(existing ?? {}), setup, accounts } if (typeof input.label === 'string') profile.label = input.label if (input.strategy === 'single' || input.strategy === 'round-robin' || input.strategy === 'usage') { profile.strategy = input.strategy @@ -400,8 +400,8 @@ export function handleApi(req: ApiRequest, deps: ApiDeps): ApiResponse { // References are checked HERE, where the state is in hand. parseProfile // validated the shape; this validates that the things it names exist. - if (!loaded.state.agentProfiles?.[parsed.agentProfile]) { - return fail(400, `no agent profile named "${parsed.agentProfile}"`) + if (!loaded.state.setups?.[parsed.setup]) { + return fail(400, `no setup named "${parsed.setup}"`) } const missing = parsed.accounts.filter((a) => !loaded.state.providerAccounts?.[a]) if (missing.length > 0) { @@ -483,20 +483,20 @@ export function handleApi(req: ApiRequest, deps: ApiDeps): ApiResponse { if (resource === 'agent-profiles') { const name = rest[0] ? decodeURIComponent(rest[0]) : null - if (!name) return fail(400, 'agent profile name is required') + if (!name) return fail(400, 'setup name is required') if (req.method === 'PUT') { const conflict = revisionConflict(store, req.body) if (conflict) return conflict const loaded = store.load() const parsed = parseAgentProfile( - isObjectLike(req.body) ? req.body.agentProfile : null, - loaded.state.agentProfiles?.[name], + isObjectLike(req.body) ? req.body.setup : null, + loaded.state.setups?.[name], ) if (typeof parsed === 'string') return fail(400, parsed) return commit(store, { ...loaded.state, - agentProfiles: { ...loaded.state.agentProfiles, [name]: parsed }, + setups: { ...loaded.state.setups, [name]: parsed }, }) } @@ -504,13 +504,13 @@ export function handleApi(req: ApiRequest, deps: ApiDeps): ApiResponse { const conflict = revisionConflict(store, req.body) if (conflict) return conflict const loaded = store.load() - if (!loaded.state.agentProfiles?.[name]) return fail(404, `no agent profile named "${name}"`) + if (!loaded.state.setups?.[name]) return fail(404, `no setup named "${name}"`) const affected = Object.entries(loaded.state.profiles ?? {}) - .filter(([, pr]) => pr.agentProfile === name) + .filter(([, pr]) => pr.setup === name) .map(([n]) => n) - const agentProfiles = { ...loaded.state.agentProfiles } - delete agentProfiles[name] - return commit(store, { ...loaded.state, agentProfiles }, { affectedProfiles: affected }) + const setups = { ...loaded.state.setups } + delete setups[name] + return commit(store, { ...loaded.state, setups }, { affectedProfiles: affected }) } } diff --git a/src/composition/config-root.ts b/src/composition/config-root.ts index 219e798..3b6d824 100644 --- a/src/composition/config-root.ts +++ b/src/composition/config-root.ts @@ -93,6 +93,10 @@ export type RunConfigCommandOptions = { const SUBCOMMANDS = Object.freeze([ 'list', 'default', 'agent', 'rm', 'use', 'bind', 'unbind', 'bindings', 'doctor', 'help', 'accounts', + 'setups', + // v3's name for `setups`. Kept dispatchable so an install that scripted it + // does not break on upgrade; undocumented in USAGE, and it says so once when + // used. See the v3->v4 note in core/migrate.ts for why the word changed. 'agents', ]) @@ -113,7 +117,7 @@ const USAGE = `swisscode config — manage profiles and directory bindings swisscode config accounts swap move one account's login into another session --into directory — narrower than /login, which hits every running session at once - swisscode config agents agent profiles, and which profiles use each + swisscode config setups setups, and which profiles use each swisscode config agent list agents and which profile uses each swisscode config agent show which coding CLI launches @@ -207,8 +211,11 @@ export async function runConfigCommand({ return upgradeCommand({ deps, args: rest, out, err }) case 'accounts': return accountsCommand({ deps, args: rest, out, err }) + case 'setups': + return listSetups({ deps, out }) case 'agents': - return listAgentProfiles({ deps, out }) + err('swisscode: `config agents` is now `config setups`. The old name still works for now.') + return listSetups({ deps, out }) default: break } @@ -302,7 +309,7 @@ function agentCommand({ return 0 } for (const n of names) { - const ap = state.agentProfiles?.[state.profiles[n]?.agentProfile ?? ''] + const ap = state.setups?.[state.profiles[n]?.setup ?? ''] out(` ${n} → ${ap?.agent ?? DEFAULT_AGENT_ID}`) } return 0 @@ -318,10 +325,10 @@ function agentCommand({ return 2 } - const agentProfileName = profile.agentProfile - const agentProfile = state.agentProfiles?.[agentProfileName] + const setupName = profile.setup + const setup = state.setups?.[setupName] if (agentId === undefined) { - out(`${profileName} → ${agentProfile?.agent ?? DEFAULT_AGENT_ID}`) + out(`${profileName} → ${setup?.agent ?? DEFAULT_AGENT_ID}`) return 0 } @@ -332,31 +339,31 @@ function agentCommand({ return 2 } if (loaded.readOnly) return refuseWrite(err) - if (!agentProfile) { + if (!setup) { err( - `swisscode: profile "${profileName}" uses agent profile "${agentProfileName}", which ` + + `swisscode: profile "${profileName}" uses setup "${setupName}", which ` + 'does not exist. Run `swisscode config ' + profileName + '` to repair it.', ) return 2 } // Written to the AGENT PROFILE, not the profile: since v3 that is where the - // coding CLI lives, and an agent profile may back several profiles — which is + // coding CLI lives, and a setup may back several profiles — which is // the point of the split, and worth the reminder in the confirmation line. const next: State = { ...state, - agentProfiles: { - ...state.agentProfiles, - [agentProfileName]: { ...agentProfile, agent: agentId }, + setups: { + ...state.setups, + [setupName]: { ...setup, agent: agentId }, }, } deps.store.save(next) const alsoUsing = Object.entries(state.profiles ?? {}) - .filter(([n, pr]) => n !== profileName && pr.agentProfile === agentProfileName) + .filter(([n, pr]) => n !== profileName && pr.setup === setupName) .map(([n]) => n) out( `${profileName} now launches ${agentId}.` + (alsoUsing.length - ? ` (shared agent profile "${agentProfileName}" — also used by ${alsoUsing.join(', ')})` + ? ` (shared setup "${setupName}" — also used by ${alsoUsing.join(', ')})` : ''), ) return 0 @@ -428,7 +435,7 @@ function listProfiles({ deps, out }: { deps: LaunchDeps; out: Emit }): number { // Resolution warnings say what was skipped and why; swallowing them here // would leave a stale reference invisible until someone read the JSON. for (const w of resolution.warnings) out(` ⚠ ${w}`) - out(` agent ${r.agentProfileName} → ${r.agent ?? DEFAULT_AGENT_ID}`) + out(` agent ${r.setupName} → ${r.agent ?? DEFAULT_AGENT_ID}`) if (r.baseUrl) out(` baseUrl ${r.baseUrl}`) // Presence and ORIGIN only. Never a prefix, never a suffix, never a length: // a masked key is still a fingerprint, and this output gets pasted into bug @@ -1310,24 +1317,25 @@ const WHY_MATCHED: Record = { } /** - * `swisscode config agents` — what runs, and who uses it. + * `swisscode config setups` — what runs, and who uses it. * * Named for the concept rather than the CLI: `config agent ` - * already existed and still edits which coding CLI a profile launches. This - * lists the agent PROFILES, which is the thing that can now be shared. + * already existed and still edits WHICH coding CLI a setup launches, so it + * keeps its name. This lists the setups themselves, which is the thing that can + * be shared between profiles. */ -function listAgentProfiles({ deps, out }: { deps: LaunchDeps; out: Emit }): number { +function listSetups({ deps, out }: { deps: LaunchDeps; out: Emit }): number { const { state } = deps.store.load() - const names = Object.keys(state.agentProfiles ?? {}).sort() + const names = Object.keys(state.setups ?? {}).sort() if (names.length === 0) { - out('No agent profiles yet. Run `swisscode config` to make one.') + out('No setups yet. Run `swisscode config` to make one.') return 0 } for (const name of names) { - const ap = state.agentProfiles[name]! + const ap = state.setups[name]! const usedBy = Object.entries(state.profiles ?? {}) - .filter(([, p]) => p.agentProfile === name) + .filter(([, p]) => p.setup === name) .map(([n]) => n) out(` ${name}${ap.label ? ` (${ap.label})` : ''}`) diff --git a/src/composition/launch-root.ts b/src/composition/launch-root.ts index e9ef239..94e1f0a 100644 --- a/src/composition/launch-root.ts +++ b/src/composition/launch-root.ts @@ -153,7 +153,7 @@ export type LaunchPlan = { needsSetup: false loaded: LoadResult selection: ProfileSelection - /** the flattened account + agent profile this launch resolved to */ + /** the flattened account + setup this launch resolved to */ profile: ResolvedProfile /** * null is a REAL state, not a defect: a profile naming a provider this build @@ -234,7 +234,7 @@ export function planLaunch({ // A matched positional profile name is CONSUMED — claude never sees it. const args = sel.consumedPositional ? passthrough.slice(1) : passthrough - // v3: a profile is three objects. Dereference the agent profile and select + // v3: a profile is three objects. Dereference the setup and select // one account BEFORE anything else, because every step below operates on the // flattened view — which is deliberately the shape v2 stored, so nothing // downstream of here changed when the schema split. diff --git a/src/core/migrate.ts b/src/core/migrate.ts index 7fe7f28..b2a10d1 100644 --- a/src/core/migrate.ts +++ b/src/core/migrate.ts @@ -18,7 +18,7 @@ import type { State, } from '../ports/config-store.ts' -export const SUPPORTED_VERSION = 3 +export const SUPPORTED_VERSION = 4 /** Enforced at creation, never at parse — a hand-edited file is still read. */ export const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/ @@ -86,7 +86,7 @@ export function emptyState(): State { return { version: SUPPORTED_VERSION, providerAccounts: {}, - agentProfiles: {}, + setups: {}, profiles: {}, defaultProfile: null, bindings: {}, @@ -222,7 +222,11 @@ export function fromV2(raw: ConfigV2): V3Draft { } const draft: V3Draft = { - version: SUPPORTED_VERSION, + // LITERAL 3, not SUPPORTED_VERSION. `fromV1` has carried this warning since + // before there was a v4: stamping the current version here would make the + // ladder skip the v3->v4 step, because `migrate` dispatches on the number. + // The moment SUPPORTED_VERSION became 4 this was the bug it predicted. + version: 3, providerAccounts, agentProfiles, profiles, @@ -236,6 +240,83 @@ export function fromV2(raw: ConfigV2): V3Draft { return draft } +/** + * The v4 SHAPE. Identical to v3 but for two names, which is the whole change. + */ +type V4Draft = Omit & { + version: number + setups: Record> +} + +/** + * v3 -> v4. Lossless, deterministic, and it repairs nothing. + * + * PURELY A RENAME: `agentProfiles` -> `setups`, and each profile's + * `agentProfile` -> `setup`. Nothing moves between objects and no value is + * rewritten, which is why this step has no rules of its own the way v1->v2 and + * v2->v3 do. + * + * The rename exists because "agent profile" and "profile" were two names one + * word apart for two different things, and users read the pair exactly + * backwards — "agent profiles are Claude Code setting overrides, optional". + * They are neither optional nor settings: 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 (`swisscode work`, `--cc-profile`, + * bindings, `config default`). + * + * A v3 file that already carries `setups` (written by a newer swisscode, then + * read back by one that stamps v3 — not a shape we produce, but cheap to + * survive) keeps them: the old key is merged OVER the new one only where it + * exists, so nothing is lost either way. + */ +export function fromV3(raw: Record): V4Draft { + const setups: Record> = { + ...(isPlainObject(raw.setups) ? (raw.setups as Record>) : {}), + ...(isPlainObject(raw.agentProfiles) + ? (raw.agentProfiles as Record>) + : {}), + } + + const profiles: Record> = {} + for (const [name, p] of Object.entries(isPlainObject(raw.profiles) ? raw.profiles : {})) { + if (!isPlainObject(p)) continue + const { agentProfile, ...rest } = p as Record + // `setup` wins if both are somehow present; otherwise the v3 key becomes it. + // Written this way rather than as a delete so rule M1's unrecognized keys + // ride through untouched, exactly as they did in the earlier steps. + profiles[name] = { + ...rest, + ...(rest.setup !== undefined + ? {} + : agentProfile !== undefined + ? { setup: agentProfile } + : {}), + } + } + + // SPREAD, then override. Rule W3: unknown top-level keys survive the ladder, + // so an older binary round-trips a newer file's additions instead of eating + // them. Rebuilding this object field-by-field silently broke that — a + // `somethingNewer` key written by a future swisscode vanished on the first + // save by this one, which is data loss disguised as a rename. + const { agentProfiles: _dropped, ...rest } = raw + return { + ...rest, + version: SUPPORTED_VERSION, + providerAccounts: isPlainObject(raw.providerAccounts) + ? (raw.providerAccounts as Record>) + : {}, + setups, + profiles, + defaultProfile: typeof raw.defaultProfile === 'string' ? raw.defaultProfile : null, + bindings: (isPlainObject(raw.bindings) ? raw.bindings : {}) as Record, + settings: (isPlainObject(raw.settings) ? raw.settings : {}) as Settings, + ...(raw.providers !== undefined + ? { providers: raw.providers as Record } + : {}), + } +} + /** Fill defaults, drop junk, resolve the default profile. Idempotent. */ export function normalize(raw: unknown): { state: State; warnings: string[] } { const warnings: string[] = [] @@ -266,7 +347,7 @@ export function normalize(raw: unknown): { state: State; warnings: string[] } { // The two new v3 maps, validated exactly as `profiles` is: an entry that is // not an object is dropped with a warning rather than reaching a consumer // that will read fields off a number. - for (const key of ['providerAccounts', 'agentProfiles'] as const) { + for (const key of ['providerAccounts', 'setups'] as const) { if (!isPlainObject(state[key])) { if (state[key] !== undefined) { warnings.push(`config.json: \`${key}\` is not an object; ignoring it.`) @@ -370,7 +451,7 @@ export function migrate(raw: unknown): MigrateResult { const salvage = { version, providerAccounts: isPlainObject(raw.providerAccounts) ? raw.providerAccounts : {}, - agentProfiles: isPlainObject(raw.agentProfiles) ? raw.agentProfiles : {}, + setups: isPlainObject(raw.setups) ? raw.setups : {}, profiles: isPlainObject(raw.profiles) ? raw.profiles : {}, defaultProfile: typeof raw.defaultProfile === 'string' ? raw.defaultProfile : null, bindings: isPlainObject(raw.bindings) ? raw.bindings : {}, @@ -381,8 +462,13 @@ export function migrate(raw: unknown): MigrateResult { return { state, migratedFrom: null, corrupt: false, readOnly: true, warnings } } + if (version === 3) { + const { state, warnings } = normalize(fromV3(raw)) + return { state, migratedFrom: 3, corrupt: false, readOnly: false, warnings } + } + if (version === 2) { - const { state, warnings } = normalize(fromV2(raw as unknown as ConfigV2)) + const { state, warnings } = normalize(fromV3(fromV2(raw as unknown as ConfigV2))) return { state, migratedFrom: 2, corrupt: false, readOnly: false, warnings } } @@ -395,10 +481,14 @@ export function migrate(raw: unknown): MigrateResult { // reaches `fromV1`, where `raw.provider` is undefined, `NAME_RE.test(undefined)` // coerces to the string "undefined" and MATCHES, and rule M1 nests the // entire file inside a single profile named "undefined". - // CHAINED, not parallel. fromV1 produces a v2 draft, which fromV2 then - // splits — so a 0.1.0 file reaches v3 in a single read and there is exactly - // one implementation of each step to keep correct. - const { state, warnings } = normalize(fromV2(fromV1(raw as unknown as ConfigV1) as unknown as ConfigV2)) + // CHAINED, not parallel. fromV1 produces a v2 draft, which fromV2 splits, + // which fromV3 renames — so a 0.1.0 file reaches v4 in a single read and + // there is exactly one implementation of each step to keep correct. Each + // step stamps its OWN output version rather than the current one, which is + // what lets the ladder grow without the earlier rungs quietly falling off. + const { state, warnings } = normalize( + fromV3(fromV2(fromV1(raw as unknown as ConfigV1) as unknown as ConfigV2)), + ) return { state, migratedFrom: 1, corrupt: false, readOnly: false, warnings } } diff --git a/src/core/overrides.ts b/src/core/overrides.ts index 413a50d..5bdd7c2 100644 --- a/src/core/overrides.ts +++ b/src/core/overrides.ts @@ -51,7 +51,7 @@ export function applyOverrides( } if (overrides.env && typeof overrides.env === 'object') { - // Merged AFTER the agent profile's env, and '' still means UNSET. + // Merged AFTER the setup's env, and '' still means UNSET. next.env = { ...(next.env ?? {}), ...overrides.env } } diff --git a/src/core/profile.ts b/src/core/profile.ts index 86d49d1..ebc0b64 100644 --- a/src/core/profile.ts +++ b/src/core/profile.ts @@ -136,8 +136,8 @@ export function resolveProfile( if (positional) { const kind = has(state?.providerAccounts ?? {}, positional) ? ({ what: 'an account', does: 'accounts say who pays' } as const) - : has(state?.agentProfiles ?? {}, positional) - ? ({ what: 'an agent profile', does: 'agent profiles say what runs' } as const) + : has(state?.setups ?? {}, positional) + ? ({ what: 'a setup', does: 'setups say what runs' } as const) : null if (kind) { return { diff --git a/src/core/resolve.ts b/src/core/resolve.ts index 120b5db..e0ee8c3 100644 --- a/src/core/resolve.ts +++ b/src/core/resolve.ts @@ -1,8 +1,8 @@ -// Turn a selected profile into the one account + agent profile a launch uses. +// Turn a selected profile into the one account + setup a launch uses. // // `core/profile.ts` answers WHICH profile (positional / flag / binding / // default). This answers what that profile actually resolves to, which since v3 -// means dereferencing an agent profile and choosing among one or more accounts. +// means dereferencing a setup and choosing among one or more accounts. // // Pure, and pure on purpose: strategy selection is the step that decides WHICH // ACCOUNT PAYS, so it must be exhaustively testable without a store, a clock or @@ -68,17 +68,17 @@ export type ResolveOptions = { now?: number } -/** Flatten an account and an agent profile into the shape a launch consumes. */ +/** Flatten an account and a setup into the shape a launch consumes. */ function flatten( accountName: string, account: ProviderAccount, - agentProfileName: string, + setupName: string, state: State, ): ResolvedProfile { - const agentProfile = state.agentProfiles?.[agentProfileName] ?? {} + const setup = state.setups?.[setupName] ?? {} const resolved: ResolvedProfile = { accountName, - agentProfileName, + setupName, provider: account.provider, } // Assigned conditionally throughout: `exactOptionalPropertyTypes` makes @@ -90,15 +90,15 @@ function flatten( if (account.apiKeyFromEnv !== undefined) resolved.apiKeyFromEnv = account.apiKeyFromEnv if (account.configDir !== undefined) resolved.configDir = account.configDir - if (agentProfile.agent !== undefined) resolved.agent = agentProfile.agent - if (agentProfile.models !== undefined) resolved.models = agentProfile.models - if (agentProfile.skipPermissions !== undefined) { - resolved.skipPermissions = agentProfile.skipPermissions + if (setup.agent !== undefined) resolved.agent = setup.agent + if (setup.models !== undefined) resolved.models = setup.models + if (setup.skipPermissions !== undefined) { + resolved.skipPermissions = setup.skipPermissions } - if (agentProfile.env !== undefined) resolved.env = agentProfile.env - if (agentProfile.compat !== undefined) resolved.compat = agentProfile.compat - if (agentProfile.contextWindows !== undefined) { - resolved.contextWindows = agentProfile.contextWindows + if (setup.env !== undefined) resolved.env = setup.env + if (setup.compat !== undefined) resolved.compat = setup.compat + if (setup.contextWindows !== undefined) { + resolved.contextWindows = setup.contextWindows } return resolved } @@ -172,7 +172,7 @@ export function selectAccount( * * Every failure names the fix rather than reporting a shape mismatch: a config * that dangles is something a person has to repair, and "profile X references - * agent profile Y, which does not exist" is the whole diagnosis. + * setup Y, which does not exist" is the whole diagnosis. */ export function resolveProfileRefs( state: State, @@ -182,12 +182,12 @@ export function resolveProfileRefs( const profile = state.profiles?.[profileName] if (!profile) return { ok: false, reason: `profile "${profileName}" does not exist.` } - const agentProfileName = profile.agentProfile - if (!agentProfileName || !state.agentProfiles?.[agentProfileName]) { + const setupName = profile.setup + if (!setupName || !state.setups?.[setupName]) { return { ok: false, reason: - `profile "${profileName}" uses agent profile "${agentProfileName ?? '—'}", which does ` + + `profile "${profileName}" uses setup "${setupName ?? '—'}", which does ` + 'not exist. Run `swisscode config ' + profileName + '` to repair it.', } } @@ -235,7 +235,7 @@ export function resolveProfileRefs( return { ok: true, - resolved: flatten(picked.name, account, agentProfileName, state), + resolved: flatten(picked.name, account, setupName, state), warnings, } } diff --git a/src/ports/config-store.ts b/src/ports/config-store.ts index 60f1807..fb4e722 100644 --- a/src/ports/config-store.ts +++ b/src/ports/config-store.ts @@ -78,11 +78,11 @@ export type ProviderAccount = { * a documented, tested behaviour. Reshaping it is a separate decision that * should not ride along with a schema migration. */ -export type AgentProfile = { +export type Setup = { /** * id from the agent registry — which coding CLI to launch. Absent means the - * default, 'claude-code'. An agent profile naming an agent this build does - * not know still launches the default (launch-root refuses only an explicit + * default, 'claude-code'. A setup naming an agent this build does not know + * still launches the default (launch-root refuses only an explicit * --cc-agent for an unknown id). */ agent?: string @@ -109,7 +109,7 @@ export type AgentProfile = { } /** - * One account and one agent profile, flattened — what a launch actually needs. + * One account and one setup, flattened — what a launch actually needs. * * THIS IS THE SEAM THAT KEEPS THE v3 SPLIT CHEAP. It is deliberately the same * shape v2's `Profile` had, so everything downstream of resolution — the agent @@ -124,7 +124,7 @@ export type AgentProfile = { export type ResolvedProfile = { /** which account was selected, so callers can report it */ accountName: string - agentProfileName: string + setupName: string // ── from the provider account ── provider: string baseUrl?: string @@ -132,7 +132,7 @@ export type ResolvedProfile = { apiKeyFromEnv?: string /** session mode: a directory holding a login the agent already performed */ configDir?: string - // ── from the agent profile ── + // ── from the setup ── agent?: string models?: Partial> skipPermissions?: boolean @@ -166,8 +166,8 @@ export type SelectionStrategy = 'single' | 'round-robin' | 'usage' */ export type Profile = { label?: string - /** key into `State.agentProfiles` */ - agentProfile: string + /** key into `State.setups` */ + setup: string /** * keys into `State.providerAccounts`, in preference order. Never empty in a * valid config; an empty list is a resolution error that names the fix rather @@ -243,7 +243,7 @@ export type State = { /** who pays */ providerAccounts: Record /** what runs */ - agentProfiles: Record + setups: Record /** the pairing — what `swisscode ` and every binding refer to */ profiles: Record defaultProfile: string | null @@ -281,7 +281,7 @@ export type ConfigV1 = { * The v2 profile: provider, credential, agent and agent settings on ONE object. * * Kept as a type because `fromV2` still has to read it. This is the shape v3 - * splits into `ProviderAccount` + `AgentProfile` + `Profile`, and writing it + * splits into `ProviderAccount` + `Setup` + `Profile`, and writing it * down is what lets that migration be checked rather than guessed at. * * The index signature is rule M1 again, inherited from v1: unrecognized keys @@ -313,7 +313,13 @@ export type ConfigV2 = { providers?: Record } -export type ConfigV3 = State +/** + * v3 is no longer the current shape — v4 renamed `setups` to `setups` + * and `Profile.setup` to `Profile.setup`. Kept as the untrusted bag the + * ladder reads, NOT as an alias of `State`, which is what it was while the two + * happened to coincide. + */ +export type ConfigV3 = Record /** * What the migration ladder reports. @@ -326,7 +332,7 @@ export type ConfigV3 = State */ export type MigrateResult = { state: State - migratedFrom: 1 | 2 | null + migratedFrom: 1 | 2 | 3 | null /** the file existed but could not be understood */ corrupt: boolean /** the file is a NEWER schema; every write path is disabled */ diff --git a/test/adapters/claude-session-onboard.test.ts b/test/adapters/claude-session-onboard.test.ts index f4f820c..50b2ee0 100644 --- a/test/adapters/claude-session-onboard.test.ts +++ b/test/adapters/claude-session-onboard.test.ts @@ -211,7 +211,7 @@ test('the default directory earns no permissions warning — swisscode did not c }) test('the recorded config is exactly one account and nothing else', () => { - const h = harness({ profiles: { p: { agentProfile: 'a', accounts: [] } } } as Partial) + const h = harness({ profiles: { p: { setup: 'a', accounts: [] } } } as Partial) h.run({ ...base(h), name: 'personal' }) const saved = h.saved.at(-1)! assert.deepEqual(Object.keys(saved.providerAccounts ?? {}), ['personal']) diff --git a/test/adapters/composite-providers.test.ts b/test/adapters/composite-providers.test.ts index 4b2394c..67e9bec 100644 --- a/test/adapters/composite-providers.test.ts +++ b/test/adapters/composite-providers.test.ts @@ -29,11 +29,11 @@ const stateWith = (providers: Record): State => providerAccounts: { p: makeProfile({ provider: 'my-gw', apiKey: 'k' }), }, - agentProfiles: { + setups: { p: {}, }, profiles: { - p: { agentProfile: 'p', accounts: ['p'] }, + p: { setup: 'p', accounts: ['p'] }, }, defaultProfile: 'p', bindings: {}, diff --git a/test/adapters/doctor-probe.test.ts b/test/adapters/doctor-probe.test.ts index 8f060fe..424b269 100644 --- a/test/adapters/doctor-probe.test.ts +++ b/test/adapters/doctor-probe.test.ts @@ -176,11 +176,11 @@ function deps(over: { state?: State; env?: EnvMap } = {}) { providerAccounts: { z: { provider: 'zai', apiKey: SECRET }, }, - agentProfiles: { + setups: { z: { models: { opus: 'glm-5.2', sonnet: 'glm-5.2', haiku: 'glm-5.2', fable: 'glm-5.2' } }, }, profiles: { - z: { agentProfile: 'z', accounts: ['z'] }, + z: { setup: 'z', accounts: ['z'] }, }, defaultProfile: 'z', bindings: {}, @@ -220,11 +220,11 @@ test('a provider placeholder is not treated as a secret and is not redacted', as providerAccounts: { local: makeProfile({ provider: 'ollama' }), }, - agentProfiles: { + setups: { local: { models: { opus: 'qwen3-coder:30b' } }, }, profiles: { - local: { agentProfile: 'local', accounts: ['local'] }, + local: { setup: 'local', accounts: ['local'] }, }, defaultProfile: 'local', bindings: {}, @@ -295,11 +295,11 @@ test('the total budget stops the run rather than running long', async () => { providerAccounts: { z: { provider: 'zai', apiKey: SECRET }, }, - agentProfiles: { + setups: { z: { models: { opus: 'a', sonnet: 'b', haiku: 'c', fable: 'd' } }, }, profiles: { - z: { agentProfile: 'z', accounts: ['z'] }, + z: { setup: 'z', accounts: ['z'] }, }, defaultProfile: 'z', bindings: {}, @@ -361,11 +361,11 @@ test('--fix prunes dangling bindings and nothing else', async () => { providerAccounts: { z: { provider: 'zai', apiKey: SECRET }, }, - agentProfiles: { + setups: { z: { models: { opus: 'glm-5.2' } }, }, profiles: { - z: { agentProfile: 'z', accounts: ['z'] }, + z: { setup: 'z', accounts: ['z'] }, }, defaultProfile: 'z', bindings: { '/definitely/not/a/real/path': 'gone' }, @@ -375,7 +375,7 @@ test('--fix prunes dangling bindings and nothing else', async () => { await runDoctor({ deps: d.deps, offline: true, fix: true }) assert.equal(d.saves.length, 1) assert.deepEqual(d.saves[0]!.bindings, {}) - // The model string the user pinned is untouched — on the agent profile, + // The model string the user pinned is untouched — on the setup, // which is where models live since v3. - assert.deepEqual(d.saves[0]!.agentProfiles.z!.models, { opus: 'glm-5.2' }) + assert.deepEqual(d.saves[0]!.setups.z!.models, { opus: 'glm-5.2' }) }) diff --git a/test/adapters/doctor-session.test.ts b/test/adapters/doctor-session.test.ts index eee4d70..825c839 100644 --- a/test/adapters/doctor-session.test.ts +++ b/test/adapters/doctor-session.test.ts @@ -62,8 +62,8 @@ function deps(over: DepsOver = {}) { makeState({ version: 3, providerAccounts: { z: makeAccount({ provider: 'zai', apiKey: 'k' }) }, - agentProfiles: { z: makeAgentProfile({ models: { opus: 'glm-5.2' } }) }, - profiles: { z: makeProfileRefs({ agentProfile: 'z', accounts: ['z'] }) }, + setups: { z: makeAgentProfile({ models: { opus: 'glm-5.2' } }) }, + profiles: { z: makeProfileRefs({ setup: 'z', accounts: ['z'] }) }, defaultProfile: 'z', bindings: {}, settings: {}, @@ -94,10 +94,10 @@ const sessionState = (configDir: string, over: { strategy?: SelectionStrategy } makeState({ version: 3, providerAccounts: { personal: makeAccount({ provider: 'anthropic', configDir }) }, - agentProfiles: { a: makeAgentProfile({ models: { opus: 'claude-opus-4-8' } }) }, + setups: { a: makeAgentProfile({ models: { opus: 'claude-opus-4-8' } }) }, profiles: { a: makeProfileRefs({ - agentProfile: 'a', + setup: 'a', accounts: ['personal'], ...(over.strategy ? { strategy: over.strategy } : {}), }), @@ -219,10 +219,10 @@ test('only the accounts a usage profile NAMES are measured', async () => { personal: makeAccount({ provider: 'anthropic', configDir: dir }), unused: makeAccount({ provider: 'anthropic', configDir: other }), }, - agentProfiles: { a: makeAgentProfile({ models: { opus: 'claude-opus-4-8' } }) }, + setups: { a: makeAgentProfile({ models: { opus: 'claude-opus-4-8' } }) }, profiles: { - a: makeProfileRefs({ agentProfile: 'a', accounts: ['personal'], strategy: 'usage' }), - b: makeProfileRefs({ agentProfile: 'a', accounts: ['unused'] }), + a: makeProfileRefs({ setup: 'a', accounts: ['personal'], strategy: 'usage' }), + b: makeProfileRefs({ setup: 'a', accounts: ['unused'] }), }, defaultProfile: 'a', bindings: {}, @@ -270,9 +270,9 @@ test('a partly-measured set writes what it has and NAMES what it missed', async one: makeAccount({ provider: 'anthropic', configDir: a }), two: makeAccount({ provider: 'anthropic', configDir: b }), }, - agentProfiles: { p: makeAgentProfile({ models: { opus: 'claude-opus-4-8' } }) }, + setups: { p: makeAgentProfile({ models: { opus: 'claude-opus-4-8' } }) }, profiles: { - p: makeProfileRefs({ agentProfile: 'p', accounts: ['one', 'two'], strategy: 'usage' }), + p: makeProfileRefs({ setup: 'p', accounts: ['one', 'two'], strategy: 'usage' }), }, defaultProfile: 'p', bindings: {}, diff --git a/test/adapters/fs-config-store.test.ts b/test/adapters/fs-config-store.test.ts index 811d4df..d07373b 100644 --- a/test/adapters/fs-config-store.test.ts +++ b/test/adapters/fs-config-store.test.ts @@ -13,6 +13,7 @@ import { import { tmpdir } from 'node:os' import { join } from 'node:path' import { createFsConfigStore } from '../../src/adapters/store/fs-config-store.ts' +import { SUPPORTED_VERSION } from '../../src/core/migrate.ts' import { makeProfile } from '../support/fixtures.ts' /** Byte-for-byte what swisscode 0.1.0's saveConfig writes. */ @@ -52,20 +53,20 @@ test('a real 0.1.0 config file migrates automatically on load', () => { const loaded = store.load() assert.equal(loaded.migrated, true) - assert.equal(loaded.state.version, 3) + assert.equal(loaded.state.version, SUPPORTED_VERSION) assert.equal(loaded.state.defaultProfile, 'zai') assert.deepEqual(loaded.state.providerAccounts.zai, { provider: 'zai', apiKey: 'zai-secret-key', }) - assert.deepEqual(loaded.state.agentProfiles.zai, { + assert.deepEqual(loaded.state.setups.zai, { models: { opus: 'glm-5.2', sonnet: 'glm-5.2', haiku: 'glm-5.2' }, skipPermissions: true, }) // Persisted, and the original kept beside it. const onDisk = JSON.parse(readFileSync(join(dir, 'config.json'), 'utf8')) - assert.equal(onDisk.version, 3) + assert.equal(onDisk.version, SUPPORTED_VERSION) assert.equal(onDisk.providerAccounts.zai.apiKey, 'zai-secret-key') assert.equal(readFileSync(join(dir, 'config.v1.bak.json'), 'utf8'), V1_ON_DISK) assert.ok(loaded.warnings.some((w) => w.includes('migrated'))) @@ -96,8 +97,8 @@ test('save writes 0600 in 0700 even when the directory did not exist', () => { store.save({ version: 3, providerAccounts: { a: { provider: 'zai' } }, - agentProfiles: { a: {} }, - profiles: { a: { agentProfile: 'a', accounts: ['a'] } }, + setups: { a: {} }, + profiles: { a: { setup: 'a', accounts: ['a'] } }, defaultProfile: 'a', bindings: {}, settings: {}, @@ -108,7 +109,7 @@ test('save writes 0600 in 0700 even when the directory did not exist', () => { test('save leaves no temp file behind', () => { const { dir, store } = freshHome() - store.save({ version: 2, providerAccounts: {}, agentProfiles: {}, profiles: {}, defaultProfile: null, bindings: {}, settings: {} }) + store.save({ version: 2, providerAccounts: {}, setups: {}, profiles: {}, defaultProfile: null, bindings: {}, settings: {} }) assert.deepEqual(readdirSync(dir), ['config.json']) }) @@ -120,7 +121,7 @@ test('a truncated config is quarantined rather than overwritten in place', () => // Still on disk: nothing has been written yet. assert.equal(readFileSync(join(dir, 'config.json'), 'utf8'), '{"provider": "zai", "apiK') - store.save({ version: 2, providerAccounts: {}, agentProfiles: {}, profiles: {}, defaultProfile: null, bindings: {}, settings: {} }) + store.save({ version: 2, providerAccounts: {}, setups: {}, profiles: {}, defaultProfile: null, bindings: {}, settings: {} }) const quarantined = readdirSync(dir).filter((f) => f.startsWith('config.corrupt-')) assert.equal(quarantined.length, 1) assert.equal(readFileSync(join(dir, quarantined[0]!), 'utf8'), '{"provider": "zai", "apiK') @@ -134,7 +135,7 @@ test('a quarantine that cannot rename aborts save() instead of destroying the co chmodSync(dir, 0o500) try { assert.throws( - () => store.save({ version: 2, providerAccounts: {}, agentProfiles: {}, profiles: {}, defaultProfile: null, bindings: {}, settings: {} }), + () => store.save({ version: 2, providerAccounts: {}, setups: {}, profiles: {}, defaultProfile: null, bindings: {}, settings: {} }), /could not move it aside|refusing to overwrite/, ) chmodSync(dir, 0o700) @@ -149,8 +150,8 @@ test('a NEWER schema is read but never written back', () => { const future = `${JSON.stringify({ version: 99, providerAccounts: { a: { provider: 'zai', apiKey: 'k' } }, - agentProfiles: { a: {} }, - profiles: { a: { agentProfile: 'a', accounts: ['a'] } }, + setups: { a: {} }, + profiles: { a: { setup: 'a', accounts: ['a'] } }, defaultProfile: 'a', })}\n` const { dir, store } = freshHome(future) @@ -180,7 +181,7 @@ test('a failed migration write does not block the launch', () => { const loaded = store.load() // The migrated settings are still usable in memory, and the launch proceeds. - assert.equal(loaded.state.version, 3) + assert.equal(loaded.state.version, SUPPORTED_VERSION) assert.equal(loaded.state.providerAccounts.zai!.provider, 'zai') assert.equal(loaded.state.providerAccounts.zai!.apiKey, 'zai-secret-key') assert.ok(loaded.warnings.some((w) => w.includes('could not rewrite'))) @@ -199,8 +200,8 @@ test('unknown top-level keys survive a round trip', () => { const withExtra = `${JSON.stringify({ version: 3, providerAccounts: { a: { provider: 'zai' } }, - agentProfiles: { a: {} }, - profiles: { a: { agentProfile: 'a', accounts: ['a'], futureField: 1 } }, + setups: { a: {} }, + profiles: { a: { setup: 'a', accounts: ['a'], futureField: 1 } }, defaultProfile: 'a', bindings: {}, settings: {}, @@ -228,7 +229,7 @@ test('revision is null when there is no file, and a string once there is', () => // the meantime. const { store } = freshHome() assert.equal(store.revision!(), null) - store.save({ version: 2, providerAccounts: {}, agentProfiles: {}, profiles: {}, defaultProfile: null, bindings: {}, settings: {} }) + store.save({ version: 2, providerAccounts: {}, setups: {}, profiles: {}, defaultProfile: null, bindings: {}, settings: {} }) assert.equal(typeof store.revision!(), 'string') }) @@ -238,7 +239,7 @@ test('revision follows CONTENT, not the clock', () => { // slip through exactly when writers are most concurrent. Hashing bytes cannot // have that failure, and this test is what pins the choice. const { store } = freshHome() - const base = { version: 2, providerAccounts: {}, agentProfiles: {}, profiles: {}, defaultProfile: null, bindings: {}, settings: {} } + const base = { version: 2, providerAccounts: {}, setups: {}, profiles: {}, defaultProfile: null, bindings: {}, settings: {} } store.save(base) const first = store.revision!() @@ -256,9 +257,9 @@ test('an edit made behind our back is visible as a revision change', () => { // The interleaving this exists to catch: read, wait, and meanwhile another // swisscode command writes. const { dir, store } = freshHome() - store.save({ version: 2, providerAccounts: {}, agentProfiles: {}, profiles: {}, defaultProfile: null, bindings: {}, settings: {} }) + store.save({ version: 2, providerAccounts: {}, setups: {}, profiles: {}, defaultProfile: null, bindings: {}, settings: {} }) const held = store.revision!() - writeFileSync(join(dir, 'config.json'), JSON.stringify({ version: 2, providerAccounts: {}, agentProfiles: {}, profiles: {}, defaultProfile: 'other', bindings: {}, settings: {} })) + writeFileSync(join(dir, 'config.json'), JSON.stringify({ version: 2, providerAccounts: {}, setups: {}, profiles: {}, defaultProfile: 'other', bindings: {}, settings: {} })) assert.notEqual(store.revision!(), held) }) diff --git a/test/adapters/ollama-doctor.test.ts b/test/adapters/ollama-doctor.test.ts index 960f300..e2094e3 100644 --- a/test/adapters/ollama-doctor.test.ts +++ b/test/adapters/ollama-doctor.test.ts @@ -135,11 +135,11 @@ const ollamaState = { providerAccounts: { local: makeProfile({ provider: 'ollama' }), }, - agentProfiles: { + setups: { local: { models: { opus: 'qwen3:0.6b' } }, }, profiles: { - local: { agentProfile: 'local', accounts: ['local'] }, + local: { setup: 'local', accounts: ['local'] }, }, defaultProfile: 'local', bindings: {}, @@ -210,11 +210,11 @@ test('a non-Ollama profile gets no context check at all', async () => { providerAccounts: { z: makeProfile({ provider: 'zai', apiKey: 'k' }), }, - agentProfiles: { + setups: { z: { models: { opus: 'glm-5.2' } }, }, profiles: { - z: { agentProfile: 'z', accounts: ['z'] }, + z: { setup: 'z', accounts: ['z'] }, }, defaultProfile: 'z', bindings: {}, diff --git a/test/adapters/version-check.test.ts b/test/adapters/version-check.test.ts index 8bbd3e1..9a02cd7 100644 --- a/test/adapters/version-check.test.ts +++ b/test/adapters/version-check.test.ts @@ -153,8 +153,8 @@ function launchHarness(versionStore: ReturnType | u const state = { version: 3, providerAccounts: { z: { provider: 'zai', apiKey: 'k' } }, - agentProfiles: { z: { models: { opus: 'glm-5.2' } } }, - profiles: { z: { agentProfile: 'z', accounts: ['z'] } }, + setups: { z: { models: { opus: 'glm-5.2' } } }, + profiles: { z: { setup: 'z', accounts: ['z'] } }, defaultProfile: 'z', bindings: {}, settings: {}, diff --git a/test/adapters/web.test.ts b/test/adapters/web.test.ts index 9ea5f99..5521886 100644 --- a/test/adapters/web.test.ts +++ b/test/adapters/web.test.ts @@ -153,11 +153,11 @@ const baseState = (): State => version: 2, providerAccounts: { work: makeProfile({ provider: 'zai', apiKey: 'secret' }), }, - agentProfiles: { + setups: { work: { models: {} }, }, profiles: { - work: { agentProfile: 'work', accounts: ['work'] }, + work: { setup: 'work', accounts: ['work'] }, }, defaultProfile: 'work', bindings: {}, @@ -558,7 +558,7 @@ test('deleting a provider reports orphaned profiles rather than silently repairi { method: 'PUT', path: '/api/agent-profiles/gw-agent', - body: { revision: s.revision!(), agentProfile: {} }, + body: { revision: s.revision!(), setup: {} }, }, deps(s), ) @@ -568,7 +568,7 @@ test('deleting a provider reports orphaned profiles rather than silently repairi path: '/api/profiles/uses-gw', body: { revision: s.revision!(), - profile: { agentProfile: 'gw-agent', accounts: ['gw-acct'] }, + profile: { setup: 'gw-agent', accounts: ['gw-acct'] }, }, }, deps(s), diff --git a/test/config-commands.test.ts b/test/config-commands.test.ts index 14c3877..c75259e 100644 --- a/test/config-commands.test.ts +++ b/test/config-commands.test.ts @@ -25,13 +25,13 @@ const STATE = (): State => ({ z: makeProfile({ provider: 'zai', apiKey: 'zai-secret-value' }), or: makeProfile({ provider: 'openrouter', apiKeyFromEnv: 'OPENROUTER_KEY' }), }, - agentProfiles: { + setups: { z: { models: { opus: 'glm-5.2', sonnet: 'glm-5.2', haiku: 'glm-5.2', fable: 'glm-5.2' }, skipPermissions: true }, or: {}, }, profiles: { - z: { agentProfile: 'z', accounts: ['z'] }, - or: { agentProfile: 'or', accounts: ['or'] }, + z: { setup: 'z', accounts: ['z'] }, + or: { setup: 'or', accounts: ['or'] }, }, defaultProfile: 'z', bindings: {}, @@ -139,8 +139,8 @@ test('an existing profile with an awkward name still opens', async () => { // Validation applies at CREATION only; a hand-edited config keeps working. const state = STATE() state.providerAccounts.fix = { provider: 'zai' } - state.agentProfiles.fix = {} - state.profiles.fix = { agentProfile: 'fix', accounts: ['fix'] } + state.setups.fix = {} + state.profiles.fix = { setup: 'fix', accounts: ['fix'] } const h = harness({ state }) assert.equal(await h.run(['fix']), 0) assert.equal(h.uiCalls[0]!.profileName, 'fix') @@ -174,8 +174,8 @@ test('config list flags a profile whose provider this build does not know', asyn // The provider id lives on the ACCOUNT now, so an unknown one is an unknown // account provider — the profile itself resolves fine and still lists. state.providerAccounts.old = { provider: 'volcengine' } - state.agentProfiles.old = {} - state.profiles.old = { agentProfile: 'old', accounts: ['old'] } + state.setups.old = {} + state.profiles.old = { setup: 'old', accounts: ['old'] } const h = harness({ state }) await h.run(['list']) assert.match(h.text(), /unknown provider/) @@ -213,8 +213,8 @@ test('deleting the default profile promotes the survivor only when there is one' const three = STATE() three.providerAccounts.third = { provider: 'zai' } - three.agentProfiles.third = {} - three.profiles.third = { agentProfile: 'third', accounts: ['third'] } + three.setups.third = {} + three.profiles.third = { setup: 'third', accounts: ['third'] } const h2 = harness({ state: three }) await h2.run(['rm', 'z']) // Guessing among several would silently pick an account to bill. @@ -408,11 +408,11 @@ test('every surface that names a provider sees the custom ones', async () => { version: 2, providerAccounts: { rig: makeProfile({ provider: 'vllm' }), }, - agentProfiles: { + setups: { rig: {}, }, profiles: { - rig: { agentProfile: 'rig', accounts: ['rig'] }, + rig: { setup: 'rig', accounts: ['rig'] }, }, defaultProfile: 'rig', bindings: {}, @@ -445,11 +445,11 @@ test('a profile on a genuinely unknown provider still says so', async () => { version: 2, providerAccounts: { ghost: makeProfile({ provider: 'nope' }), }, - agentProfiles: { + setups: { ghost: {}, }, profiles: { - ghost: { agentProfile: 'ghost', accounts: ['ghost'] }, + ghost: { setup: 'ghost', accounts: ['ghost'] }, }, defaultProfile: 'ghost', bindings: {}, @@ -532,11 +532,11 @@ test('config accounts names two accounts that are secretly one subscription', as ) }) -test('config agents marks a shared agent profile as shared', async () => { +test('config agents marks a shared setup as shared', async () => { // Sharing is the capability the split bought; a listing that did not show it // would leave the user unable to tell one setup from two identical ones. const state = STATE() - state.profiles.or!.agentProfile = state.profiles.z!.agentProfile + state.profiles.or!.setup = state.profiles.z!.setup const h = harness({ state }) assert.equal(await h.run(['agents']), 0) assert.match(h.text(), /used by\s+or, z\s+\(shared\)|used by\s+z, or\s+\(shared\)/) @@ -546,7 +546,7 @@ test('both list commands cope with an empty config rather than printing nothing' const empty = { version: 3, providerAccounts: {}, - agentProfiles: {}, + setups: {}, profiles: {}, defaultProfile: null, bindings: {}, @@ -558,5 +558,5 @@ test('both list commands cope with an empty config rather than printing nothing' const b = harness({ state: empty }) await b.run(['agents']) - assert.match(b.text(), /No agent profiles yet/) + assert.match(b.text(), /No setups yet/) }) diff --git a/test/core/binding.test.ts b/test/core/binding.test.ts index 9f954da..19e0dcc 100644 --- a/test/core/binding.test.ts +++ b/test/core/binding.test.ts @@ -136,10 +136,10 @@ import { const state = () => makeState({ providerAccounts: { z: { provider: 'zai' }, or: { provider: 'openrouter' } }, - agentProfiles: { z: {}, or: {} }, + setups: { z: {}, or: {} }, profiles: { - z: { agentProfile: 'z', accounts: ['z'] }, - or: { agentProfile: 'or', accounts: ['or'] }, + z: { setup: 'z', accounts: ['z'] }, + or: { setup: 'or', accounts: ['or'] }, }, defaultProfile: 'z', bindings: { '/work/a': 'or', '/work/a/b/c': 'z', '/gone': 'deleted' }, diff --git a/test/core/doctor.test.ts b/test/core/doctor.test.ts index f7c6b06..dc34ba0 100644 --- a/test/core/doctor.test.ts +++ b/test/core/doctor.test.ts @@ -46,7 +46,7 @@ const profile = makeProfile({ const loaded = (over: Partial> & { state?: Partial } = {}): LoadResult => ({ state: { - version: 2, agentProfiles: {}, + version: 2, setups: {}, profiles: { z: profile }, defaultProfile: 'z', bindings: {}, @@ -64,7 +64,7 @@ const loaded = (over: Partial> & { state?: Partial { const st = loaded() st.state.profiles = { ...st.state.profiles, - doctor: makeProfileRefs({ agentProfile: 'z', accounts: ['z'] }), + doctor: makeProfileRefs({ setup: 'z', accounts: ['z'] }), } const c = byId(run({ loaded: st }), 'shadowed-names') assert.equal(c!.status, 'warn') diff --git a/test/core/migrate.test.ts b/test/core/migrate.test.ts index fb4451e..ab385bd 100644 --- a/test/core/migrate.test.ts +++ b/test/core/migrate.test.ts @@ -34,12 +34,12 @@ test('migrates a real 0.1.0 config into a named profile', () => { assert.equal(state.defaultProfile, 'zai') // v1 now chains through v2 into the three-way split, in one read. assert.deepEqual(state.providerAccounts.zai, { provider: 'zai', apiKey: 'zai-secret' }) - assert.deepEqual(state.agentProfiles.zai, { + assert.deepEqual(state.setups.zai, { models: { opus: 'glm-5.2', sonnet: 'glm-5.2', haiku: 'glm-5.2' }, skipPermissions: true, }) assert.deepEqual(state.profiles.zai, { - agentProfile: 'zai', + setup: 'zai', accounts: ['zai'], strategy: 'single', }) @@ -50,8 +50,8 @@ test('migration is shape-only: it repairs nothing', () => { // The [1m] fix reaches existing users at env-build time. Rewriting stored // model strings is `config doctor`'s job, invoked by a human. const { state } = migrate(V1_FULL) - assert.equal(state.agentProfiles.zai!.models!.opus, 'glm-5.2') - assert.equal(state.agentProfiles.zai!.models!.fable, undefined) + assert.equal(state.setups.zai!.models!.opus, 'glm-5.2') + assert.equal(state.setups.zai!.models!.fable, undefined) }) test('migration is lossless: unknown v1 keys ride along on the profile', () => { @@ -64,7 +64,7 @@ test('migration is lossless: unknown v1 keys ride along on the profile', () => { // They ride on the AGENT PROFILE now: the split files a v2 profile's // non-credential fields there, and an unrecognized key is by definition not a // credential. - const migrated = state.agentProfiles.zai as unknown as Record + const migrated = state.setups.zai as unknown as Record assert.deepEqual(migrated.somethingNew, { a: 1 }) assert.equal(migrated.note, 'hi') }) @@ -86,9 +86,9 @@ test('a minimal v1 config still produces a usable profile', () => { const { state } = migrate({ provider: 'anthropic' }) assert.equal(state.defaultProfile, 'anthropic') assert.deepEqual(state.providerAccounts.anthropic, { provider: 'anthropic' }) - assert.deepEqual(state.agentProfiles.anthropic, {}) + assert.deepEqual(state.setups.anthropic, {}) assert.deepEqual(state.profiles.anthropic, { - agentProfile: 'anthropic', + setup: 'anthropic', accounts: ['anthropic'], strategy: 'single', }) @@ -109,12 +109,12 @@ test('a v1 custom-endpoint config keeps its baseUrl and env', () => { models: { opus: 'm', sonnet: 'm', haiku: 'm' }, }) assert.equal(state.providerAccounts.custom!.baseUrl, 'https://local.example') - assert.deepEqual(state.agentProfiles.custom!.env, { API_TIMEOUT_MS: '600000' }) + assert.deepEqual(state.setups.custom!.env, { API_TIMEOUT_MS: '600000' }) }) test('models are picked down to the four tiers, values untouched', () => { const { state } = migrate({ provider: 'zai', models: { opus: 'a', bogus: 'b' } } as never) - assert.deepEqual(state.agentProfiles.zai!.models, { opus: 'a' }) + assert.deepEqual(state.setups.zai!.models, { opus: 'a' }) }) test('a v2 config is MIGRATED, and says so', () => { @@ -133,37 +133,91 @@ test('a v2 config is MIGRATED, and says so', () => { assert.equal(r.migratedFrom, 2) assert.equal(r.state.version, SUPPORTED_VERSION) assert.deepEqual(r.state.providerAccounts.a, { provider: 'zai', apiKey: 'k' }) - assert.deepEqual(r.state.agentProfiles.a, { models: { opus: 'glm-5.2' } }) + assert.deepEqual(r.state.setups.a, { models: { opus: 'glm-5.2' } }) assert.deepEqual(r.state.profiles.a, { - agentProfile: 'a', + setup: 'a', accounts: ['a'], strategy: 'single', }) }) -test('an already-v3 config is returned unchanged and is not rewritten', () => { - // The terminal rung. A launch that merely READS must not touch the disk, and - // `migratedFrom: null` is the only thing that keeps it from doing so. +test('v3 -> v4 renames the map and every reference to it, losing nothing', () => { + // PURELY A RENAME, so the test is about what SURVIVES. A step that quietly + // dropped a setup, or left a profile pointing at a key that no longer exists, + // would migrate a working config into one that cannot resolve an agent. const v3 = { version: 3, + providerAccounts: { a: { provider: 'zai', apiKey: 'k' } }, + agentProfiles: { a: { models: { opus: 'glm-5.2' }, skipPermissions: true } }, + profiles: { a: { agentProfile: 'a', accounts: ['a'], strategy: 'single' } }, + defaultProfile: 'a', + bindings: { '/work': 'a' }, + settings: {}, + } + const r = migrate(v3) + assert.equal(r.migratedFrom, 3) + assert.equal(r.state.version, SUPPORTED_VERSION) + assert.deepEqual(r.state.setups.a, { models: { opus: 'glm-5.2' }, skipPermissions: true }) + assert.deepEqual(r.state.profiles.a, { setup: 'a', accounts: ['a'], strategy: 'single' }) + // Everything the rename does not touch is bit-identical. + assert.deepEqual(r.state.providerAccounts.a, { provider: 'zai', apiKey: 'k' }) + assert.deepEqual(r.state.bindings, { '/work': 'a' }) + assert.equal(r.state.defaultProfile, 'a') + // The old names are GONE, not carried alongside — two spellings of one field + // is how a later reader picks the stale one. + const raw = r.state as unknown as Record + assert.equal(raw.agentProfiles, undefined) + assert.equal((r.state.profiles.a as unknown as Record).agentProfile, undefined) +}) + +test('an already-v4 config is returned unchanged and is not rewritten', () => { + // The terminal rung. A launch that merely READS must not touch the disk, and + // `migratedFrom: null` is the only thing that keeps it from doing so. + const v4 = { + version: SUPPORTED_VERSION, providerAccounts: { a: { provider: 'zai' } }, - agentProfiles: { a: {} }, - profiles: { a: { agentProfile: 'a', accounts: ['a'] } }, + setups: { a: {} }, + profiles: { a: { setup: 'a', accounts: ['a'] } }, defaultProfile: 'a', bindings: {}, settings: {}, } - const r = migrate(v3) + const r = migrate(v4) assert.equal(r.migratedFrom, null) - assert.deepEqual(r.state, v3) + assert.deepEqual(r.state, v4) +}) + +test('every rung reaches v4 in one read, and migrating twice changes nothing', () => { + // The ladder is the thing most likely to rot: `fromV2` stamping the CURRENT + // version instead of a literal 3 would silently skip the v3->v4 step, which + // is exactly the failure `fromV1`'s comment predicted before v4 existed. + const v1 = { provider: 'zai', apiKey: 'k', opusModel: 'glm-5.2' } + const v2 = { + version: 2, + profiles: { a: { provider: 'zai', apiKey: 'k' } }, + defaultProfile: 'a', + } + for (const [label, raw] of [['v1', v1], ['v2', v2]] as const) { + const r = migrate(raw) + assert.equal(r.state.version, SUPPORTED_VERSION, `${label} must land on v4`) + assert.ok(Object.keys(r.state.setups).length > 0, `${label} must produce a setup`) + for (const p of Object.values(r.state.profiles)) { + assert.ok(p.setup, `${label} profile must reference its setup by the v4 name`) + assert.ok(r.state.setups[p.setup], `${label} reference must resolve`) + } + // Idempotence, structurally rather than by a flag someone has to set. + assert.deepEqual(migrate(r.state).state, r.state, `${label} must be a fixed point`) + } }) test('a NEWER schema is read best-effort and locked read-only', () => { const r = migrate({ version: 99, providerAccounts: { a: { provider: 'zai' } }, - agentProfiles: { a: {} }, - profiles: { a: { agentProfile: 'a', accounts: ['a'] } }, + // A file from the future speaks the future's vocabulary, so the salvage + // reads the CURRENT key names rather than the ones v3 happened to use. + setups: { a: {} }, + profiles: { a: { setup: 'a', accounts: ['a'] } }, defaultProfile: 'a', futureThing: true, }) @@ -185,7 +239,8 @@ test('normalize resolves a dangling defaultProfile only when unambiguous', () => assert.equal(one.state.defaultProfile, 'solo') const many = normalize({ - version: 2, agentProfiles: {}, + version: 2, + setups: {}, profiles: { a: makeProfile({ provider: 'zai' }), b: { provider: 'openrouter' } }, defaultProfile: 'gone', }) @@ -196,7 +251,8 @@ test('normalize resolves a dangling defaultProfile only when unambiguous', () => test('normalize drops non-absolute binding keys with a warning', () => { const { state, warnings } = normalize({ - version: 2, agentProfiles: {}, + version: 2, + setups: {}, profiles: {}, bindings: { 'relative/path': 'a', '/abs/path': 'b' }, }) diff --git a/test/core/overrides.test.ts b/test/core/overrides.test.ts index 80a3773..e2b6174 100644 --- a/test/core/overrides.test.ts +++ b/test/core/overrides.test.ts @@ -141,7 +141,7 @@ test('retargeting cannot inherit models, because accounts do not hold any', () = // from whichever profile lent the credential, on the reasoning that it was // "already configured FOR this provider". // - // v3 removes both the ability and the need: models live on the agent profile, + // v3 removes both the ability and the need: models live on the setup, // which does not change when the provider does, and an account carries no // models to borrow. So the models chosen for the OLD provider are simply // dropped and the target's own defaults apply — which is the safer of the two diff --git a/test/core/profile.test.ts b/test/core/profile.test.ts index 46bfe8a..900a5fa 100644 --- a/test/core/profile.test.ts +++ b/test/core/profile.test.ts @@ -15,10 +15,10 @@ import { makeState } from '../support/fixtures.ts' const state = { version: 2, providerAccounts: { z: { provider: 'zai' }, or: { provider: 'openrouter' } }, - agentProfiles: { z: {}, or: {} }, + setups: { z: {}, or: {} }, profiles: { - z: { agentProfile: 'z', accounts: ['z'] }, - or: { agentProfile: 'or', accounts: ['or'] }, + z: { setup: 'z', accounts: ['z'] }, + or: { setup: 'or', accounts: ['or'] }, }, defaultProfile: 'z', bindings: { '/work/or-project': 'or' }, @@ -62,7 +62,7 @@ test('no profiles at all resolves to nothing, which means the wizard', () => { test('exactly one profile and no default is not ambiguous', () => { const sel = resolveProfile( - makeState({ providerAccounts: { solo: { provider: 'zai' } }, agentProfiles: { solo: {} }, profiles: { solo: { agentProfile: 'solo', accounts: ['solo'] } }, defaultProfile: 'solo' }), + makeState({ providerAccounts: { solo: { provider: 'zai' } }, setups: { solo: {} }, profiles: { solo: { setup: 'solo', accounts: ['solo'] } }, defaultProfile: 'solo' }), { cwd: '/x' }, ) assert.equal(sel.name, 'solo') @@ -159,13 +159,13 @@ test('tier 1 short-circuits the binding walk entirely', () => { test('a profile named after a subcommand is still selectable by flag', () => { // `config list` always wins positionally, but the profile is not unreachable. - const shadowed = makeState({ providerAccounts: { list: { provider: 'zai' } }, agentProfiles: { list: {} }, profiles: { list: { agentProfile: 'list', accounts: ['list'] } }, defaultProfile: null }) + const shadowed = makeState({ providerAccounts: { list: { provider: 'zai' } }, setups: { list: {} }, profiles: { list: { setup: 'list', accounts: ['list'] } }, defaultProfile: null }) assert.equal(resolveProfile(shadowed, { profileFlag: 'list' }).name, 'list') }) // A name that is a config concept, but not the one the positional selects. // -// `orphan` has an account and an agent profile that NO profile pairs — the +// `orphan` has an account and a setup that NO profile pairs — the // state you are in immediately after `config accounts login`, and the one that // made `swisscode ezra.spero` fire the account name at the default profile as // a prompt. @@ -173,8 +173,8 @@ test('a profile named after a subcommand is still selectable by flag', () => { const orphan = { version: 2, providerAccounts: { z: { provider: 'zai' }, spare: { provider: 'anthropic' } }, - agentProfiles: { z: {}, solo: {} }, - profiles: { z: { agentProfile: 'z', accounts: ['z'] } }, + setups: { z: {}, solo: {} }, + profiles: { z: { setup: 'z', accounts: ['z'] } }, defaultProfile: 'z', bindings: {}, settings: {}, @@ -194,8 +194,8 @@ test('an account name given positionally is refused, not sent as a prompt', () = test('an agent-profile name is refused the same way, in its own words', () => { const sel = resolveProfile(orphan, { cwd: '/x', positional: 'solo' }) - assert.match(sel.error!, /"solo" is an agent profile, not a profile/) - assert.match(sel.error!, /agent profiles say what runs/) + assert.match(sel.error!, /"solo" is a setup, not a profile/) + assert.match(sel.error!, /setups say what runs/) }) test('an ordinary prompt word is UNTOUCHED by the account check', () => { @@ -211,7 +211,7 @@ test('an ordinary prompt word is UNTOUCHED by the account check', () => { }) test('a name that is BOTH a profile and an account still selects the profile', () => { - // `z` is an account, an agent profile and a profile. Tier 1a matches first, so + // `z` is an account, a setup and a profile. Tier 1a matches first, so // the check never sees it — a launch that already worked must keep working. const sel = resolveProfile(orphan, { cwd: '/x', positional: 'z' }) assert.equal(sel.error, null) diff --git a/test/core/resolve.test.ts b/test/core/resolve.test.ts index a9c3857..555958f 100644 --- a/test/core/resolve.test.ts +++ b/test/core/resolve.test.ts @@ -1,4 +1,4 @@ -// Resolution: a profile -> the one account + agent profile a launch uses. +// Resolution: a profile -> the one account + setup a launch uses. // // This is the step that decides WHICH ACCOUNT PAYS, so it gets exhaustive // coverage of both the happy path and every way it can degrade. Degrading is @@ -18,11 +18,11 @@ const state = (over: Partial = {}): State => backup: { provider: 'zai', apiKey: 'zai-key' }, third: { provider: 'anthropic' }, }, - agentProfiles: { + setups: { main: { agent: 'kilo', models: { opus: 'm' }, skipPermissions: true }, }, profiles: { - p: { agentProfile: 'main', accounts: ['work'] }, + p: { setup: 'main', accounts: ['work'] }, }, defaultProfile: 'p', bindings: {}, @@ -46,28 +46,28 @@ function cursorStore(initial: Record = {}): CursorPort & { seen: // the happy path -test('resolution flattens the account and the agent profile together', () => { +test('resolution flattens the account and the setup together', () => { const r = resolveProfileRefs(state(), 'p') assert.ok(r.ok) // From the account… assert.equal(r.resolved.provider, 'openrouter') assert.equal(r.resolved.apiKey, 'or-key') - // …and from the agent profile. + // …and from the setup. assert.equal(r.resolved.agent, 'kilo') assert.equal(r.resolved.skipPermissions, true) assert.deepEqual(r.resolved.models, { opus: 'm' }) // Both names travel with it, so a caller can REPORT which account paid. assert.equal(r.resolved.accountName, 'work') - assert.equal(r.resolved.agentProfileName, 'main') + assert.equal(r.resolved.setupName, 'main') }) -test('one agent profile can back several profiles', () => { +test('one setup can back several profiles', () => { // The point of the split: a shared setup is expressed by reference, not by // duplicating models and permissions into every profile that wants them. const s = state({ profiles: { - a: { agentProfile: 'main', accounts: ['work'] }, - b: { agentProfile: 'main', accounts: ['backup'] }, + a: { setup: 'main', accounts: ['work'] }, + b: { setup: 'main', accounts: ['backup'] }, }, } as Partial) const a = resolveProfileRefs(s, 'a') @@ -80,17 +80,17 @@ test('one agent profile can back several profiles', () => { // every reference failure names the fix -test('a missing agent profile is refused with the repair named', () => { - const s = state({ profiles: { p: { agentProfile: 'gone', accounts: ['work'] } } } as Partial) +test('a missing setup is refused with the repair named', () => { + const s = state({ profiles: { p: { setup: 'gone', accounts: ['work'] } } } as Partial) const r = resolveProfileRefs(s, 'p') assert.equal(r.ok, false) - assert.match(r.reason, /agent profile "gone"/) + assert.match(r.reason, /setup "gone"/) assert.match(r.reason, /swisscode config p/, 'the message must say what to run') }) test('a profile with no accounts is refused rather than defaulted', () => { // Picking "some other account" would be choosing who to bill. - const s = state({ profiles: { p: { agentProfile: 'main', accounts: [] } } } as Partial) + const s = state({ profiles: { p: { setup: 'main', accounts: [] } } } as Partial) const r = resolveProfileRefs(s, 'p') assert.equal(r.ok, false) assert.match(r.reason, /no provider account/) @@ -100,7 +100,7 @@ test('a dangling account is skipped with a warning, not fatal', () => { // A profile with three accounts and one stale reference should still launch // on the other two. const s = state({ - profiles: { p: { agentProfile: 'main', accounts: ['gone', 'backup'] } }, + profiles: { p: { setup: 'main', accounts: ['gone', 'backup'] } }, } as Partial) const r = resolveProfileRefs(s, 'p') assert.ok(r.ok) @@ -110,7 +110,7 @@ test('a dangling account is skipped with a warning, not fatal', () => { test('when every account is dangling it refuses and says how many', () => { const s = state({ - profiles: { p: { agentProfile: 'main', accounts: ['gone', 'also-gone'] } }, + profiles: { p: { setup: 'main', accounts: ['gone', 'also-gone'] } }, } as Partial) const r = resolveProfileRefs(s, 'p') assert.equal(r.ok, false) diff --git a/test/core/session-mode.test.ts b/test/core/session-mode.test.ts index 34f922c..69dc6e4 100644 --- a/test/core/session-mode.test.ts +++ b/test/core/session-mode.test.ts @@ -25,8 +25,8 @@ const state = (over: Record = {}): State => personal: { provider: 'anthropic', configDir: DIR }, keyed: { provider: 'anthropic', apiKey: 'sk-ant-real' }, }, - agentProfiles: { main: {} }, - profiles: { p: { agentProfile: 'main', accounts: ['personal'] } }, + setups: { main: {} }, + profiles: { p: { setup: 'main', accounts: ['personal'] } }, defaultProfile: 'p', bindings: {}, settings: {}, diff --git a/test/e2e/agents.e2e.ts b/test/e2e/agents.e2e.ts index ab84f7c..f5909a3 100644 --- a/test/e2e/agents.e2e.ts +++ b/test/e2e/agents.e2e.ts @@ -17,8 +17,8 @@ import { launch, makeConfig, resolvedAgent } from './harness.ts' function configForAgent(agent: string) { return makeConfig({ providerAccounts: { or: { provider: 'openrouter', apiKey: 'sk-agent-KEY' } }, - agentProfiles: { main: { agent, models: { opus: 'the-model' } } }, - profiles: { p: { agentProfile: 'main', accounts: ['or'] } }, + setups: { main: { agent, models: { opus: 'the-model' } } }, + profiles: { p: { setup: 'main', accounts: ['or'] } }, }) } diff --git a/test/e2e/errors.e2e.ts b/test/e2e/errors.e2e.ts index c927236..81ac211 100644 --- a/test/e2e/errors.e2e.ts +++ b/test/e2e/errors.e2e.ts @@ -15,7 +15,7 @@ test('an unknown provider exits 2 and launches nothing', () => { const r = launch({ config: makeConfig({ providerAccounts: { a: { provider: 'nonesuch', apiKey: 'k' } }, - profiles: { p: { agentProfile: 'main', accounts: ['a'] } }, + profiles: { p: { setup: 'main', accounts: ['a'] } }, }), }) assert.equal(r.capture, null, 'a broken config must not hand off to an agent') @@ -49,7 +49,7 @@ test('a profile naming a missing account launches nothing', () => { const r = launch({ config: makeConfig({ providerAccounts: { real: { provider: 'openrouter', apiKey: 'k' } }, - profiles: { p: { agentProfile: 'main', accounts: ['ghost'] } }, + profiles: { p: { setup: 'main', accounts: ['ghost'] } }, }), }) assert.equal(r.capture, null) diff --git a/test/e2e/harness.ts b/test/e2e/harness.ts index fce00de..daab04c 100644 --- a/test/e2e/harness.ts +++ b/test/e2e/harness.ts @@ -203,18 +203,18 @@ export function launch({ // ── config fixtures ── type AccountSpec = { provider: string; apiKey?: string; apiKeyFromEnv?: string; configDir?: string; baseUrl?: string } -type ProfileSpec = { agentProfile: string; accounts: string[]; strategy?: string } +type ProfileSpec = { setup: string; accounts: string[]; strategy?: string } /** * A v3 config, spelled at the density a test needs. * - * Defaults to one openrouter key account, one claude-code agent profile pinning + * Defaults to one openrouter key account, one claude-code setup pinning * a model, and one profile pairing them as the default — the smallest config * that launches. Any piece can be overridden. */ export function makeConfig(over: { providerAccounts?: Record - agentProfiles?: Record }> + setups?: Record }> profiles?: Record defaultProfile?: string | null bindings?: Record @@ -224,8 +224,8 @@ export function makeConfig(over: { return { version: 3, providerAccounts: over.providerAccounts ?? { or: { provider: 'openrouter', apiKey: 'sk-e2e-or' } }, - agentProfiles: over.agentProfiles ?? { main: { agent: 'claude-code', models: { opus: 'openrouter/fusion' } } }, - profiles: over.profiles ?? { p: { agentProfile: 'main', accounts: ['or'] } }, + setups: over.setups ?? { main: { agent: 'claude-code', models: { opus: 'openrouter/fusion' } } }, + profiles: over.profiles ?? { p: { setup: 'main', accounts: ['or'] } }, defaultProfile: 'defaultProfile' in over ? over.defaultProfile : 'p', bindings: over.bindings ?? {}, settings: over.settings ?? {}, diff --git a/test/e2e/launch.e2e.ts b/test/e2e/launch.e2e.ts index 7492f9f..2ce6c85 100644 --- a/test/e2e/launch.e2e.ts +++ b/test/e2e/launch.e2e.ts @@ -28,8 +28,8 @@ function configFor(providerId: string) { if (provider.askBaseUrl) account.baseUrl = 'https://custom.example' return makeConfig({ providerAccounts: { acct: account }, - agentProfiles: { main: { agent: 'claude-code', models: { opus: 'test-opus' } } }, - profiles: { p: { agentProfile: 'main', accounts: ['acct'] } }, + setups: { main: { agent: 'claude-code', models: { opus: 'test-opus' } } }, + profiles: { p: { setup: 'main', accounts: ['acct'] } }, }) } @@ -97,7 +97,7 @@ test('the ambient PATH survives — the child still finds node for the shebang', test('a profile pinning all four tiers writes all four into the child', () => { const r = launch({ config: makeConfig({ - agentProfiles: { + setups: { main: { agent: 'claude-code', models: { opus: 'm-opus', sonnet: 'm-sonnet', haiku: 'm-haiku', fable: 'm-fable' }, diff --git a/test/e2e/overrides.e2e.ts b/test/e2e/overrides.e2e.ts index 07e363a..349325f 100644 --- a/test/e2e/overrides.e2e.ts +++ b/test/e2e/overrides.e2e.ts @@ -18,10 +18,10 @@ function twoProviderConfig() { or: { provider: 'openrouter', apiKey: 'sk-or' }, z: { provider: 'zai', apiKey: 'sk-zai' }, }, - agentProfiles: { main: { agent: 'claude-code', models: { opus: 'base-opus', sonnet: 'base-sonnet' } } }, + setups: { main: { agent: 'claude-code', models: { opus: 'base-opus', sonnet: 'base-sonnet' } } }, profiles: { - p: { agentProfile: 'main', accounts: ['or'] }, - onz: { agentProfile: 'main', accounts: ['z'] }, + p: { setup: 'main', accounts: ['or'] }, + onz: { setup: 'main', accounts: ['z'] }, }, }) } diff --git a/test/e2e/real-handoff.real.ts b/test/e2e/real-handoff.real.ts index 7b802f7..1dc7298 100644 --- a/test/e2e/real-handoff.real.ts +++ b/test/e2e/real-handoff.real.ts @@ -45,8 +45,8 @@ for (const agent of AGENTS) { const r = launch({ config: makeConfig({ providerAccounts: { a: { provider: 'openrouter', apiKey: 'sk-e2e-not-used-by-version' } }, - agentProfiles: { m: { agent: agent.id, models: { opus: agent.model } } }, - profiles: { p: { agentProfile: 'm', accounts: ['a'] } }, + setups: { m: { agent: agent.id, models: { opus: agent.model } } }, + profiles: { p: { setup: 'm', accounts: ['a'] } }, }), argv: ['--version'], useRealBinaries: true, diff --git a/test/e2e/session.e2e.ts b/test/e2e/session.e2e.ts index a9a76ac..4a3e87d 100644 --- a/test/e2e/session.e2e.ts +++ b/test/e2e/session.e2e.ts @@ -20,8 +20,8 @@ import { launch, makeConfig } from './harness.ts' function sessionConfig(configDir: string, extra: Record = {}) { return makeConfig({ providerAccounts: { s: { provider: 'anthropic', configDir, ...extra } }, - agentProfiles: { m: { agent: 'claude-code', models: { opus: 'x' } } }, - profiles: { p: { agentProfile: 'm', accounts: ['s'] } }, + setups: { m: { agent: 'claude-code', models: { opus: 'x' } } }, + profiles: { p: { setup: 'm', accounts: ['s'] } }, }) } diff --git a/test/launch-overrides.test.ts b/test/launch-overrides.test.ts index 338767d..44c4c01 100644 --- a/test/launch-overrides.test.ts +++ b/test/launch-overrides.test.ts @@ -21,15 +21,15 @@ const STATE = (): State => ({ or: makeProfile({ provider: 'openrouter', apiKey: 'or-secret' }), keyless: makeProfile({ provider: 'openrouter' }), }, - agentProfiles: { + setups: { z: { models: { opus: 'glm-5.2', sonnet: 'glm-5.2', haiku: 'glm-5.2', fable: 'glm-5.2' }, skipPermissions: true }, or: {}, keyless: {}, }, profiles: { - z: { agentProfile: 'z', accounts: ['z'] }, - or: { agentProfile: 'or', accounts: ['or'] }, - keyless: { agentProfile: 'keyless', accounts: ['keyless'] }, + z: { setup: 'z', accounts: ['z'] }, + or: { setup: 'or', accounts: ['or'] }, + keyless: { setup: 'keyless', accounts: ['keyless'] }, }, defaultProfile: 'z', bindings: { '/work/or-project': 'or' }, diff --git a/test/picker.test.ts b/test/picker.test.ts index 5c7e27d..7c9defa 100644 --- a/test/picker.test.ts +++ b/test/picker.test.ts @@ -191,11 +191,11 @@ assert.ok(result, 'wizard should have produced a profile') // configured is read back out of the file it wrote. const written = JSON.parse(readFileSync(join(home, 'swisscode', 'config.json'), 'utf8')) const account = written.providerAccounts[(result as Profile).accounts[0]!] -const agentProfile = written.agentProfiles[(result as Profile).agentProfile] +const setup = written.setups[(result as Profile).setup] assert.equal(account.provider, 'openrouter') -assert.equal(agentProfile.models.opus, 'anthropic/claude-opus-4.8', 'picked model must persist') -assert.equal(agentProfile.models.sonnet, 'openrouter/fusion', 'untouched tiers keep defaults') -assert.equal(agentProfile.models.fable, 'openrouter/fusion', 'the fable tier must not be left unset') +assert.equal(setup.models.opus, 'anthropic/claude-opus-4.8', 'picked model must persist') +assert.equal(setup.models.sonnet, 'openrouter/fusion', 'untouched tiers keep defaults') +assert.equal(setup.models.fable, 'openrouter/fusion', 'the fable tier must not be left unset') // ModelScope: no prices, no benchmarks diff --git a/test/profiles-ui.test.ts b/test/profiles-ui.test.ts index c6af728..b18cf11 100644 --- a/test/profiles-ui.test.ts +++ b/test/profiles-ui.test.ts @@ -44,13 +44,13 @@ const baseState = (): State => ({ work: { provider: 'zai', apiKey: SECRET }, personal: makeProfile({ provider: 'openrouter', apiKey: 'or-secret' }), }, - agentProfiles: { + setups: { work: { models: { opus: 'glm-5.2', sonnet: 'glm-5.2', haiku: 'glm-5.2', fable: 'glm-5.2' }, skipPermissions: true }, personal: {}, }, profiles: { - work: { agentProfile: 'work', accounts: ['work'] }, - personal: { agentProfile: 'personal', accounts: ['personal'] }, + work: { setup: 'work', accounts: ['work'] }, + personal: { setup: 'personal', accounts: ['personal'] }, }, defaultProfile: 'work', bindings: {}, diff --git a/test/support/fixtures.ts b/test/support/fixtures.ts index ffa22ef..eb19c71 100644 --- a/test/support/fixtures.ts +++ b/test/support/fixtures.ts @@ -32,7 +32,7 @@ // receives, with the same identity. All they do is carry a type claim across a // boundary the compiler cannot see across on its own. import type { - AgentProfile, + Setup, Profile, ProviderAccount, ResolvedProfile, @@ -42,7 +42,7 @@ import type { ProviderDescriptor } from '../../src/ports/provider.ts' import type { ProfileSelection } from '../../src/core/profile.ts' /** - * A RESOLVED profile fixture — the flattened account + agent profile that + * A RESOLVED profile fixture — the flattened account + setup that * everything downstream of resolution consumes. * * Named `makeProfile` still, and deliberately: it is used by roughly twenty @@ -51,13 +51,13 @@ import type { ProfileSelection } from '../../src/core/profile.ts' * have implied those tests were testing something new. They are not — that is * the whole point, and `test/golden.test.ts` passing unchanged is the proof. * - * `accountName`/`agentProfileName` are defaulted rather than required because + * `accountName`/`setupName` are defaulted rather than required because * no consumer downstream of resolution reads them; they exist so a caller can * REPORT which account paid. A fixture that had to invent both every time would * add noise to twenty files for a field under test in none of them. */ export const makeProfile = (p: Partial): ResolvedProfile => - ({ accountName: 'acct', agentProfileName: 'agent', ...p }) as ResolvedProfile + ({ accountName: 'acct', setupName: 'agent', ...p }) as ResolvedProfile /** A stored `Profile` — references only. For tests about the pairing itself. */ export const makeProfileRefs = (p: Partial): Profile => p as Profile @@ -65,8 +65,8 @@ export const makeProfileRefs = (p: Partial): Profile => p as Profile /** A stored provider account. For tests about credentials and retargeting. */ export const makeAccount = (a: Partial): ProviderAccount => a as ProviderAccount -/** A stored agent profile. For tests about models, permissions and compat. */ -export const makeAgentProfile = (a: Partial): AgentProfile => a as AgentProfile +/** A stored setup. For tests about models, permissions and compat. */ +export const makeAgentProfile = (a: Partial): Setup => a as Setup /** * A v3 state built from ONE profile's worth of flat v2-shaped fields. @@ -74,7 +74,7 @@ export const makeAgentProfile = (a: Partial): AgentProfile => a as * The migration produces exactly this 1:1:1 arrangement, and so does the * wizard, so a test that just needs "a state with a working profile named N" * can say so without spelling three objects. Tests that are ABOUT the split — - * multi-account profiles, shared agent profiles — build the maps explicitly. + * multi-account profiles, shared setups — build the maps explicitly. */ export const makeSimpleState = ( name: string, @@ -91,7 +91,7 @@ export const makeSimpleState = ( ...(flat.apiKeyFromEnv !== undefined ? { apiKeyFromEnv: flat.apiKeyFromEnv } : {}), }, }, - agentProfiles: { + setups: { [name]: { ...(flat.agent !== undefined ? { agent: flat.agent } : {}), ...(flat.models !== undefined ? { models: flat.models } : {}), @@ -101,7 +101,7 @@ export const makeSimpleState = ( ...(flat.contextWindows !== undefined ? { contextWindows: flat.contextWindows } : {}), }, }, - profiles: { [name]: { agentProfile: name, accounts: [name], strategy: 'single' } }, + profiles: { [name]: { setup: name, accounts: [name], strategy: 'single' } }, defaultProfile: name, bindings: {}, settings: {}, diff --git a/test/ui-notice.test.ts b/test/ui-notice.test.ts index 29046e2..cd82f11 100644 --- a/test/ui-notice.test.ts +++ b/test/ui-notice.test.ts @@ -43,10 +43,10 @@ const YELLOW = '' const state = (): State => ({ version: 2, providerAccounts: { work: { provider: 'zai', apiKey: 'k' }, personal: { provider: 'openrouter', apiKey: 'k' } }, - agentProfiles: { work: {}, personal: {} }, + setups: { work: {}, personal: {} }, profiles: { - work: { agentProfile: 'work', accounts: ['work'] }, - personal: { agentProfile: 'personal', accounts: ['personal'] }, + work: { setup: 'work', accounts: ['work'] }, + personal: { setup: 'personal', accounts: ['personal'] }, }, defaultProfile: 'work', bindings: {}, diff --git a/test/ui.test.ts b/test/ui.test.ts index 5777a21..df9b26a 100644 --- a/test/ui.test.ts +++ b/test/ui.test.ts @@ -6,6 +6,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import assert from 'node:assert/strict' import type { Profile } from '../src/ports/config-store.ts' +import { SUPPORTED_VERSION } from '../src/core/migrate.ts' /** * dist/ui.js is BUILD OUTPUT. Same treatment as src/cli.ts: tsc must not @@ -71,19 +72,19 @@ await tick() assert.ok(result, 'wizard should have produced a profile') // The wizard returns the stored PROFILE — references only. What it configured -// lives in the account and the agent profile it wrote alongside it. -assert.equal(result.agentProfile, 'zai') +// lives in the account and the setup it wrote alongside it. +assert.equal(result.setup, 'zai') assert.deepEqual(result.accounts, ['zai']) const path = join(home, 'swisscode', 'config.json') const saved = JSON.parse(readFileSync(path, 'utf8')) -assert.equal(saved.version, 3, 'wizard must write the v3 schema') +assert.equal(saved.version, SUPPORTED_VERSION, 'wizard must write the CURRENT schema') -// The settings themselves live on the agent profile the wizard minted. -assert.equal(saved.agentProfiles.zai.skipPermissions, true) +// The settings themselves live on the setup the wizard minted. +assert.equal(saved.setups.zai.skipPermissions, true) // Four tiers, not three: [1m] is read per variable, so a tier the wizard never // writes is a tier that silently runs at the assumed window. -assert.deepEqual(saved.agentProfiles.zai.models, { +assert.deepEqual(saved.setups.zai.models, { opus: 'glm-5.2', sonnet: 'glm-5.2', haiku: 'glm-5.2', diff --git a/web/src/App.tsx b/web/src/App.tsx index d4605d6..2fdc839 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -12,18 +12,18 @@ import { BrandMark } from './Brand' import { Banner, Dot, SegmentedControl } from './ui' import { Profiles } from './routes/Profiles' import { Accounts } from './routes/Accounts' -import { AgentProfiles } from './routes/AgentProfiles' +import { Setups } from './routes/Setups' import { Providers } from './routes/Providers' import { Settings } from './routes/Settings' import { Doctor } from './routes/Doctor' import { Environment } from './routes/Environment' -type Tab = 'profiles' | 'accounts' | 'agentProfiles' | 'providers' | 'environment' | 'doctor' | 'settings' +type Tab = 'profiles' | 'accounts' | 'setups' | 'providers' | 'environment' | 'doctor' | 'settings' const TABS: { id: Tab; label: string }[] = [ // Ordered as the concepts compose: who pays, what runs, then the pairing. { id: 'accounts', label: 'Accounts' }, - { id: 'agentProfiles', label: 'Agent profiles' }, + { id: 'setups', label: 'Setups' }, { id: 'profiles', label: 'Profiles' }, { id: 'providers', label: 'Providers' }, { id: 'environment', label: 'Environment' }, @@ -228,7 +228,7 @@ export function App() { ))} {tab === 'accounts' ? : null} - {tab === 'agentProfiles' ? : null} + {tab === 'setups' ? : null} {tab === 'profiles' ? : null} {tab === 'providers' ? : null} {tab === 'environment' ? : null} diff --git a/web/src/api.ts b/web/src/api.ts index fbf1f18..bd5bb3d 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -77,7 +77,7 @@ export type ProviderAccount = { } /** WHAT RUNS. Holds no credential, so it crosses whole. */ -export type AgentProfile = { +export type Setup = { agent?: string label?: string models?: Record @@ -92,7 +92,7 @@ export type SelectionStrategy = 'single' | 'round-robin' | 'usage' /** THE PAIRING. References plus the rule for choosing among them. */ export type Profile = { label?: string - agentProfile: string + setup: string accounts: string[] strategy?: SelectionStrategy } @@ -114,7 +114,7 @@ export type CustomProvider = { export type Bootstrap = { state: { providerAccounts: Record - agentProfiles: Record + setups: Record profiles: Record defaultProfile: string | null bindings: Record @@ -283,10 +283,10 @@ export const api = { { method: 'DELETE', body: JSON.stringify({ revision }) }, ), - saveAgentProfile: (name: string, agentProfile: unknown, revision: string | null) => + saveAgentProfile: (name: string, setup: unknown, revision: string | null) => call<{ revision: string }>(`/api/agent-profiles/${encodeURIComponent(name)}`, { method: 'PUT', - body: JSON.stringify({ revision, agentProfile }), + body: JSON.stringify({ revision, setup }), }), deleteAgentProfile: (name: string, revision: string | null) => diff --git a/web/src/routes/Profiles.tsx b/web/src/routes/Profiles.tsx index bc4f697..682e584 100644 --- a/web/src/routes/Profiles.tsx +++ b/web/src/routes/Profiles.tsx @@ -117,7 +117,7 @@ const choiceLabelRow = css({ display: 'inline-flex', alignItems: 'baseline', gap export function Profiles({ data, reload }: { data: Bootstrap; reload: () => Promise }) { const names = Object.keys(data.state.profiles ?? {}) const accountNames = Object.keys(data.state.providerAccounts ?? {}) - const agentProfileNames = Object.keys(data.state.agentProfiles ?? {}) + const setupNames = Object.keys(data.state.setups ?? {}) const [editing, setEditing] = useState(null) const [draft, setDraft] = useState>({}) @@ -130,7 +130,7 @@ export function Profiles({ data, reload }: { data: Bootstrap; reload: () => Prom name ? { ...data.state.profiles[name] } : { - agentProfile: agentProfileNames[0] ?? '', + setup: setupNames[0] ?? '', accounts: accountNames[0] ? [accountNames[0]] : [], strategy: 'single', }, @@ -164,7 +164,7 @@ export function Profiles({ data, reload }: { data: Bootstrap; reload: () => Prom if (editing !== null) { const isNew = !data.state.profiles?.[editing] - const canCreate = agentProfileNames.length > 0 && accountNames.length > 0 + const canCreate = setupNames.length > 0 && accountNames.length > 0 return ( <> Prom {error ? {error} : null} {!canCreate ? ( - A profile references an account and an agent profile, so at least one of each has to + A profile references an account and a setup, so at least one of each has to exist first. ) : null} @@ -193,16 +193,16 @@ export function Profiles({ data, reload }: { data: Bootstrap; reload: () => Prom /> ) : null} - + @@ -307,7 +307,7 @@ export function Profiles({ data, reload }: { data: Bootstrap; reload: () => Prom {names.length === 0 ? ( - No profiles yet. A profile pairs an agent profile with one or more accounts. + No profiles yet. A profile pairs a setup with one or more accounts. ) : ( {names.map((name) => { @@ -315,11 +315,11 @@ export function Profiles({ data, reload }: { data: Bootstrap; reload: () => Prom const isDefault = data.state.defaultProfile === name // Report what it RESOLVES to, not what it references — a list of // key names would make the reader do the dereference in their head. - const agentProfile = data.state.agentProfiles?.[p.agentProfile] + const setup = data.state.setups?.[p.setup] const attached = p.accounts ?? [] const first = attached[0] const account = first ? data.state.providerAccounts?.[first] : undefined - const broken = !agentProfile || attached.length === 0 || !account + const broken = !setup || attached.length === 0 || !account return ( Prom meta={ - + {attached.length === 0 ? ( diff --git a/web/src/routes/AgentProfiles.tsx b/web/src/routes/Setups.tsx similarity index 94% rename from web/src/routes/AgentProfiles.tsx rename to web/src/routes/Setups.tsx index 9d668e7..0bba753 100644 --- a/web/src/routes/AgentProfiles.tsx +++ b/web/src/routes/Setups.tsx @@ -62,7 +62,7 @@ const modelGrid = cva({ const tierLabel = cx(labelStyle, css({ cursor: 'pointer' })) /** - * Agent profiles — what runs. + * Setups — what runs. * * Holds no credential, which is why this screen has no password field and no * redaction to think about. It is also the thing that can be SHARED: one setup @@ -70,13 +70,13 @@ const tierLabel = cx(labelStyle, css({ cursor: 'pointer' })) * pointed at a different account. The listing says when one is shared, because * editing a shared setup changes every profile that uses it. * - * The model picker needs a provider to browse, and an agent profile has none — + * The model picker needs a provider to browse, and a setup has none — * so it borrows one from a profile that uses this setup. When nothing does, * there is no catalog to offer and the fields stay plain text, which is the * honest answer rather than a picker over a list we cannot obtain. */ -export function AgentProfiles({ data, reload }: { data: Bootstrap; reload: () => Promise }) { - const agentProfiles = Object.entries(data.state.agentProfiles ?? {}) +export function Setups({ data, reload }: { data: Bootstrap; reload: () => Promise }) { + const setups = Object.entries(data.state.setups ?? {}) const [editing, setEditing] = useState(null) const [draft, setDraft] = useState>({}) const [error, setError] = useState(null) @@ -94,7 +94,7 @@ export function AgentProfiles({ data, reload }: { data: Bootstrap; reload: () => setEditing(name ?? '') // Re-derive the browse default for whichever profile just opened. setBrowseProviderId(null) - setDraft(name ? { ...data.state.agentProfiles[name] } : { models: {}, compat: {} }) + setDraft(name ? { ...data.state.setups[name] } : { models: {}, compat: {} }) } const save = async (name: string) => { @@ -128,14 +128,14 @@ export function AgentProfiles({ data, reload }: { data: Bootstrap; reload: () => const models = (draft.models as Record) ?? {} const compat = (draft.compat as Record) ?? {} - /** Which profiles use a given agent profile — the reverse index. */ + /** Which profiles use a given setup — the reverse index. */ const usersOf = (name: string) => Object.entries(data.state.profiles ?? {}) - .filter(([, p]) => p.agentProfile === name) + .filter(([, p]) => p.setup === name) .map(([n]) => n) if (editing !== null) { - const isNew = !data.state.agentProfiles?.[editing] + const isNew = !data.state.setups?.[editing] const users = usersOf(editing) // The agent decides how many model slots exist and which behaviour applies — @@ -190,7 +190,7 @@ export function AgentProfiles({ data, reload }: { data: Bootstrap; reload: () => return ( <> setEditing(null)} /> {error ? {error} : null} @@ -255,7 +255,7 @@ export function AgentProfiles({ data, reload }: { data: Bootstrap; reload: () => {/* The catalog lens. Browsing needs a provider and this setup names none of its own, so choose which one to browse against — it only drives the Browse buttons and the default-model placeholders, and - is never saved onto the agent profile. */} + is never saved onto the setup. */} @@ -390,12 +390,12 @@ export function AgentProfiles({ data, reload }: { data: Bootstrap; reload: () => return ( <> open(null)}> - New agent profile + New setup } /> @@ -407,11 +407,11 @@ export function AgentProfiles({ data, reload }: { data: Bootstrap; reload: () => ))} - {agentProfiles.length === 0 ? ( - No agent profiles yet. An agent profile is a coding CLI plus how it should behave. + {setups.length === 0 ? ( + No setups yet. A setup is a coding CLI plus how it should behave. ) : ( - {agentProfiles.map(([name, ap]) => { + {setups.map(([name, ap]) => { const users = usersOf(name) // Count only the slots this agent actually reads — a Kilo setup // with an opus id is "1 model", not "1 of 4 tiers". From 415a8ee1f0e62f1ef4f365b3ebc6c28cf4f495a9 Mon Sep 17 00:00:00 2001 From: jellologic <31935831+jellologic@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:42:01 -0400 Subject: [PATCH 4/6] Make `accounts login` produce something you can launch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ` 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> --- README.md | 12 +- src/adapters/claude-session/onboard.ts | 116 ++++++++++++++++++- src/composition/config-root.ts | 22 +++- src/composition/doctor-root.ts | 26 +++++ test/adapters/claude-session-onboard.test.ts | 48 +++++++- 5 files changed, 213 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 515c790..7ba62c8 100644 --- a/README.md +++ b/README.md @@ -375,9 +375,15 @@ swisscode config accounts login personal --dir ~/.claude # adopt the login you swisscode config accounts # who each account is, no keychain prompt ``` -`login` creates `~/.config/swisscode/accounts/` at `0700`, then runs the -agent there so you can complete `/login` once. After that the account is a -normal thing profiles can reference. +`login` creates `~/.config/swisscode/accounts/` at `0700`, mints a profile +of the same name so there is something to launch, then runs the agent there so +you can complete `/login` once. + +The profile matters: an account says *who pays*, and `swisscode ` selects +a **profile**. Without one the account is unreachable — which is why `config +accounts` and `config doctor` both flag an account no profile uses. Pass +`--no-profile` if you mean to wire it into an existing multi-account profile +yourself. > **A new directory does not start logged out — it starts as a copy of the login > you already have.** Claude Code seeds a fresh `CLAUDE_CONFIG_DIR` from your diff --git a/src/adapters/claude-session/onboard.ts b/src/adapters/claude-session/onboard.ts index 3f129cc..845c36b 100644 --- a/src/adapters/claude-session/onboard.ts +++ b/src/adapters/claude-session/onboard.ts @@ -15,6 +15,7 @@ import { existsSync, mkdirSync, statSync } from 'node:fs' import { homedir } from 'node:os' import { isAbsolute, join, resolve } from 'node:path' import { describeIdentity, readSessionIdentity } from './identity.ts' +import { validateProfileName } from '../../core/migrate.ts' import { isDefaultConfigDir } from '../agents/claude-code/env.ts' import type { ConfigStorePort, ProviderAccount, State } from '../../ports/config-store.ts' import type { AgentRegistryPort } from '../../ports/agent.ts' @@ -29,6 +30,14 @@ export type LoginOptions = { dir?: string | undefined /** `--provider `, defaulting to anthropic — the only one with this flow today */ provider?: string | undefined + /** + * `--no-profile`: record the account and stop, leaving it unlaunchable. + * + * For the deliberate case — an account you are about to add to an existing + * multi-account profile by hand — which is the only reason to want the state + * this command used to leave behind by accident. + */ + noProfile?: boolean | undefined store: ConfigStorePort agents: AgentRegistryPort proc: ProcessPort @@ -74,11 +83,86 @@ export function validateAccountName(name: string): { ok: true } | { ok: false; r return { ok: true } } +/** + * What linking an account to a launchable profile did, or why it did not. + * + * `profile` is the name you can actually type at `swisscode ` afterwards. + * `null` with a `reason` is a real outcome, not a failure: the account is still + * recorded, and the reason is what the caller prints instead of a lie. + */ +export type LinkResult = { state: State; profile: string | null; reason: string | null } + +/** + * Make a freshly recorded account LAUNCHABLE. + * + * WHY THIS EXISTS. `config accounts login` used to record an account, print + * "Nothing else to do — this account is ready to use", and stop. 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 ` and watch the name go to the agent as a prompt. + * + * So this mints the same 1:1:1 shape the wizard already produces — an account, a + * setup and a profile all sharing one name — which is also what the v2->v3 + * migration produces, so there is exactly one arrangement a new install can be + * in rather than two. + * + * It REFUSES rather than improvises in the two cases where guessing would + * silently change what a launch bills: + * + * - a profile of that name already exists and does not name this account. + * Adding the account to it would change who pays for an existing setup. + * - the name is not a legal profile name — an account may be called `fix`, + * but a PROFILE called `fix` would swallow `swisscode fix the login bug`, + * which is the exact hazard COMMON_WORD_GUARD exists to prevent. + * + * An existing setup of the same name is REUSED, never overwritten: setups are + * shareable by design, and clobbering one would silently re-point every profile + * that references it. + */ +export function linkAccount(state: State, name: string): LinkResult { + const already = Object.entries(state.profiles ?? {}).find(([, p]) => + (p.accounts ?? []).includes(name), + ) + if (already) return { state, profile: already[0], reason: null } + + const existing = state.profiles?.[name] + if (existing) { + return { + state, + profile: null, + reason: + `a profile called "${name}" already exists and does not use this account — adding it ` + + 'would change who pays for that profile', + } + } + const verdict = validateProfileName(name) + if (!verdict.ok) { + return { state, profile: null, reason: `"${name}" cannot be a profile name: ${verdict.reason}` } + } + + return { + state: { + ...state, + // Reused when it exists — a setup can back several profiles. + setups: { ...(state.setups ?? {}), [name]: state.setups?.[name] ?? {} }, + profiles: { + ...(state.profiles ?? {}), + [name]: { setup: name, accounts: [name], strategy: 'single' }, + }, + // First profile on the machine becomes the default, matching the wizard. + defaultProfile: state.defaultProfile ?? name, + }, + profile: name, + reason: null, + } +} + /** @returns the process exit code, or does not return at all (execve). */ export function accountLogin({ name, dir, provider = 'anthropic', + noProfile = false, store, agents, proc, @@ -161,17 +245,41 @@ export function accountLogin({ } const account: ProviderAccount = { provider, configDir: target } - const next: State = { + const recorded: State = { ...state, providerAccounts: { ...(state.providerAccounts ?? {}), [name]: account }, } + // An account on its own cannot be launched — only a profile can — so make one + // unless the user asked not to. See `linkAccount`. + const link = noProfile + ? { state: recorded, profile: null, reason: 'you passed `--no-profile`' } + : linkAccount(recorded, name) try { - store.save(next) + store.save(link.state) } catch (e) { err(`swisscode: could not record the account: ${(e as { message?: string }).message ?? e}`) return 2 } + /** + * The one sentence that has to be true. + * + * Printed at every exit below, because the previous version's cheerful + * "nothing else to do" was the whole bug: it said an account was ready when + * nothing could launch it. + */ + const sayHowToLaunch = (): void => { + if (link.profile) { + out('') + out(`Launch it with: swisscode ${link.profile}`) + } else { + out('') + out(`This account cannot be launched yet — ${link.reason}.`) + out('An account says who pays; a profile is the thing you launch. Make one with') + out(' swisscode config ') + } + } + const env = proc.env() const isDefault = isDefaultConfigDir(target, env) const already = readSessionIdentity(target, { env }) @@ -182,8 +290,9 @@ export function accountLogin({ // are already using would be busywork that risks replacing it. out(`Account "${name}" adopted your existing login: ${describeIdentity(already)}.`) out(` ${target} (Claude Code's default directory)`) + sayHowToLaunch() out('') - out('Nothing else to do — this account is ready to use. Add a second one with') + out('Add a second subscription with') out(` swisscode config accounts login `) return 0 } @@ -197,6 +306,7 @@ export function accountLogin({ } else { out(`Account "${name}" recorded, using ${target}.`) } + sayHowToLaunch() // Claude Code is the only agent with this flow — the login being adopted IS a // Claude subscription — so this does not consult the setup. Kilo and diff --git a/src/composition/config-root.ts b/src/composition/config-root.ts index 3b6d824..6aa37f3 100644 --- a/src/composition/config-root.ts +++ b/src/composition/config-root.ts @@ -110,7 +110,9 @@ const USAGE = `swisscode config — manage profiles and directory bindings swisscode config accounts provider accounts, and which profiles use each swisscode config accounts login adopt a Claude subscription: makes a session - [--dir ] directory and runs the agent so you can /login + [--dir ] directory, a profile you can launch, and runs + [--no-profile] the agent so you can /login (--no-profile skips + the profile, leaving it unlaunchable) swisscode config accounts usage how much of each subscription is left, and cache it swisscode config upgrade check npm for a newer swisscode and install it [--dry-run] (--dry-run only prints the command) @@ -1187,7 +1189,13 @@ function accountsCommand({ const i = rest.indexOf(name) return i >= 0 ? rest[i + 1] : undefined } - const positional = rest.filter((a, i) => !a.startsWith('--') && !rest[i - 1]?.startsWith('--')) + // ONLY these consume the token after them. The previous version treated every + // `--flag` as value-taking, so adding one boolean would have silently eaten + // the account name in `login --no-profile personal`. + const VALUE_FLAGS = ['--dir', '--provider'] + const positional = rest.filter( + (a, i) => !a.startsWith('--') && !VALUE_FLAGS.includes(rest[i - 1] ?? ''), + ) const options = { name: positional[0], @@ -1203,6 +1211,7 @@ function accountsCommand({ ...options, ...(dir !== undefined ? { dir } : {}), ...(provider !== undefined ? { provider } : {}), + ...(rest.includes('--no-profile') ? { noProfile: true } : {}), }) } @@ -1286,7 +1295,14 @@ function listAccounts({ deps, out }: { deps: LaunchDeps; out: Emit }): number { // still a fingerprint and this output gets pasted into bug reports. out(` key ${credentialOrigin(a)}`) } - out(` used by ${usedBy.length > 0 ? usedBy.join(', ') : '— nothing'}`) + // "used by — nothing" understated it: an account no profile names cannot be + // launched at all, which is a broken state rather than an idle one. + if (usedBy.length > 0) { + out(` used by ${usedBy.join(', ')}`) + } else { + out(' used by — NOTHING CAN LAUNCH THIS. An account says who pays; a profile is') + out(` what you launch. Make one: \`swisscode config ${name}\``) + } } // The explanation goes ONCE, at the end, with the fix. Repeating the full diff --git a/src/composition/doctor-root.ts b/src/composition/doctor-root.ts index 275ad33..1bb38cc 100644 --- a/src/composition/doctor-root.ts +++ b/src/composition/doctor-root.ts @@ -44,6 +44,7 @@ import { measureAccounts, remainingMap } from '../adapters/usage/measure.ts' import { COLLISION_REASON, CONFLICT_REASON, + accountsUsedBy, credentialSource, identityCollisions, } from '../core/account.ts' @@ -397,6 +398,31 @@ export async function runDoctor({ } } + // Accounts nothing can launch. + // + // CONFIG-WIDE, like the duplicate check below. An account no profile + // references is not idle, it is unreachable: `swisscode ` does + // not select it (a positional names a PROFILE), so the credential sits there + // paying for nothing. `config accounts login` now links one automatically, so + // this catches the leftovers — hand-edited configs, `--no-profile`, and every + // account created before that fix existed. + const orphans = Object.keys(loaded.state.providerAccounts ?? {}).filter( + (name) => accountsUsedBy(loaded.state.profiles, name).length === 0, + ) + checks.push( + orphans.length === 0 + ? makeCheck('account-orphan', 'accounts reachable', OK, 'every account is used by a profile') + : makeCheck( + 'account-orphan', + 'accounts reachable', + WARN, + `no profile uses ${orphans.join(', ')} — nothing can launch ${ + orphans.length === 1 ? 'it' : 'them' + }`, + { fix: `swisscode config ${orphans[0]}` }, + ), + ) + // Accounts that are secretly one subscription. // // CONFIG-WIDE, unlike the session check above, which can only see the profile diff --git a/test/adapters/claude-session-onboard.test.ts b/test/adapters/claude-session-onboard.test.ts index 50b2ee0..49dc51e 100644 --- a/test/adapters/claude-session-onboard.test.ts +++ b/test/adapters/claude-session-onboard.test.ts @@ -210,12 +210,56 @@ test('the default directory earns no permissions warning — swisscode did not c assert.doesNotMatch(h.err.join('\n'), /readable by other users/) }) -test('the recorded config is exactly one account and nothing else', () => { +test('login makes the account LAUNCHABLE, not merely recorded', () => { + // THE BUG THIS REPLACES. The old assertion was "exactly one account and + // nothing else", which is what left users with an account they could not + // launch — `swisscode ` sent the name to the agent as a prompt, + // because a positional names a PROFILE and none referenced the account. const h = harness({ profiles: { p: { setup: 'a', accounts: [] } } } as Partial) h.run({ ...base(h), name: 'personal' }) const saved = h.saved.at(-1)! assert.deepEqual(Object.keys(saved.providerAccounts ?? {}), ['personal']) - assert.deepEqual(Object.keys(saved.profiles ?? {}), ['p'], 'existing config is preserved') + // The 1:1:1 shape the wizard mints, so a new install is in one arrangement. + assert.deepEqual(saved.profiles!.personal, { + setup: 'personal', + accounts: ['personal'], + strategy: 'single', + }) + assert.deepEqual(saved.setups!.personal, {}) + assert.ok(saved.profiles!.p, 'an unrelated existing profile is preserved') + assert.match(h.out.join('\n'), /swisscode personal/, 'and it says how to launch it') +}) + +test('--no-profile records the account alone, and says it cannot be launched', () => { + const h = harness() + h.run({ ...base(h), name: 'personal', noProfile: true }) + const saved = h.saved.at(-1)! + assert.deepEqual(Object.keys(saved.providerAccounts ?? {}), ['personal']) + assert.deepEqual(Object.keys(saved.profiles ?? {}), []) + // The claim has to match reality — saying "ready to use" here was the bug. + assert.match(h.out.join('\n'), /cannot be launched yet/) +}) + +test('an existing profile already naming the account is reused, not duplicated', () => { + const h = harness({ + profiles: { mine: { setup: 's', accounts: ['personal'] } }, + setups: { s: {} }, + } as Partial) + h.run({ ...base(h), name: 'personal' }) + const saved = h.saved.at(-1)! + assert.deepEqual(Object.keys(saved.profiles ?? {}), ['mine'], 'no second profile invented') + assert.match(h.out.join('\n'), /swisscode mine/, 'it names the profile that already works') +}) + +test('a name that cannot be a profile records the account and explains', () => { + // `fix` is a legal ACCOUNT name but a profile called `fix` would swallow + // `swisscode fix the login bug`. Refuse the profile, keep the account, say so. + const h = harness() + h.run({ ...base(h), name: 'fix' }) + const saved = h.saved.at(-1)! + assert.deepEqual(Object.keys(saved.providerAccounts ?? {}), ['fix']) + assert.deepEqual(Object.keys(saved.profiles ?? {}), [], 'no prompt-shadowing profile') + assert.match(h.out.join('\n'), /cannot be launched yet/) }) test('the created directory is empty — swisscode writes no credential into it', () => { From ac86558adc5b33a4f8e9581264b931e12270ffde Mon Sep 17 00:00:00 2001 From: jellologic <31935831+jellologic@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:47:45 -0400 Subject: [PATCH 5/6] Teach the wizard subscription accounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- README.md | 5 ++ src/adapters/providers/anthropic.ts | 3 + src/adapters/ui/index.tsx | 114 ++++++++++++++++++++++++++- src/adapters/web/api.ts | 1 + src/ports/provider.ts | 15 ++++ test/ui-subscription.test.ts | 118 ++++++++++++++++++++++++++++ web/src/api.ts | 2 + web/src/routes/Accounts.tsx | 11 +-- 8 files changed, 262 insertions(+), 7 deletions(-) create mode 100644 test/ui-subscription.test.ts diff --git a/README.md b/README.md index 7ba62c8..99189ed 100644 --- a/README.md +++ b/README.md @@ -369,6 +369,11 @@ key — it is a **login**, stored by Claude Code in your keychain and pointed at An account is one or the other, never both; a config naming a key *and* a session directory is refused rather than resolved by precedence. +The terminal wizard covers this too — pick **Anthropic (direct)** in +`swisscode config ` and it asks how the account pays, offering a +subscription kept separate from your other logins, the login you already use, or +an API key. Or do it directly: + ```sh swisscode config accounts login work # make a session dir, run /login inside swisscode config accounts login personal --dir ~/.claude # adopt the login you already have diff --git a/src/adapters/providers/anthropic.ts b/src/adapters/providers/anthropic.ts index 0edc05d..27a6770 100644 --- a/src/adapters/providers/anthropic.ts +++ b/src/adapters/providers/anthropic.ts @@ -17,6 +17,9 @@ export const anthropic = { baseUrl: null, credentialEnv: 'ANTHROPIC_API_KEY', credentialOptional: true, + // The only provider a Claude subscription can authenticate against, because a + // session directory holds a login to api.anthropic.com and nowhere else. + sessionCapable: true, // No defaults: every tier variable is cleared so Claude Code uses its own. defaultModels: {}, catalogId: null, diff --git a/src/adapters/ui/index.tsx b/src/adapters/ui/index.tsx index ccfad79..771fcb8 100644 --- a/src/adapters/ui/index.tsx +++ b/src/adapters/ui/index.tsx @@ -12,6 +12,11 @@ import { createCatalogRegistry } from '../catalog/registry.ts' import { fetchNet } from '../net/fetch-net.ts' import { systemClock } from '../clock/system-clock.ts' import { resolveProfileRefs } from '../../core/resolve.ts' +// The SAME directory `config accounts login` uses, so the two ways of creating a +// subscription account put it in one place rather than two. +import { accountsDir } from '../claude-session/onboard.ts' +import { join } from 'node:path' +import { mkdirSync } from 'node:fs' import { ModelPicker } from './ModelPicker.tsx' import { ConfirmDelete, ProfileActions, ProfilePicker } from './ProfilePicker.tsx' import { frameBorder, Select, tone } from './theme.tsx' @@ -133,6 +138,7 @@ type Step = | 'confirmDelete' | 'name' | 'provider' + | 'credentialKind' | 'baseUrl' | 'apiKey' | 'picker' @@ -170,6 +176,16 @@ export type AppProps = { profileName?: string | null | undefined /** called exactly once, with the saved profile or null if cancelled */ onResult: (profile: Profile | null) => void + /** + * Called when the saved account authenticates with a SESSION rather than a + * key, with the directory it will use. + * + * Separate from `onResult` because it is not part of the result — it is a + * message for AFTER Ink has unmounted. The wizard cannot print it itself: Ink + * owns the screen until it restores the terminal, and anything written during + * a render is either cleared or interleaved with it. + */ + onSessionAccount?: ((configDir: string) => void) | undefined store?: ConfigStorePort | null | undefined registry?: ProviderRegistryPort | undefined catalogs?: CatalogRegistryPort | null | undefined @@ -183,6 +199,7 @@ export function App({ initial = undefined, profileName = null, onResult, + onSessionAccount, store = null, registry = defaultRegistry, catalogs = null, @@ -260,6 +277,15 @@ export function App({ const [providerId, setProviderId] = useState(startProfile?.provider ?? null) const [baseUrl, setBaseUrl] = useState(startProfile?.baseUrl ?? '') const [apiKey, setApiKey] = useState(startProfile?.apiKey ?? '') + /** + * The session directory this account will authenticate with, or ''. + * + * Non-empty means SUBSCRIPTION MODE: no key is stored, and the account points + * at a directory the agent logs into itself. Mutually exclusive with `apiKey` + * by construction here, because `finish()` writes one or the other and the + * config schema refuses both. + */ + const [sessionDir, setSessionDir] = useState(startProfile?.configDir ?? '') const [models, setModels] = useState>({ ...emptyModels(), ...(startProfile?.models ?? {}), @@ -419,7 +445,11 @@ export function App({ ? { ...emptyModels(), ...stored.models } : { ...emptyModels(), ...registry.byId(id)!.defaultModels }, ) - if (registry.byId(id)!.askBaseUrl) setStep('baseUrl') + // A provider that can hold a subscription login asks HOW before asking for + // a key, because for Claude Pro/Max the answer is "no key at all" and the + // key screen has no way to say that. Everything else keeps the old path. + if (registry.byId(id)!.sessionCapable) setStep('credentialKind') + else if (registry.byId(id)!.askBaseUrl) setStep('baseUrl') else setStep('apiKey') } @@ -442,10 +472,13 @@ export function App({ // multi-account profiles: rotating between accounts is a thing you set up // once, in the web UI or `config accounts`, not something a first-run // terminal flow should ask everyone about. + // ONE credential or the other, never both — the config schema refuses an + // account carrying a key AND a session directory, because "which one paid + // for this" must never have a subtle answer. See core/account.ts. const account: ProviderAccount = { provider: providerId!, ...(provider!.askBaseUrl ? { baseUrl: baseUrl.trim() } : {}), - apiKey: apiKey.trim(), + ...(sessionDir ? { configDir: sessionDir } : { apiKey: apiKey.trim() }), } const setup: Setup = { models, @@ -474,10 +507,24 @@ export function App({ defaultProfile: doc.defaultProfile ?? name, } + // 0700 BEFORE the agent gets there, matching `config accounts login`. Left + // to Claude Code the directory would be created with default permissions, + // and it is about to hold a login — the same reason onboard.ts does this. + // Best effort: a failure here is not worth losing a saved configuration + // over, and the launch that follows reports its own problems. + if (sessionDir) { + try { + mkdirSync(sessionDir, { recursive: true, mode: 0o700 }) + } catch { + /* the launch will say so if the directory is genuinely unusable */ + } + } + // A throw here would escape an Ink input handler with the tty still in raw // mode, leaving the terminal unusable. Surface it in-frame instead. if (!persist(next)) return onResult(profile) + if (sessionDir) onSessionAccount?.(sessionDir) exit() } @@ -603,6 +650,54 @@ export function App({ ) } + if (step === 'credentialKind') { + // THREE OPTIONS, AND THE FIRST TWO ARE THE POINT. "A subscription kept + // separate" and "the login I already use" look identical from outside and + // are not: the first is a directory swisscode manages, so you can hold + // several logins at once; the second is whatever plain `claude` uses, which + // there is exactly one of. Collapsing them is what made multi-account setup + // feel impossible — the wizard could only ever express the second, so + // subscription users were pushed onto the raw four-noun path in the web UI. + const name = editingName || newName || 'account' + const items = [ + { label: 'A Claude subscription, kept separate from your other logins', value: 'session' }, + { label: 'The Claude login I already use', value: 'ambient' }, + { label: 'An API key', value: 'key' }, + ] + return ( + + + How does this account pay? + +