Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 83 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,14 +126,43 @@ need to pass a reserved token through literally, put it after `--`:
swisscode -- --cc-profile # claude receives "--cc-profile"
```

## The four things

Four concepts, and only the last one is a thing you launch.

| | What it is | Optional? |
|---|---|---|
| **Provider** | An endpoint dialect — base URL, which credential variable, default models, compatibility flags. Eight ship built in | No. Every account names one. *Adding your own* is the optional part |
| **Account** | **Who pays.** One provider plus one credential: an API key, an env var, or a Claude subscription login | No |
| **Setup** | **What runs.** Which CLI (`claude-code`, `kilo`, `opencode`), which model per tier, permissions, env | No. Every profile names one |
| **Profile** | **The pairing.** One setup + one or more accounts + how to choose between them | No — this is what `swisscode <name>` launches |

```
swisscode work
└── profile "work"
├── setup "cc" claude-code, opus/sonnet/haiku
└── accounts ["personal", "team"] strategy: usage
└── account "personal" → provider "anthropic" → subscription login
```

Why split at all: they vary independently. One setup ("Claude Code, yolo, GLM on
every tier") can be pointed at several accounts, and one account can back several
setups. A profile naming more than one account is how rotation works.

Two mistakes the tool now catches for you, because both used to fail silently:
an **account with no profile** cannot be launched (`swisscode <account-name>`
selects a *profile*, so the name would go to the agent as a prompt), and **two
accounts that are the same subscription** share one quota while looking like two.

## Profiles

A profile is a named provider + key + models. Name one after each account,
client or experiment.
Name one after each account, client or experiment.

```sh
swisscode config work # create or edit the "work" profile
swisscode config list # every profile (keys are never printed)
swisscode config setups # what runs, and which profiles share each
swisscode config accounts # who pays, and which profiles use each
swisscode config default work # used when nothing else applies
swisscode config rm old # deletes it, and any bindings to it
```
Expand All @@ -148,6 +177,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
**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.

```
$ 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 <name>`, 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
Expand Down Expand Up @@ -354,15 +398,35 @@ 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 <name>` 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
swisscode config accounts # who each account is, no keychain prompt
```

`login` creates `~/.config/swisscode/accounts/<name>` 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/<name>` 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 <name>` 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
> 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
Expand All @@ -384,7 +448,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
Expand Down Expand Up @@ -569,12 +633,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": {
Expand All @@ -590,7 +654,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" },
Expand All @@ -599,12 +663,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
Expand Down
20 changes: 15 additions & 5 deletions src/adapters/claude-session/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
132 changes: 128 additions & 4 deletions src/adapters/claude-session/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -29,6 +30,14 @@ export type LoginOptions = {
dir?: string | undefined
/** `--provider <id>`, 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
Expand Down Expand Up @@ -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 <name>` 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 <account-name>` 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,
Expand Down Expand Up @@ -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 <profile-name>')
}
}

const env = proc.env()
const isDefault = isDefaultConfigDir(target, env)
const already = readSessionIdentity(target, { env })
Expand All @@ -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 <other-name>`)
return 0
}
Expand All @@ -197,9 +306,10 @@ 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 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) {
Expand All @@ -221,6 +331,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
Expand Down
3 changes: 3 additions & 0 deletions src/adapters/providers/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading