diff --git a/README.md b/README.md index ad14eac..894690e 100644 --- a/README.md +++ b/README.md @@ -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 ` 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 ` +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 ``` @@ -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 `, 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 @@ -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 ` 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/` 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 +> 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 @@ -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 @@ -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": { @@ -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" }, @@ -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 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..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,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) { @@ -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 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 7ccb5ce..771fcb8 100644 --- a/src/adapters/ui/index.tsx +++ b/src/adapters/ui/index.tsx @@ -12,13 +12,18 @@ 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' import type { ProfileAction } from './ProfilePicker.tsx' import type { Tier, TierRecord, ProviderDescriptor, ProviderRegistryPort } from '../../ports/provider.ts' import type { - AgentProfile, + Setup, ConfigStorePort, Profile, ProviderAccount, @@ -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,12 +472,15 @@ 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 agentProfile: AgentProfile = { + const setup: Setup = { models, ...(Object.keys(keptWindows).length > 0 ? { contextWindows: keptWindows } : {}), skipPermissions, @@ -456,28 +489,42 @@ 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, } + // 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? + + put('agentProfile', e.target.value)} + value={String(draft.setup ?? '')} + onChange={(e) => put('setup', e.target.value)} > - {agentProfileNames.map((n) => ( + {setupNames.map((n) => ( ))} @@ -304,10 +373,11 @@ export function Profiles({ data, reload }: { data: Bootstrap; reload: () => Prom } /> {error ? {error} : null} + {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 +385,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".