diff --git a/package.json b/package.json index b2f3953..d5e6704 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "swisscode", "version": "0.4.0", - "description": "Drop-in launcher for Claude Code, Kilo & OpenCode. Run multiple Claude Pro/Max accounts, check usage limits and switch accounts without /login \u2014 or point any coding CLI at Ollama (local, no key), OpenRouter, z.ai/GLM, DeepSeek & Qwen. No proxy, no daemon.", + "description": "Drop-in launcher for Claude Code, Kilo & OpenCode. Run multiple Claude Pro/Max accounts, check usage limits and switch accounts without /login — or point any coding CLI at Ollama (local, no key), OpenRouter, z.ai/GLM, DeepSeek & Qwen. No proxy, no daemon.", "type": "module", "bin": { "swisscode": "./bin/swisscode.js" @@ -26,7 +26,8 @@ "prepublishOnly": "node build.js", "size": "node scripts/size-budget.js", "dev:web": "vite --config web/vite.config.ts", - "typecheck:web": "tsc --noEmit -p web/tsconfig.json" + "typecheck:web": "tsc --noEmit -p web/tsconfig.json", + "extract:claude-env": "node scripts/extract-claude-env.mjs > src/adapters/agents/claude-code/env-catalog.ts" }, "keywords": [ "claude", diff --git a/scripts/extract-claude-env.mjs b/scripts/extract-claude-env.mjs new file mode 100644 index 0000000..86db78e --- /dev/null +++ b/scripts/extract-claude-env.mjs @@ -0,0 +1,256 @@ +// Extract every environment variable a Claude Code build references. +// +// WHY A SCRIPT AND NOT A PASTED SNAPSHOT. The catalog it produces is committed +// (the build must not require Claude Code to be installed), so without this it +// would be a list nobody could regenerate, aging silently against a binary that +// ships weekly. Re-run it against a newer Claude Code and diff. +// +// node scripts/extract-claude-env.mjs > src/adapters/agents/claude-code/env-catalog.ts +// +// WHAT THIS CANNOT DO, stated up front because the output is a UI surface: +// it reads STRINGS OUT OF A BINARY. That yields names, and nothing else. It +// cannot yield meaning, defaults, accepted values, or whether a variable is +// still wired to anything. Every description in the output comes from the +// hand-maintained table below — never from the binary — and anything absent +// from that table ships explicitly marked as undocumented rather than given a +// plausible-sounding guess. A wrong description is worse than no description: +// it is a claim someone will act on. + +import { execFileSync } from 'node:child_process' +import { existsSync, readdirSync, statSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' + +/** Where the native installer puts versioned binaries. */ +function findBinary() { + const explicit = process.argv[2] + if (explicit) return explicit + const dir = join(homedir(), '.local', 'share', 'claude', 'versions') + if (!existsSync(dir)) return null + const versions = readdirSync(dir) + .filter((v) => /^\d+\.\d+\.\d+$/.test(v)) + .sort((a, b) => { + const pa = a.split('.').map(Number) + const pb = b.split('.').map(Number) + for (let i = 0; i < 3; i++) if (pa[i] !== pb[i]) return pb[i] - pa[i] + return 0 + }) + for (const v of versions) { + const candidate = join(dir, v) + if (existsSync(candidate) && statSync(candidate).isFile()) return candidate + } + return null +} + +const binary = findBinary() +if (!binary) { + console.error( + 'extract-claude-env: no Claude Code binary found. Pass one explicitly:\n' + + ' node scripts/extract-claude-env.mjs /path/to/claude', + ) + process.exit(2) +} + +const version = binary.split('/').pop() + +const raw = execFileSync('strings', ['-a', binary], { + encoding: 'utf8', + maxBuffer: 1024 * 1024 * 1024, +}) + +// Anthropic's two prefixes. `CLAUDE_` alone is deliberately excluded: it also +// matches CLAUDE_CONFIG_DIR-style host variables and a long tail of one-off +// internal names, and widening the net does not make the result more useful. +const names = [...new Set(raw.match(/\b(?:CLAUDE_CODE|ANTHROPIC)_[A-Z0-9_]+\b/g) ?? [])].sort() + +/** + * Names that clearly are not a user-facing knob, by SHAPE rather than by + * judgement about any individual one. + * + * Each rule is something you can check yourself against the list, which matters + * because this classification is the only thing standing between a browsable + * catalog and 428 undifferentiated strings. + */ +const INTERNAL = [ + { re: /_FOR_TESTING$|^CLAUDE_CODE_TEST_|_FIXTURE$/, why: 'test hook' }, + { re: /^CLAUDE_CODE_(MOCK|SIMULATE|FORCE_TIP_ID|OVERRIDE_DATE)/, why: 'test hook' }, + { re: /^CLAUDE_CODE_(BENCH|PERFETTO|FRAME_TIMING|PROFILE_(QUERY|STARTUP)|DEBUG_REPAINTS)/, why: 'profiling' }, + { re: /^ANTHROPIC_(BEDROCK|VERTEX|FOUNDRY|AWS|GOOGLE_CLOUD)/, why: 'third-party cloud provider' }, + { re: /^CLAUDE_CODE_(USE_BEDROCK|USE_VERTEX|USE_FOUNDRY|USE_MANTLE|USE_ANTHROPIC_AWS|USE_ANTHROPIC_GOOGLE_CLOUD|SKIP_.*_AUTH|SKIP_AWS_CRED_CACHE)/, why: 'third-party cloud provider' }, +] + +/** + * Two unrelated words joined by an underscore, with nothing else in the name. + * + * Anthropic ships unreleased features behind codenames — ALDER_WICKET, + * BISON_CAIRN, PEWTER_OWL, THISTLE_GREBE. They are indistinguishable from real + * knobs by name alone, they appear and vanish between releases, and a user who + * sets one is configuring something nobody can describe. Matched structurally + * so the rule keeps working on names that do not exist yet. + */ +const CODENAME = /^CLAUDE_CODE_[A-Z]+_[A-Z]+$/ + +// Words that make a two-word name a real knob rather than a codename. Without +// this, TMUX_PREFIX and MAX_RETRIES would be classified as codenames. +const REAL_WORDS = + /(MAX|MIN|DISABLE|ENABLE|SKIP|FORCE|USE|API|OAUTH|TOKEN|MODEL|PROXY|TIMEOUT|SESSION|DEBUG|LOG|DIR|PATH|FILE|URL|KEY|MODE|TMUX|SHELL|PLUGIN|SYNC|IDE|OTEL|MCP|OUTPUT|CONTEXT|TOOL|AGENT|MEMORY|REMOTE|SANDBOX|TELEMETRY|VERSION|PROMPT|RETRY|RETRIES|CACHE|GLOB|EFFORT|SUBAGENT|TERMINAL|ARTIFACT|WORKFLOW|ENTRYPOINT|EMAIL|UUID|ID)/ + +/** + * The hand-written half. NOTHING HERE COMES FROM THE BINARY. + * + * Every entry is a variable whose behaviour is documented by Anthropic, visible + * in `claude --help`, or already relied on by swisscode's own adapter — which + * is to say, one somebody can be held to. Everything not in this table ships as + * `undocumented`, name only. + */ +const DESCRIBED = { + ANTHROPIC_API_KEY: ['credential', 'The API key Claude Code authenticates with. swisscode sets this per profile; it is cleared for any launch not going to first-party Anthropic.'], + ANTHROPIC_AUTH_TOKEN: ['credential', 'Bearer-token form of the credential, used by most gateways. swisscode picks this or ANTHROPIC_API_KEY based on the provider descriptor.'], + ANTHROPIC_BASE_URL: ['endpoint', 'The Anthropic-compatible endpoint to talk to. swisscode sets this from the profile’s provider; a bare host, with no /v1.'], + ANTHROPIC_MODEL: ['model', 'Overrides the model for the session. Prefer the per-tier ANTHROPIC_DEFAULT_*_MODEL variables, which is what swisscode pins.'], + ANTHROPIC_SMALL_FAST_MODEL: ['model', 'The model used for cheap background work. Superseded by ANTHROPIC_DEFAULT_HAIKU_MODEL on current builds.'], + ANTHROPIC_DEFAULT_OPUS_MODEL: ['model', 'Model id for the opus tier. Accepts a [1m] suffix to request the extended context window.'], + ANTHROPIC_DEFAULT_SONNET_MODEL: ['model', 'Model id for the sonnet tier.'], + ANTHROPIC_DEFAULT_HAIKU_MODEL: ['model', 'Model id for the haiku tier, used for background and summarisation work.'], + ANTHROPIC_DEFAULT_FABLE_MODEL: ['model', 'Model id for the fable tier.'], + ANTHROPIC_CUSTOM_HEADERS: ['endpoint', 'Extra HTTP headers on every API request, as newline-separated Name: Value pairs.'], + ANTHROPIC_BETAS: ['endpoint', 'Beta headers to include in API requests. API-key users only.'], + ANTHROPIC_LOG: ['debug', 'Log verbosity for the API client.'], + CLAUDE_CONFIG_DIR: ['session', 'Which directory holds the login and settings. THE BRANCH IS ON WHETHER THIS IS SET, not on its value — setting it to the default ~/.claude is a different, empty login.'], + CLAUDE_CODE_MAX_OUTPUT_TOKENS: ['limits', 'Ceiling on tokens the model may produce in one response.'], + CLAUDE_CODE_MAX_CONTEXT_TOKENS: ['limits', 'Ceiling on the context window Claude Code will fill before compacting.'], + CLAUDE_CODE_MAX_RETRIES: ['limits', 'How many times a failed API request is retried.'], + CLAUDE_CODE_MAX_TURNS: ['limits', 'Maximum agent turns before the session stops on its own.'], + CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS: ['limits', 'How many subagents may run at once.'], + CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY: ['limits', 'How many tool calls may run in parallel within one turn.'], + CLAUDE_CODE_SUBAGENT_MODEL: ['model', 'Model subagents run on. Pinning it matters on gateways: subagents 404 when the id is not one the endpoint serves. swisscode sets this for OpenRouter.'], + CLAUDE_CODE_AUTO_COMPACT_WINDOW: ['limits', 'Fraction of the context window at which auto-compaction triggers.'], + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: ['traffic', 'Stops background requests an endpoint may not serve. ALSO disables gateway model discovery, so every tier must be pinned explicitly.'], + CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING: ['compat', 'Works around gateways that reject the adaptive thinking input tag with 400.'], + CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS: ['compat', 'Works around gateways that reject unknown beta fields with "Extra inputs are not permitted".'], + CLAUDE_CODE_SKIP_FAST_MODE_ORG_CHECK: ['compat', 'Skips the organisation check that reports fast mode as disabled on non-first-party endpoints.'], + CLAUDE_CODE_ATTRIBUTION_HEADER: ['traffic', 'Set to 0 to drop the attribution header, which improves prompt-cache hit rate through some gateways.'], + CLAUDE_CODE_DISABLE_1M_CONTEXT: ['limits', 'Turns off the 1M context window request.'], + CLAUDE_CODE_DISABLE_THINKING: ['behaviour', 'Disables extended thinking.'], + CLAUDE_CODE_DISABLE_TERMINAL_TITLE: ['ui', 'Stops Claude Code rewriting the terminal window title.'], + CLAUDE_CODE_DISABLE_MOUSE: ['ui', 'Disables mouse reporting, which some terminals and multiplexers handle badly.'], + CLAUDE_CODE_DISABLE_AUTO_MEMORY: ['behaviour', 'Stops automatic memory capture.'], + CLAUDE_CODE_DISABLE_CLAUDE_MDS: ['behaviour', 'Stops CLAUDE.md files being discovered and loaded.'], + CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: ['behaviour', 'Disables background task execution.'], + CLAUDE_CODE_ENABLE_TELEMETRY: ['telemetry', 'Enables OpenTelemetry export.'], + CLAUDE_CODE_SAFE_MODE: ['behaviour', 'Set by --safe-mode. Disables customisations — CLAUDE.md, skills, plugins, hooks, MCP servers — for troubleshooting a broken configuration.'], + CLAUDE_CODE_SIMPLE: ['behaviour', 'Set by --bare. Skips hooks, LSP, plugin sync, auto-memory, keychain reads and CLAUDE.md discovery.'], + CLAUDE_CODE_EFFORT_LEVEL: ['behaviour', 'Effort level for the session: low, medium, high, xhigh, max. Also settable with --effort.'], + CLAUDE_CODE_SHELL: ['environment', 'Which shell the Bash tool uses.'], + CLAUDE_CODE_TMPDIR: ['environment', 'Temporary directory Claude Code writes to.'], + CLAUDE_CODE_GIT_BASH_PATH: ['environment', 'Path to Git Bash on Windows.'], + CLAUDE_CODE_HTTP_PROXY: ['network', 'HTTP proxy for Claude Code’s own requests.'], + CLAUDE_CODE_HTTPS_PROXY: ['network', 'HTTPS proxy for Claude Code’s own requests.'], + CLAUDE_CODE_CLIENT_CERT: ['network', 'Client certificate for mTLS.'], + CLAUDE_CODE_CLIENT_KEY: ['network', 'Client key for mTLS.'], + CLAUDE_CODE_ENTRYPOINT: ['environment', 'How Claude Code was started. swisscode does not set this; the agent uses it for its own telemetry.'], + CLAUDE_CODE_VERSION: ['environment', 'The running Claude Code version.'], + CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: ['traffic', 'Lets Claude Code ask a gateway which models it serves. Disabled as a side effect of CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC.'], + CLAUDE_CODE_API_KEY_HELPER_TTL_MS: ['credential', 'How long a credential from apiKeyHelper is cached.'], + CLAUDE_CODE_OAUTH_TOKEN: ['credential', 'OAuth access token. Written by /login; swisscode reads it only to measure subscription usage, and never refreshes it.'], + CLAUDE_CODE_SUBSCRIPTION_TYPE: ['credential', 'The plan behind the current login, e.g. max.'], + CLAUDE_CODE_DEBUG_LOG_LEVEL: ['debug', 'Verbosity of debug logging. Also settable with --debug.'], + CLAUDE_CODE_DEBUG_LOGS_DIR: ['debug', 'Where debug logs are written.'], + CLAUDE_CODE_SESSION_ID: ['session', 'The current session id. Also settable with --session-id.'], + CLAUDE_CODE_MANAGED_SETTINGS_PATH: ['environment', 'Path to admin-managed policy settings.'], + ENABLE_TOOL_SEARCH: ['compat', 'Enables MCP tool search, which is off by default away from first-party Anthropic.'], + API_FORCE_IDLE_TIMEOUT: ['compat', 'Set to 0 to stop the client giving up on slow or locally hosted models.'], + MAX_THINKING_TOKENS: ['limits', 'Ceiling on tokens spent on extended thinking.'], + DISABLE_TELEMETRY: ['telemetry', 'Disables telemetry reporting.'], + DISABLE_ERROR_REPORTING: ['telemetry', 'Disables error reporting.'], + DISABLE_AUTOUPDATER: ['behaviour', 'Stops Claude Code updating itself.'], +} + +// Variables swisscode itself writes, so the UI can say "this one is already +// yours to set from a profile" instead of listing it as inert trivia. +const SWISSCODE_SETS = new Set([ + 'ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL', 'CLAUDE_CONFIG_DIR', + 'ANTHROPIC_DEFAULT_OPUS_MODEL', 'ANTHROPIC_DEFAULT_SONNET_MODEL', + 'ANTHROPIC_DEFAULT_HAIKU_MODEL', 'ANTHROPIC_DEFAULT_FABLE_MODEL', + 'CLAUDE_CODE_SUBAGENT_MODEL', 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC', + 'CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING', 'CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS', + 'CLAUDE_CODE_SKIP_FAST_MODE_ORG_CHECK', 'CLAUDE_CODE_ATTRIBUTION_HEADER', + 'ENABLE_TOOL_SEARCH', 'API_FORCE_IDLE_TIMEOUT', +]) + +function classify(name) { + // THE HAND-WRITTEN TABLE OUTRANKS EVERY HEURISTIC, and the order here is the + // whole point rather than a detail. A description in DESCRIBED means somebody + // verified the variable; a regex is a guess about a name's shape. Running the + // guesses first got `CLAUDE_CODE_ATTRIBUTION_HEADER` — a variable swisscode + // sets itself — filed as an "unreleased feature codename", because it happens + // to be two words. Caught by the catalog's own tests, not by reading. + if (DESCRIBED[name]) return { kind: 'documented' } + for (const rule of INTERNAL) if (rule.re.test(name)) return { kind: 'internal', why: rule.why } + if (CODENAME.test(name) && !REAL_WORDS.test(name)) { + return { kind: 'internal', why: 'unreleased feature codename' } + } + return { kind: 'undocumented' } +} + +// Names swisscode knows about that the binary does not spell with a prefix we +// scan for (ENABLE_TOOL_SEARCH, MAX_THINKING_TOKENS, …). Included so the +// catalog is not narrower than the adapter it describes. +const extra = Object.keys(DESCRIBED).filter((n) => !names.includes(n)) +const all = [...names, ...extra].sort() + +const entries = all.map((name) => { + const c = classify(name) + const described = DESCRIBED[name] + return { + name, + kind: c.kind, + ...(c.why ? { why: c.why } : {}), + ...(described ? { category: described[0], description: described[1] } : {}), + ...(SWISSCODE_SETS.has(name) ? { managed: true } : {}), + } +}) + +const counts = entries.reduce((acc, e) => ({ ...acc, [e.kind]: (acc[e.kind] ?? 0) + 1 }), {}) + +process.stdout.write(`// GENERATED by scripts/extract-claude-env.mjs — do not edit by hand. +// +// Every environment variable referenced by Claude Code ${version}, extracted +// from the shipped binary, plus the ones swisscode's own adapter sets. +// +// READ THE 'kind' FIELD BEFORE TRUSTING AN ENTRY. Extraction yields NAMES AND +// NOTHING ELSE — no meaning, no defaults, no accepted values, and no evidence +// that a name is still wired to anything. So: +// +// documented ${String(counts.documented ?? 0).padStart(3)} described by hand from Anthropic's docs, \`claude --help\`, +// or swisscode's own adapter. Safe to act on. +// undocumented ${String(counts.undocumented ?? 0).padStart(3)} the name is real; the meaning is NOT KNOWN. Shipped +// without a description on purpose — a plausible guess is +// worse than a blank, because someone will act on it. +// internal ${String(counts.internal ?? 0).padStart(3)} test hooks, profiling switches, third-party cloud +// auth, and unreleased feature codenames. Present for +// completeness; not knobs. +// +// Regenerate against a newer Claude Code and diff: +// node scripts/extract-claude-env.mjs > src/adapters/agents/claude-code/env-catalog.ts + +/** How much is actually known about an entry. */ +export type ClaudeEnvKind = 'documented' | 'undocumented' | 'internal' + +export type ClaudeEnvVar = { + name: string + kind: ClaudeEnvKind + /** why it was classified internal; absent otherwise */ + why?: string + category?: string + /** hand-written, never extracted; absent for undocumented and internal */ + description?: string + /** swisscode's own adapter sets this one from a profile */ + managed?: boolean +} + +/** The Claude Code build this was extracted from. */ +export const CATALOG_SOURCE = ${JSON.stringify({ agent: 'claude-code', version })} as const + +export const CLAUDE_ENV_CATALOG: readonly ClaudeEnvVar[] = Object.freeze(${JSON.stringify(entries, null, 2)}) +`) diff --git a/src/adapters/agents/claude-code/env-catalog.ts b/src/adapters/agents/claude-code/env-catalog.ts new file mode 100644 index 0000000..432cddc --- /dev/null +++ b/src/adapters/agents/claude-code/env-catalog.ts @@ -0,0 +1,2254 @@ +// GENERATED by scripts/extract-claude-env.mjs — do not edit by hand. +// +// Every environment variable referenced by Claude Code 2.1.217, extracted +// from the shipped binary, plus the ones swisscode's own adapter sets. +// +// READ THE 'kind' FIELD BEFORE TRUSTING AN ENTRY. Extraction yields NAMES AND +// NOTHING ELSE — no meaning, no defaults, no accepted values, and no evidence +// that a name is still wired to anything. So: +// +// documented 60 described by hand from Anthropic's docs, `claude --help`, +// or swisscode's own adapter. Safe to act on. +// undocumented 338 the name is real; the meaning is NOT KNOWN. Shipped +// without a description on purpose — a plausible guess is +// worse than a blank, because someone will act on it. +// internal 97 test hooks, profiling switches, third-party cloud +// auth, and unreleased feature codenames. Present for +// completeness; not knobs. +// +// Regenerate against a newer Claude Code and diff: +// node scripts/extract-claude-env.mjs > src/adapters/agents/claude-code/env-catalog.ts + +/** How much is actually known about an entry. */ +export type ClaudeEnvKind = 'documented' | 'undocumented' | 'internal' + +export type ClaudeEnvVar = { + name: string + kind: ClaudeEnvKind + /** why it was classified internal; absent otherwise */ + why?: string + category?: string + /** hand-written, never extracted; absent for undocumented and internal */ + description?: string + /** swisscode's own adapter sets this one from a profile */ + managed?: boolean +} + +/** The Claude Code build this was extracted from. */ +export const CATALOG_SOURCE = {"agent":"claude-code","version":"2.1.217"} as const + +export const CLAUDE_ENV_CATALOG: readonly ClaudeEnvVar[] = Object.freeze([ + { + "name": "ANTHROPIC_API_KEY", + "kind": "documented", + "category": "credential", + "description": "The API key Claude Code authenticates with. swisscode sets this per profile; it is cleared for any launch not going to first-party Anthropic.", + "managed": true + }, + { + "name": "ANTHROPIC_AUTH_TOKEN", + "kind": "documented", + "category": "credential", + "description": "Bearer-token form of the credential, used by most gateways. swisscode picks this or ANTHROPIC_API_KEY based on the provider descriptor.", + "managed": true + }, + { + "name": "ANTHROPIC_AWS_API_KEY", + "kind": "internal", + "why": "third-party cloud provider" + }, + { + "name": "ANTHROPIC_AWS_BASE_URL", + "kind": "internal", + "why": "third-party cloud provider" + }, + { + "name": "ANTHROPIC_AWS_WORKSPACE_ID", + "kind": "internal", + "why": "third-party cloud provider" + }, + { + "name": "ANTHROPIC_BASE_URL", + "kind": "documented", + "category": "endpoint", + "description": "The Anthropic-compatible endpoint to talk to. swisscode sets this from the profile’s provider; a bare host, with no /v1.", + "managed": true + }, + { + "name": "ANTHROPIC_BEDROCK_BASE_URL", + "kind": "internal", + "why": "third-party cloud provider" + }, + { + "name": "ANTHROPIC_BEDROCK_MANTLE_BASE_URL", + "kind": "internal", + "why": "third-party cloud provider" + }, + { + "name": "ANTHROPIC_BEDROCK_SERVICE_TIER", + "kind": "internal", + "why": "third-party cloud provider" + }, + { + "name": "ANTHROPIC_BETAS", + "kind": "documented", + "category": "endpoint", + "description": "Beta headers to include in API requests. API-key users only." + }, + { + "name": "ANTHROPIC_CONFIG_DIR", + "kind": "undocumented" + }, + { + "name": "ANTHROPIC_CUSTOM_HEADERS", + "kind": "documented", + "category": "endpoint", + "description": "Extra HTTP headers on every API request, as newline-separated Name: Value pairs." + }, + { + "name": "ANTHROPIC_CUSTOM_MODEL_OPTION", + "kind": "undocumented" + }, + { + "name": "ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION", + "kind": "undocumented" + }, + { + "name": "ANTHROPIC_CUSTOM_MODEL_OPTION_NAME", + "kind": "undocumented" + }, + { + "name": "ANTHROPIC_CUSTOM_MODEL_OPTION_SUPPORTED_CAPABILITIES", + "kind": "undocumented" + }, + { + "name": "ANTHROPIC_DEFAULT_FABLE_MODEL", + "kind": "documented", + "category": "model", + "description": "Model id for the fable tier.", + "managed": true + }, + { + "name": "ANTHROPIC_DEFAULT_FABLE_MODEL_DESCRIPTION", + "kind": "undocumented" + }, + { + "name": "ANTHROPIC_DEFAULT_FABLE_MODEL_NAME", + "kind": "undocumented" + }, + { + "name": "ANTHROPIC_DEFAULT_FABLE_MODEL_SUPPORTED_CAPABILITIES", + "kind": "undocumented" + }, + { + "name": "ANTHROPIC_DEFAULT_HAIKU_MODEL", + "kind": "documented", + "category": "model", + "description": "Model id for the haiku tier, used for background and summarisation work.", + "managed": true + }, + { + "name": "ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION", + "kind": "undocumented" + }, + { + "name": "ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME", + "kind": "undocumented" + }, + { + "name": "ANTHROPIC_DEFAULT_HAIKU_MODEL_SUPPORTED_CAPABILITIES", + "kind": "undocumented" + }, + { + "name": "ANTHROPIC_DEFAULT_OPUS_MODEL", + "kind": "documented", + "category": "model", + "description": "Model id for the opus tier. Accepts a [1m] suffix to request the extended context window.", + "managed": true + }, + { + "name": "ANTHROPIC_DEFAULT_OPUS_MODEL_DESCRIPTION", + "kind": "undocumented" + }, + { + "name": "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME", + "kind": "undocumented" + }, + { + "name": "ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES", + "kind": "undocumented" + }, + { + "name": "ANTHROPIC_DEFAULT_SONNET_MODEL", + "kind": "documented", + "category": "model", + "description": "Model id for the sonnet tier.", + "managed": true + }, + { + "name": "ANTHROPIC_DEFAULT_SONNET_MODEL_DESCRIPTION", + "kind": "undocumented" + }, + { + "name": "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME", + "kind": "undocumented" + }, + { + "name": "ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES", + "kind": "undocumented" + }, + { + "name": "ANTHROPIC_ENVIRONMENT_ID", + "kind": "undocumented" + }, + { + "name": "ANTHROPIC_ENVIRONMENT_KEY", + "kind": "undocumented" + }, + { + "name": "ANTHROPIC_FEDERATION_RULE_ID", + "kind": "undocumented" + }, + { + "name": "ANTHROPIC_FOUNDRY_API_KEY", + "kind": "internal", + "why": "third-party cloud provider" + }, + { + "name": "ANTHROPIC_FOUNDRY_AUTH_TOKEN", + "kind": "internal", + "why": "third-party cloud provider" + }, + { + "name": "ANTHROPIC_FOUNDRY_BASE_URL", + "kind": "internal", + "why": "third-party cloud provider" + }, + { + "name": "ANTHROPIC_FOUNDRY_RESOURCE", + "kind": "internal", + "why": "third-party cloud provider" + }, + { + "name": "ANTHROPIC_GOOGLE_CLOUD_BASE_URL", + "kind": "internal", + "why": "third-party cloud provider" + }, + { + "name": "ANTHROPIC_GOOGLE_CLOUD_LOCATION", + "kind": "internal", + "why": "third-party cloud provider" + }, + { + "name": "ANTHROPIC_GOOGLE_CLOUD_PROJECT", + "kind": "internal", + "why": "third-party cloud provider" + }, + { + "name": "ANTHROPIC_GOOGLE_CLOUD_WORKSPACE_ID", + "kind": "internal", + "why": "third-party cloud provider" + }, + { + "name": "ANTHROPIC_IDENTITY_TOKEN", + "kind": "undocumented" + }, + { + "name": "ANTHROPIC_IDENTITY_TOKEN_FILE", + "kind": "undocumented" + }, + { + "name": "ANTHROPIC_LOG", + "kind": "documented", + "category": "debug", + "description": "Log verbosity for the API client." + }, + { + "name": "ANTHROPIC_MODEL", + "kind": "documented", + "category": "model", + "description": "Overrides the model for the session. Prefer the per-tier ANTHROPIC_DEFAULT_*_MODEL variables, which is what swisscode pins." + }, + { + "name": "ANTHROPIC_ORGANIZATION_ID", + "kind": "undocumented" + }, + { + "name": "ANTHROPIC_PROFILE", + "kind": "undocumented" + }, + { + "name": "ANTHROPIC_SCOPE", + "kind": "undocumented" + }, + { + "name": "ANTHROPIC_SERVICE_ACCOUNT_ID", + "kind": "undocumented" + }, + { + "name": "ANTHROPIC_SESSION_ID", + "kind": "undocumented" + }, + { + "name": "ANTHROPIC_SMALL_FAST_MODEL", + "kind": "documented", + "category": "model", + "description": "The model used for cheap background work. Superseded by ANTHROPIC_DEFAULT_HAIKU_MODEL on current builds." + }, + { + "name": "ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION", + "kind": "undocumented" + }, + { + "name": "ANTHROPIC_UNIX_SOCKET", + "kind": "undocumented" + }, + { + "name": "ANTHROPIC_VERTEX_BASE_URL", + "kind": "internal", + "why": "third-party cloud provider" + }, + { + "name": "ANTHROPIC_VERTEX_PROJECT_ID", + "kind": "internal", + "why": "third-party cloud provider" + }, + { + "name": "ANTHROPIC_WEBHOOK_SIGNING_KEY", + "kind": "undocumented" + }, + { + "name": "ANTHROPIC_WORKSPACE_ID", + "kind": "undocumented" + }, + { + "name": "ANTHROPIC_WORK_ID", + "kind": "undocumented" + }, + { + "name": "API_FORCE_IDLE_TIMEOUT", + "kind": "documented", + "category": "compat", + "description": "Set to 0 to stop the client giving up on slow or locally hosted models.", + "managed": true + }, + { + "name": "CLAUDE_CODE_3P_PROBE_WROTE_OPUS_DEFAULT", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_3P_PROBE_WROTE_SONNET_DEFAULT", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_ACCESSIBILITY", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_ACCOUNT_TAGGED_ID", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_ACCOUNT_UUID", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_ACTION", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_ACT_DONT_REDERIVE", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_ADDITIONAL_PROTECTION", + "kind": "internal", + "why": "unreleased feature codename" + }, + { + "name": "CLAUDE_CODE_AGENT", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_AGENT_PROXY_GH_SHIM", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_AGENT_PROXY_GIT_CONFIG", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_AGENT_RULE_DISABLED", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_AGENT_VIEW_RELAUNCH", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_ALDER_WICKET", + "kind": "internal", + "why": "unreleased feature codename" + }, + { + "name": "CLAUDE_CODE_ALTGR_AS_TEXT", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_ALT_SCREEN_FULL_REPAINT", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_ALWAYS_ENABLE_EFFORT", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_AMBER_ASTROLABE", + "kind": "internal", + "why": "unreleased feature codename" + }, + { + "name": "CLAUDE_CODE_API_BASE_URL", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_API_KEY_HELPER_TTL_MS", + "kind": "documented", + "category": "credential", + "description": "How long a credential from apiKeyHelper is cached." + }, + { + "name": "CLAUDE_CODE_ARTIFACT", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_ARTIFACTS_API_BASE_URL", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_ARTIFACT_AUTO_OPEN", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_ARTIFACT_DIRECT_UPLOAD", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_ARTIFACT_MCP", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_ATTRIBUTION_HEADER", + "kind": "documented", + "category": "traffic", + "description": "Set to 0 to drop the attribution header, which improves prompt-cache hit rate through some gateways.", + "managed": true + }, + { + "name": "CLAUDE_CODE_AUTH_FAIL_EXIT_MS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_AUTO_BACKGROUND_TIMEOUT_MS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_AUTO_COMPACT_WINDOW", + "kind": "documented", + "category": "limits", + "description": "Fraction of the context window at which auto-compaction triggers." + }, + { + "name": "CLAUDE_CODE_AUTO_CONNECT_IDE", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_AUTO_MODE_CLASSIFY_EDITS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_AUTO_MODE_EDIT_REMOVAL", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_AUTO_MODE_EDIT_REMOVAL_CAP", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_AUTO_MODE_EXTERNAL_PERMISSIONS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_AUTO_MODE_GIT_STATUS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_AUTO_MODE_GIT_STATUS_LIMIT", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_AUTO_MODE_GIT_STATUS_UPLOADS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_AUTO_MODE_MODEL", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_AUTO_MODE_OUTCOME_CODES", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_AUTO_MODE_PRIOR_ASSISTANT_CONTEXT", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_AUTO_MODE_REPO_VISIBILITY", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_AUTO_MODE_SIBLING_CONTEXT", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_AUTO_MODE_TEMPERATURE", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_AWS_CHAIN_RESOLVE_TIMEOUT_MS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_BASALT_COVE", + "kind": "internal", + "why": "unreleased feature codename" + }, + { + "name": "CLAUDE_CODE_BASE_REF", + "kind": "internal", + "why": "unreleased feature codename" + }, + { + "name": "CLAUDE_CODE_BASE_REFS", + "kind": "internal", + "why": "unreleased feature codename" + }, + { + "name": "CLAUDE_CODE_BASH_SANDBOX_SHOW_INDICATOR", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_BENCH_LIVE_COUNTS", + "kind": "internal", + "why": "profiling" + }, + { + "name": "CLAUDE_CODE_BG_CLASSIFIER_MODEL", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_BG_TASKS_REPORT_RUNNING", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_BISON_CAIRN", + "kind": "internal", + "why": "unreleased feature codename" + }, + { + "name": "CLAUDE_CODE_BLOCKING_LIMIT_OVERRIDE", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_BRIDGE_SESSION_ID", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_BRIEF", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_BRIEF_UPLOAD", + "kind": "internal", + "why": "unreleased feature codename" + }, + { + "name": "CLAUDE_CODE_BS_AS_CTRL_BACKSPACE", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_BUBBLEWRAP", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_BYOC_ENABLE_DATADOG", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_CERT_STORE", + "kind": "internal", + "why": "unreleased feature codename" + }, + { + "name": "CLAUDE_CODE_CHILD_SESSION", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_CLASSIFIER_SUMMARY", + "kind": "internal", + "why": "unreleased feature codename" + }, + { + "name": "CLAUDE_CODE_CLIENT_CERT", + "kind": "documented", + "category": "network", + "description": "Client certificate for mTLS." + }, + { + "name": "CLAUDE_CODE_CLIENT_KEY", + "kind": "documented", + "category": "network", + "description": "Client key for mTLS." + }, + { + "name": "CLAUDE_CODE_CLIENT_KEY_PASSPHRASE", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_COLD_COMPACT", + "kind": "internal", + "why": "unreleased feature codename" + }, + { + "name": "CLAUDE_CODE_COMMIT_LOG", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_CONTAINER_ID", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_COORDINATOR_EXTRA_TOOLS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_COORDINATOR_MODE", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_COORDINATOR_PROPAGATE_NESTED_MEMORY", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_CUSTOM_OAUTH_URL", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_DAEMON_COLD_START", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_DATADOG_FLUSH_INTERVAL_MS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_DD_ERROR_TRACKING_FLUSH_INTERVAL_MS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_DEBUG_LOGS_DIR", + "kind": "documented", + "category": "debug", + "description": "Where debug logs are written." + }, + { + "name": "CLAUDE_CODE_DEBUG_LOG_LEVEL", + "kind": "documented", + "category": "debug", + "description": "Verbosity of debug logging. Also settable with --debug." + }, + { + "name": "CLAUDE_CODE_DEBUG_REPAINTS", + "kind": "internal", + "why": "profiling" + }, + { + "name": "CLAUDE_CODE_DECSTBM", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_DESIGN_OAUTH_CLIENT_ID", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_DEV_RAW_CHANGELOG_URL", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_DIAGNOSTICS_FILE", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_DISABLE_1M_CONTEXT", + "kind": "documented", + "category": "limits", + "description": "Turns off the 1M context window request." + }, + { + "name": "CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING", + "kind": "documented", + "category": "compat", + "description": "Works around gateways that reject the adaptive thinking input tag with 400.", + "managed": true + }, + { + "name": "CLAUDE_CODE_DISABLE_ADVISOR_TOOL", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_DISABLE_AGENT_VIEW", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_DISABLE_ARTIFACT", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_DISABLE_ATTACHMENTS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_DISABLE_AUTO_MEMORY", + "kind": "documented", + "category": "behaviour", + "description": "Stops automatic memory capture." + }, + { + "name": "CLAUDE_CODE_DISABLE_BACKGROUND_TASKS", + "kind": "documented", + "category": "behaviour", + "description": "Disables background task execution." + }, + { + "name": "CLAUDE_CODE_DISABLE_BEDROCK_CONTENT_TYPE_GUARD", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_DISABLE_BG_EXIT_HANDOFF", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_DISABLE_BG_SHELL_PRESSURE_REAP", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_DISABLE_BUNDLED_SKILLS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_DISABLE_CLAUDE_API_SKILL", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_DISABLE_CLAUDE_CODE_SKILL", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_DISABLE_CLAUDE_MDS", + "kind": "documented", + "category": "behaviour", + "description": "Stops CLAUDE.md files being discovered and loaded." + }, + { + "name": "CLAUDE_CODE_DISABLE_CRON", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS", + "kind": "documented", + "category": "compat", + "description": "Works around gateways that reject unknown beta fields with \"Extra inputs are not permitted\".", + "managed": true + }, + { + "name": "CLAUDE_CODE_DISABLE_EXPLORE_INHERIT_CAP", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_DISABLE_EXPLORE_PLAN_AGENTS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_DISABLE_FAST_MODE", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_DISABLE_FEEDBACK_SURVEY", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_DISABLE_LAUNCH_COMPOSER", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_DISABLE_LEGACY_MODEL_REMAP", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_DISABLE_MEMORY_BULK_INFLATE", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_DISABLE_MEMORY_PERIODIC_RESYNC", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_DISABLE_MEMORY_STREAM_LIST", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_DISABLE_MOUSE", + "kind": "documented", + "category": "ui", + "description": "Disables mouse reporting, which some terminals and multiplexers handle badly." + }, + { + "name": "CLAUDE_CODE_DISABLE_MOUSE_CLICKS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_DISABLE_NESTED_CHAIN_IDLE", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", + "kind": "documented", + "category": "traffic", + "description": "Stops background requests an endpoint may not serve. ALSO disables gateway model discovery, so every tier must be pinned explicitly.", + "managed": true + }, + { + "name": "CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_DISABLE_NOTIFICATION_PRESENCE_CHECK", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_DISABLE_ORG_MEMORY", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_DISABLE_POLICY_SKILLS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_DISABLE_PRECOMPACT_SKIP", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_DISABLE_REFUSAL_FALLBACK", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_DISABLE_TERMINAL_TITLE", + "kind": "documented", + "category": "ui", + "description": "Stops Claude Code rewriting the terminal window title." + }, + { + "name": "CLAUDE_CODE_DISABLE_THINKING", + "kind": "documented", + "category": "behaviour", + "description": "Disables extended thinking." + }, + { + "name": "CLAUDE_CODE_DISABLE_VIRTUAL_SCROLL", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_DISABLE_WORKFLOWS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_DISABLE_WORKING_SYNC", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_DONT_INHERIT_ENV", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_DOWNLOAD_DEADLINE_MS_FOR_TESTING", + "kind": "internal", + "why": "test hook" + }, + { + "name": "CLAUDE_CODE_EAGER_FLUSH", + "kind": "internal", + "why": "unreleased feature codename" + }, + { + "name": "CLAUDE_CODE_EFFORT_LEVEL", + "kind": "documented", + "category": "behaviour", + "description": "Effort level for the session: low, medium, high, xhigh, max. Also settable with --effort." + }, + { + "name": "CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_EMIT_TOOL_USE_SUMMARIES", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_ENABLE_APPEND_SUBAGENT_PROMPT", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_ENABLE_AUTO_MODE", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_ENABLE_AWAY_SUMMARY", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_ENABLE_BACKGROUND_PLUGIN_REFRESH", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_ENABLE_CFC", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_ENABLE_DESIGN_SYNC", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_ENABLE_EXPERIMENTAL_ADVISOR_TOOL", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_ENABLE_FEEDBACK_SURVEY_FOR_OTEL", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_ENABLE_FINE_GRAINED_TOOL_STREAMING", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY", + "kind": "documented", + "category": "traffic", + "description": "Lets Claude Code ask a gateway which models it serves. Disabled as a side effect of CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC." + }, + { + "name": "CLAUDE_CODE_ENABLE_LAUNCH_COMPOSER", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_ENABLE_MENU_KIND_LANES", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_ENABLE_PROXY_AUTH_HELPER", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_ENABLE_REFRESH_MCP_TOOLS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_ENABLE_REMOTE_RECAP", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_ENABLE_TASKS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_ENABLE_TELEMETRY", + "kind": "documented", + "category": "telemetry", + "description": "Enables OpenTelemetry export." + }, + { + "name": "CLAUDE_CODE_ENABLE_TOKEN_USAGE_ATTACHMENT", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_ENABLE_XAA", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_ENHANCED_TELEMETRY_BETA", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_ENTRYPOINT", + "kind": "documented", + "category": "environment", + "description": "How Claude Code was started. swisscode does not set this; the agent uses it for its own telemetry." + }, + { + "name": "CLAUDE_CODE_ENVIRONMENT_KIND", + "kind": "internal", + "why": "unreleased feature codename" + }, + { + "name": "CLAUDE_CODE_ENVIRONMENT_RUNNER_VERSION", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_EXECPATH", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_EXIT_AFTER_FIRST_RENDER", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_EXIT_AFTER_STOP_DELAY", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_EXPERIMENTAL_OBSERVER_AGENTS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_EXTRA_BODY", + "kind": "internal", + "why": "unreleased feature codename" + }, + { + "name": "CLAUDE_CODE_EXTRA_METADATA", + "kind": "internal", + "why": "unreleased feature codename" + }, + { + "name": "CLAUDE_CODE_FABLE_BRIDGE_DIALOG_TIMEOUT_MS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_FILE_READ_MAX_OUTPUT_TOKENS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_FLEETVIEW_SIMPLE", + "kind": "internal", + "why": "unreleased feature codename" + }, + { + "name": "CLAUDE_CODE_FLEET_PAST_SESSIONS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_FORCE_BRIDGE", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_FORCE_EVALUATE_MEMORY", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_FORCE_FULLSCREEN_UPSELL", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_FORCE_FULL_LOGO", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_FORCE_MEMORY_SURVEY", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_FORCE_MID_CONVERSATION_SYSTEM", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_FORCE_RC_LONG_TURN_NUDGE", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_FORCE_SANDBOX", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_FORCE_SESSION_PERSISTENCE", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_FORCE_STRIKETHROUGH", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_FORCE_SYNC_OUTPUT", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_FORCE_TIP_ID", + "kind": "internal", + "why": "test hook" + }, + { + "name": "CLAUDE_CODE_FORCE_WINDOWS_CREDMAN", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_FORK_SUBAGENT", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_FORWARD_SUBAGENT_TEXT", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_FRAME_TIMING_LOG", + "kind": "internal", + "why": "profiling" + }, + { + "name": "CLAUDE_CODE_FRAME_TIMING_SAMPLE_EVERY", + "kind": "internal", + "why": "profiling" + }, + { + "name": "CLAUDE_CODE_GAULT_KESTREL", + "kind": "internal", + "why": "unreleased feature codename" + }, + { + "name": "CLAUDE_CODE_GB_BASE_URL", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_GB_DISK_CACHE_WHEN_TELEMETRY_OFF", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_GB_REFRESH_INTERVAL_MS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_GIT_BASH_PATH", + "kind": "documented", + "category": "environment", + "description": "Path to Git Bash on Windows." + }, + { + "name": "CLAUDE_CODE_GLOB_HIDDEN", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_GLOB_NO_IGNORE", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_GLOB_TIMEOUT_SECONDS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_GZIP_REQUEST_BODIES", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_HERON_TALLOW", + "kind": "internal", + "why": "unreleased feature codename" + }, + { + "name": "CLAUDE_CODE_HFI_BEARER_TOKEN", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_HIDE_CWD", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_HIDE_SETTINGS_HINT", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_HOST_AUTH_ENV_VAR", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_HOST_AUTH_REFRESH_TIMEOUT_MS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_HOST_CREDS_FILE", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_HOST_HTTP_PROXY_PORT", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_HOST_PLATFORM", + "kind": "internal", + "why": "unreleased feature codename" + }, + { + "name": "CLAUDE_CODE_HOST_SOCKS_PROXY_PORT", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_HTTPS_PROXY", + "kind": "documented", + "category": "network", + "description": "HTTPS proxy for Claude Code’s own requests." + }, + { + "name": "CLAUDE_CODE_HTTP_PROXY", + "kind": "documented", + "category": "network", + "description": "HTTP proxy for Claude Code’s own requests." + }, + { + "name": "CLAUDE_CODE_IDE_HOST_OVERRIDE", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_IDE_SKIP_VALID_CHECK", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_IDLE_THRESHOLD_MINUTES", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_IDLE_TOKEN_THRESHOLD", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_INCLUDE_PARTIAL_MESSAGES", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_INVESTIGATE_FIRST", + "kind": "internal", + "why": "unreleased feature codename" + }, + { + "name": "CLAUDE_CODE_INVOKED_SKILLS", + "kind": "internal", + "why": "unreleased feature codename" + }, + { + "name": "CLAUDE_CODE_IS_COWORK", + "kind": "internal", + "why": "unreleased feature codename" + }, + { + "name": "CLAUDE_CODE_JSONL_TRANSCRIPT", + "kind": "internal", + "why": "unreleased feature codename" + }, + { + "name": "CLAUDE_CODE_KB_COHESION_FIXES", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_LANTERN_PRISM", + "kind": "internal", + "why": "unreleased feature codename" + }, + { + "name": "CLAUDE_CODE_LARCH_CISTERN", + "kind": "internal", + "why": "unreleased feature codename" + }, + { + "name": "CLAUDE_CODE_LOOP_KEEPALIVE", + "kind": "internal", + "why": "unreleased feature codename" + }, + { + "name": "CLAUDE_CODE_LOOP_PERSISTENT", + "kind": "internal", + "why": "unreleased feature codename" + }, + { + "name": "CLAUDE_CODE_MANAGED_SETTINGS_PATH", + "kind": "documented", + "category": "environment", + "description": "Path to admin-managed policy settings." + }, + { + "name": "CLAUDE_CODE_MARL_CORMORANT", + "kind": "internal", + "why": "unreleased feature codename" + }, + { + "name": "CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS", + "kind": "documented", + "category": "limits", + "description": "How many subagents may run at once." + }, + { + "name": "CLAUDE_CODE_MAX_CONTEXT_TOKENS", + "kind": "documented", + "category": "limits", + "description": "Ceiling on the context window Claude Code will fill before compacting." + }, + { + "name": "CLAUDE_CODE_MAX_OUTPUT_TOKENS", + "kind": "documented", + "category": "limits", + "description": "Ceiling on tokens the model may produce in one response." + }, + { + "name": "CLAUDE_CODE_MAX_RETRIES", + "kind": "documented", + "category": "limits", + "description": "How many times a failed API request is retried." + }, + { + "name": "CLAUDE_CODE_MAX_SUBAGENTS_PER_SESSION", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY", + "kind": "documented", + "category": "limits", + "description": "How many tool calls may run in parallel within one turn." + }, + { + "name": "CLAUDE_CODE_MAX_TURNS", + "kind": "documented", + "category": "limits", + "description": "Maximum agent turns before the session stops on its own." + }, + { + "name": "CLAUDE_CODE_MAX_WEB_SEARCHES_PER_SESSION", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_MCP_ALLOWLIST_ENV", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_MCP_SERVER_NAME", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_MCP_SERVER_URL", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_MEMORY_PUSH_DELETE_MODE", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_MOCK_REMOTE_SETTINGS", + "kind": "internal", + "why": "test hook" + }, + { + "name": "CLAUDE_CODE_MOCK_TRIAL", + "kind": "internal", + "why": "test hook" + }, + { + "name": "CLAUDE_CODE_NANKEEN_KESTREL", + "kind": "internal", + "why": "unreleased feature codename" + }, + { + "name": "CLAUDE_CODE_NATIVE_CURSOR", + "kind": "internal", + "why": "unreleased feature codename" + }, + { + "name": "CLAUDE_CODE_NEW_INIT", + "kind": "internal", + "why": "unreleased feature codename" + }, + { + "name": "CLAUDE_CODE_NO_FLICKER", + "kind": "internal", + "why": "unreleased feature codename" + }, + { + "name": "CLAUDE_CODE_NO_MODEL_FALLBACK", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_OAUTH_401_WAIT_MS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_OAUTH_CLIENT_ID", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_OAUTH_REFRESH_TOKEN", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_OAUTH_SCOPES", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_OAUTH_TOKEN", + "kind": "documented", + "category": "credential", + "description": "OAuth access token. Written by /login; swisscode reads it only to measure subscription usage, and never refreshes it." + }, + { + "name": "CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_ORGANIZATION_UUID", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_OTEL_CONTENT_MAX_LENGTH", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_OTEL_DIAG_STDERR", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_OTEL_FLUSH_TIMEOUT_MS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_OTEL_HEADERS_HELPER_DEBOUNCE_MS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_OTEL_SHUTDOWN_TIMEOUT_MS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_OVERRIDE_DATE", + "kind": "internal", + "why": "test hook" + }, + { + "name": "CLAUDE_CODE_PACKAGE_MANAGER_AUTO_UPDATE", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_PARKED_PERMISSION_WAIT_MS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_PERFETTO_TRACE", + "kind": "internal", + "why": "profiling" + }, + { + "name": "CLAUDE_CODE_PERFETTO_WRITE_INTERVAL_S", + "kind": "internal", + "why": "profiling" + }, + { + "name": "CLAUDE_CODE_PERFORCE_MODE", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_PEWTER_OWL", + "kind": "internal", + "why": "unreleased feature codename" + }, + { + "name": "CLAUDE_CODE_PEWTER_OWL_TOOL", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_PLAN_MODE_REQUIRED", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_PLAN_V2_AGENT_COUNT", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_PLAN_V2_EXPLORE_AGENT_COUNT", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_PLUGIN_BINARY_ASSETS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_PLUGIN_CACHE_DIR", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_PLUGIN_GIT_TIMEOUT_MS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_PLUGIN_KEEP_MARKETPLACE_ON_FAILURE", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_PLUGIN_PREFER_HTTPS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_PLUGIN_SEED_DIR", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_PLUGIN_USE_ZIP_CACHE", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_POST_FOR_SESSION_INGRESS_V2", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_POWERSHELL_RESPECT_EXECUTION_POLICY", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_POWERUP_ONBOARDING", + "kind": "internal", + "why": "unreleased feature codename" + }, + { + "name": "CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_PROACTIVE", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_PROCESS_WRAPPER", + "kind": "internal", + "why": "unreleased feature codename" + }, + { + "name": "CLAUDE_CODE_PROFILE_QUERY", + "kind": "internal", + "why": "profiling" + }, + { + "name": "CLAUDE_CODE_PROFILE_STARTUP", + "kind": "internal", + "why": "profiling" + }, + { + "name": "CLAUDE_CODE_PROPAGATE_TRACEPARENT", + "kind": "internal", + "why": "unreleased feature codename" + }, + { + "name": "CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_PROXY_AUTHENTICATE", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_PROXY_AUTH_HELPER_TTL_MS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_PROXY_HOST", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_PROXY_RESOLVES_HOSTS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_PROXY_URL", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_PWSH_PARSE_TIMEOUT_MS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_QUESTION_PREVIEW_FORMAT", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_RATE_LIMIT_TIER", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_RC_PERMISSION_NUDGE", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_REFUSAL_FALLBACK_CATCH_ALL", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_RELAUNCH_TERMINAL_SIZE", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_REMOTE", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_REMOTE_ENVIRONMENT_TYPE", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_REMOTE_HERMETIC_MODE", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_REMOTE_MEMORY_DIR", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_REMOTE_RAW_EVENTS_FILE", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_REMOTE_SEND_KEEPALIVES", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_REMOTE_SESSION_ID", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_REMOTE_SESSION_ORIGIN", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_REMOTE_SETTINGS_PATH", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_REMOTE_SETTINGS_POLL_MS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_REPL", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_REPORT_FINDINGS", + "kind": "internal", + "why": "unreleased feature codename" + }, + { + "name": "CLAUDE_CODE_REPO_CHECKOUTS", + "kind": "internal", + "why": "unreleased feature codename" + }, + { + "name": "CLAUDE_CODE_RESUME_FROM_SESSION", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_RESUME_INTERRUPTED_TURN", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_RESUME_INTERRUPTED_TURN_MAX_AGE_MS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_RESUME_PROMPT", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_RESUME_SOURCE_ALIVE", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_RESUME_THRESHOLD_MINUTES", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_RESUME_TOKEN_THRESHOLD", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_RETRY_WATCHDOG", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_SAFE_MODE", + "kind": "documented", + "category": "behaviour", + "description": "Set by --safe-mode. Disables customisations — CLAUDE.md, skills, plugins, hooks, MCP servers — for troubleshooting a broken configuration." + }, + { + "name": "CLAUDE_CODE_SANDBOXED", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_SCRIPT_CAPS", + "kind": "internal", + "why": "unreleased feature codename" + }, + { + "name": "CLAUDE_CODE_SCROLL_SPEED", + "kind": "internal", + "why": "unreleased feature codename" + }, + { + "name": "CLAUDE_CODE_SDK_HAS_HOST_AUTH_REFRESH", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_SDK_HAS_OAUTH_REFRESH", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_SEND_FEEDBACK", + "kind": "internal", + "why": "unreleased feature codename" + }, + { + "name": "CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_SESSION_ACCESS_TOKEN", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_SESSION_ID", + "kind": "documented", + "category": "session", + "description": "The current session id. Also settable with --session-id." + }, + { + "name": "CLAUDE_CODE_SESSION_KIND", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_SESSION_LOG", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_SESSION_NAME", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_SHELL", + "kind": "documented", + "category": "environment", + "description": "Which shell the Bash tool uses." + }, + { + "name": "CLAUDE_CODE_SHELL_PREFIX", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_SIMPLE", + "kind": "documented", + "category": "behaviour", + "description": "Set by --bare. Skips hooks, LSP, plugin sync, auto-memory, keychain reads and CLAUDE.md discovery." + }, + { + "name": "CLAUDE_CODE_SIMPLE_SYSTEM_PROMPT", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_SIMULATE_PROXY_USAGE", + "kind": "internal", + "why": "test hook" + }, + { + "name": "CLAUDE_CODE_SKILL_NAME", + "kind": "internal", + "why": "unreleased feature codename" + }, + { + "name": "CLAUDE_CODE_SKIP_ANTHROPIC_AWS_AUTH", + "kind": "internal", + "why": "third-party cloud provider" + }, + { + "name": "CLAUDE_CODE_SKIP_ANTHROPIC_GOOGLE_CLOUD_AUTH", + "kind": "internal", + "why": "third-party cloud provider" + }, + { + "name": "CLAUDE_CODE_SKIP_AWS_CRED_CACHE", + "kind": "internal", + "why": "third-party cloud provider" + }, + { + "name": "CLAUDE_CODE_SKIP_BEDROCK_AUTH", + "kind": "internal", + "why": "third-party cloud provider" + }, + { + "name": "CLAUDE_CODE_SKIP_FAST_MODE_NETWORK_ERRORS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_SKIP_FAST_MODE_ORG_CHECK", + "kind": "documented", + "category": "compat", + "description": "Skips the organisation check that reports fast mode as disabled on non-first-party endpoints.", + "managed": true + }, + { + "name": "CLAUDE_CODE_SKIP_FOUNDRY_AUTH", + "kind": "internal", + "why": "third-party cloud provider" + }, + { + "name": "CLAUDE_CODE_SKIP_HFI_VERSION_CHECK", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_SKIP_MANTLE_AUTH", + "kind": "internal", + "why": "third-party cloud provider" + }, + { + "name": "CLAUDE_CODE_SKIP_PLUGIN_MCP_SERVERS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_SKIP_PLUGIN_MCP_SERVERS_EXCEPT", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_SKIP_PROJECT_BACKFILL", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_SKIP_PROMPT_HISTORY", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_SKIP_REPO_UPLOAD", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_SKIP_VERTEX_AUTH", + "kind": "internal", + "why": "third-party cloud provider" + }, + { + "name": "CLAUDE_CODE_SLOW_OPERATION_THRESHOLD_MS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_SPAWN_TIMESTAMP_MS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_SSE_PORT", + "kind": "internal", + "why": "unreleased feature codename" + }, + { + "name": "CLAUDE_CODE_STALL_TIMEOUT_MS_FOR_TESTING", + "kind": "internal", + "why": "test hook" + }, + { + "name": "CLAUDE_CODE_STOP_HOOK_BLOCK_CAP", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_SUBAGENT_CACHE_EVICT", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_SUBAGENT_MODEL", + "kind": "documented", + "category": "model", + "description": "Model subagents run on. Pinning it matters on gateways: subagents 404 when the id is not one the endpoint serves. swisscode sets this for OpenRouter.", + "managed": true + }, + { + "name": "CLAUDE_CODE_SUBPROCESS_ENV_SCRUB", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_SUBSCRIPTION_TYPE", + "kind": "documented", + "category": "credential", + "description": "The plan behind the current login, e.g. max." + }, + { + "name": "CLAUDE_CODE_SUPERVISED", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_SUPPRESS_SESSION_ATTRIBUTION", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_SYNC_PLUGINS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_SYNC_PLUGINS_BUFFERED_DOWNLOAD", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_SYNC_PLUGINS_DOWNLOAD_STALL_MS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_SYNC_PLUGINS_INSTALL_TIMEOUT_MS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_SYNC_PLUGINS_MCP_TIMEOUT_MS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_SYNC_PLUGIN_INSTALL", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_SYNC_PLUGIN_INSTALL_TIMEOUT_MS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_SYNC_SESSION_REFS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_SYNC_SKILLS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_SYNC_SKILLS_INSTALL_TIMEOUT_MS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_SYNC_SKILLS_WAIT_TIMEOUT_MS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_SYNTAX_HIGHLIGHT", + "kind": "internal", + "why": "unreleased feature codename" + }, + { + "name": "CLAUDE_CODE_SYSTEM_PROMPT_GB_FEATURE", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_TAGS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_TAG_ISMETA_MESSAGES", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_TASK_LIST_ID", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_TEAMMATE_COMMAND", + "kind": "internal", + "why": "unreleased feature codename" + }, + { + "name": "CLAUDE_CODE_TEAM_TEARDOWN_PARK_TIMEOUT_MS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_TEE_SDK_STDOUT", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_TERMINAL_MCP_TOOLS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_TERMINAL_RECORDING", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_TEST_FIXTURES_ROOT", + "kind": "internal", + "why": "test hook" + }, + { + "name": "CLAUDE_CODE_TEST_FORCE_DENY", + "kind": "internal", + "why": "test hook" + }, + { + "name": "CLAUDE_CODE_TEST_NO_GIT_BASH", + "kind": "internal", + "why": "test hook" + }, + { + "name": "CLAUDE_CODE_TEST_NO_PWSH", + "kind": "internal", + "why": "test hook" + }, + { + "name": "CLAUDE_CODE_THISTLE_GREBE", + "kind": "internal", + "why": "unreleased feature codename" + }, + { + "name": "CLAUDE_CODE_TMPDIR", + "kind": "documented", + "category": "environment", + "description": "Temporary directory Claude Code writes to." + }, + { + "name": "CLAUDE_CODE_TMUX_PREFIX", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_TMUX_PREFIX_CONFLICTS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_TMUX_SESSION", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_TMUX_TRUECOLOR", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_TODO_REMINDER_MODE", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_TOTAL_TOKENS_REMINDER", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_TOTAL_TOKENS_REMINDER_AFTER_USER_TURN", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_TOTAL_TOKENS_REMINDER_BUDGET", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_TRANSCRIPT_LOCAL_GC", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_TRIGGER_ID", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_TUI_JUST_SWITCHED", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_TWO_STAGE_CLASSIFIER", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_ULTRAREVIEW_PREFLIGHT_FIXTURE", + "kind": "internal", + "why": "test hook" + }, + { + "name": "CLAUDE_CODE_USER_DIALOG_TIMEOUT_MS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_USER_EMAIL", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_USE_ANTHROPIC_AWS", + "kind": "internal", + "why": "third-party cloud provider" + }, + { + "name": "CLAUDE_CODE_USE_ANTHROPIC_GOOGLE_CLOUD", + "kind": "internal", + "why": "third-party cloud provider" + }, + { + "name": "CLAUDE_CODE_USE_BEDROCK", + "kind": "internal", + "why": "third-party cloud provider" + }, + { + "name": "CLAUDE_CODE_USE_COWORK_PLUGINS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_USE_FOUNDRY", + "kind": "internal", + "why": "third-party cloud provider" + }, + { + "name": "CLAUDE_CODE_USE_GATEWAY", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_USE_MANTLE", + "kind": "internal", + "why": "third-party cloud provider" + }, + { + "name": "CLAUDE_CODE_USE_NATIVE_FILE_SEARCH", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_USE_POWERSHELL_TOOL", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_USE_VERTEX", + "kind": "internal", + "why": "third-party cloud provider" + }, + { + "name": "CLAUDE_CODE_VERSION", + "kind": "documented", + "category": "environment", + "description": "The running Claude Code version." + }, + { + "name": "CLAUDE_CODE_VOICE_FORWARD_INTERIMS_TYPED", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_WALNUT_SPIRE", + "kind": "internal", + "why": "unreleased feature codename" + }, + { + "name": "CLAUDE_CODE_WEBFETCH_USE_CCR_PROXY", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_WEBSEARCH_USE_CCR_PROXY", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_WEBSOCKET_AUTH_FILE_DESCRIPTOR", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_WORKER_EPOCH", + "kind": "internal", + "why": "unreleased feature codename" + }, + { + "name": "CLAUDE_CODE_WORKFLOWS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_WORKFLOW_SIZE_WARNING_AGENTS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_WORKFLOW_SIZE_WARNING_TOKENS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CODE_WORKSPACE_HOST_PATHS", + "kind": "undocumented" + }, + { + "name": "CLAUDE_CONFIG_DIR", + "kind": "documented", + "category": "session", + "description": "Which directory holds the login and settings. THE BRANCH IS ON WHETHER THIS IS SET, not on its value — setting it to the default ~/.claude is a different, empty login.", + "managed": true + }, + { + "name": "DISABLE_AUTOUPDATER", + "kind": "documented", + "category": "behaviour", + "description": "Stops Claude Code updating itself." + }, + { + "name": "DISABLE_ERROR_REPORTING", + "kind": "documented", + "category": "telemetry", + "description": "Disables error reporting." + }, + { + "name": "DISABLE_TELEMETRY", + "kind": "documented", + "category": "telemetry", + "description": "Disables telemetry reporting." + }, + { + "name": "ENABLE_TOOL_SEARCH", + "kind": "documented", + "category": "compat", + "description": "Enables MCP tool search, which is off by default away from first-party Anthropic.", + "managed": true + }, + { + "name": "MAX_THINKING_TOKENS", + "kind": "documented", + "category": "limits", + "description": "Ceiling on tokens spent on extended thinking." + } +]) diff --git a/src/adapters/ui/ModelPicker.tsx b/src/adapters/ui/ModelPicker.tsx index 08e0041..dae9bf1 100644 --- a/src/adapters/ui/ModelPicker.tsx +++ b/src/adapters/ui/ModelPicker.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from 'react' import { Box, Text, useInput } from 'ink' import { filterModels } from '../../core/catalog.ts' import { formatContext, formatPrice } from '../../core/format.ts' +import { tone } from './theme.tsx' import type { Tier } from '../../ports/provider.ts' import type { CatalogCapabilities, @@ -19,12 +20,16 @@ const LEFT_WIDTH = 40 * publish tool support" can never be rendered as a confirmed "no". */ function Badge({ state, children }: { state: boolean | null; children: ReactNode }) { - // Tri-state. "unknown" must not look like "no" and must not look like "yes". + // Tri-state. "unknown" must not look like "no" and must not look like "yes", + // so it gets its own ROLE as well as its own glyph: `?` in warn against a `✓` + // in ok and a dim `·`. The glyph used to carry that distinction alone, which + // meant a catalog publishing nothing looked identical to one publishing a no + // to anybody reading at a glance. if (state === null || state === undefined) { - return ? {children} + return ? {children} } return ( - + {state ? '✓' : '·'} {children}{' '} ) @@ -37,10 +42,10 @@ function Score({ label, value }: { label: string; value: number | null }) { return ( - {label} + {label} - {'█'.repeat(filled)} - {'░'.repeat(20 - filled)} + {'█'.repeat(filled)} + {'░'.repeat(20 - filled)} {value} ) @@ -60,10 +65,10 @@ function PriceRow({ label, value }: { label: string; value: number }) { return ( - {label} + {label} {formatPrice(value)} - / M tokens + / M tokens ) } @@ -76,14 +81,14 @@ type DetailsProps = { function Details({ model, capabilities }: DetailsProps) { if (!model) { - return No model matches this filter. + return No model matches this filter. } return ( - + {model.name} - + {model.id} @@ -99,16 +104,16 @@ function Details({ model, capabilities }: DetailsProps) { ) : null} ) : ( - pricing not published by this catalog + pricing not published by this catalog )} {model.context ? ( - context + context {formatContext(model.context)} {model.maxOutput ? ( - {` (max out ${formatContext(model.maxOutput)})`} + {` (max out ${formatContext(model.maxOutput)})`} ) : null} ) : null} @@ -119,10 +124,13 @@ function Details({ model, capabilities }: DetailsProps) { reasoning {model.tools === false ? ( - Claude Code needs tool calling — this model will not work. + Claude Code needs tool calling — this model will not work. ) : null} + {/* Warn, not muted: the same reasoning as the `?` badge above. An + unpublished capability is a caveat the user has to act on, and dimming + it filed it with the decoration. */} {model.tools === null ? ( - + tool support is not published here; Claude Code needs it, so try a short prompt before relying on this model. @@ -130,7 +138,7 @@ function Details({ model, capabilities }: DetailsProps) { {capabilities.benchmarks && model.benchmarks ? ( - artificial analysis + artificial analysis @@ -139,7 +147,7 @@ function Details({ model, capabilities }: DetailsProps) { {model.description ? ( - + {model.description.slice(0, 260).trim()} {model.description.length > 260 ? '…' : ''} @@ -251,14 +259,14 @@ export function ModelPicker({ tier, current, catalog, onSelect, onCancel }: Mode }) if (state.loading) { - return Loading models from {catalog.label}… + return Loading models from {catalog.label}… } if (state.models.length === 0) { return ( - Could not reach {catalog.label}: {state.error} - Press esc to go back and type the model id by hand. + Could not reach {catalog.label}: {state.error} + Press esc to go back and type the model id by hand. ) } @@ -281,14 +289,14 @@ export function ModelPicker({ tier, current, catalog, onSelect, onCancel }: Mode return ( - model for - {tier} - · search: + model for + {tier} + · search: {query} - + - + {visible.length}/{state.models.length} shown {toolsOnly && capabilities.toolSupportKnown ? ' · tools only' : ''} {freeOnly && capabilities.pricing ? ' · free only' : ''} @@ -300,17 +308,13 @@ export function ModelPicker({ tier, current, catalog, onSelect, onCancel }: Mode {window.length === 0 ? ( - no matches + no matches ) : ( window.map((m) => { const active = visible[cursor]?.id === m.id return ( - + {active ? '› ' : ' '} {m.id === current ? '● ' : ''} {m.id} @@ -326,7 +330,7 @@ export function ModelPicker({ tier, current, catalog, onSelect, onCancel }: Mode - {hints.join(' · ')} + {hints.join(' · ')} ) diff --git a/src/adapters/ui/ProfilePicker.tsx b/src/adapters/ui/ProfilePicker.tsx index b8f4cb8..a0ff880 100644 --- a/src/adapters/ui/ProfilePicker.tsx +++ b/src/adapters/ui/ProfilePicker.tsx @@ -1,8 +1,8 @@ import React from 'react' import { Box, Text } from 'ink' -import SelectInput from 'ink-select-input' import { TIERS } from '../../core/tiers.ts' import { resolveProfileRefs } from '../../core/resolve.ts' +import { Select, tone } from './theme.tsx' import type { ResolvedProfile, State } from '../../ports/config-store.ts' /** @@ -71,21 +71,21 @@ export function ProfilePicker({ state, onPick, onNew }: ProfilePickerProps) { return ( - Profiles · ★ is the default · enter to open + Profiles · ★ is the default · enter to open {names.map((name) => ( - {name} + {name} - + {summarize(resolvedOrUndefined(state, name), bindingCounts.get(name) ?? 0)} ))} - (item.value === '__new' ? onNew() : onPick(item.value))} /> @@ -148,10 +148,10 @@ export function ProfileActions({ name, state, cwd, onAction }: ProfileActionsPro return ( - Profile {name} + Profile {name} - onAction(item.value)} /> + put('provider', e.target.value)} > @@ -196,113 +275,140 @@ export function Accounts({ data, reload }: { data: Bootstrap; reload: () => Prom - - - - - {draft.configDir ? ( - <> - - put('configDir', e.target.value)} - placeholder="~/.claude" - /> - + + {/* + A choice of MODE, so a segmented control rather than a select: the + two answers are the two shapes an account can have, and a closed + menu that has to be opened to learn what the alternative even is + hides the one decision this panel exists to make. + */} + {/* - THE ONE THING THIS PAGE CANNOT DO. `/login` is an interactive - OAuth flow inside the agent's own TUI; a browser tab cannot - drive it. Pointing an account at an empty directory here is - legal and silently useless until someone logs in there, so the - terminal command that does it is named rather than implied. + The caption is POINTED AT rather than repeated. A `SegmentedControl` + is a `role="group"`, not a form control, so there is no `htmlFor` + that could reach it; an `aria-label` saying the same words is a + second copy that drifts, and it leaves the visible caption + naming nothing. */} - - Logging in happens in a terminal, not here — run{' '} - swisscode config accounts login {editing || ''} and complete{' '} - /login inside the agent. This page only points the account at a - directory. - - {!isNew && data.logins ? ( -
- currently: {data.logins[editing] ?? 'not logged in'} -
- ) : null} - - ) : ( - <> - - put('apiKey', e.target.value)} - placeholder={stored?.hasKey ? '•••••••• stored' : 'paste key'} - /> - - - put('apiKeyFromEnv', e.target.value)} - placeholder="MY_TOKEN" - /> - - - )} + How this account authenticates + put('configDir', id === 'session' ? '~/.claude' : '')} + /> + + A key, or a login the agent already performed. Never both — the server refuses that + rather than picking one. + +
+ + {/* + A plain wrapper, deliberately: `Field` owns its own bottom margin, + so a run of them must NOT become `Stack` children or every gap is + counted twice. The Stack above spaces the two groups, not the + fields inside this one. + */} +
+ {mode === 'session' ? ( + <> + + put('configDir', e.target.value)} + placeholder="~/.claude" + /> + + {/* + THE ONE THING THIS PAGE CANNOT DO. `/login` is an interactive + OAuth flow inside the agent's own TUI; a browser tab cannot + drive it. Pointing an account at an empty directory here is + legal and silently useless until someone logs in there, so the + terminal command that does it is named rather than implied. + */} + + Logging in happens in a terminal, not here — run{' '} + swisscode config accounts login {editing || ''} and complete{' '} + /login inside the agent. This page only points the account at a + directory. + + {!isNew && data.logins ? ( + + currently: {data.logins[editing] ?? 'not logged in'} + + ) : null} + + ) : ( + <> + + put('apiKey', e.target.value)} + placeholder={stored?.hasKey ? '•••••••• stored' : 'paste key'} + /> + + + put('apiKeyFromEnv', e.target.value)} + placeholder="MY_TOKEN" + /> + + + )} +
+
-
+ -
+ ) } return ( <> -
-

Accounts

-
- {/* - Measuring is a BUTTON rather than something the page does on load. - Each session account costs a keychain read — which on macOS can pop - an unlock dialog — plus a request to Anthropic. A screen that did - that every time you opened it would be indefensible. - */} - - -
-
+ + {/* + Measuring is a BUTTON rather than something the page does on load. + Each session account costs a keychain read — which on macOS can pop + an unlock dialog — plus a request to Anthropic. A screen that did + that every time you opened it would be indefensible. + */} + + + + } + /> {error ? {error} : null} {warnings.map((w) => ( @@ -315,79 +421,104 @@ export function Accounts({ data, reload }: { data: Bootstrap; reload: () => Prom in, and its token unexpired, to report a window. ) : null} - {usage?.checkedAt ? ( -
- Measured {new Date(usage.checkedAt).toLocaleTimeString()}. “% left” is the tighter of the two - windows, never their average — an account at 5% of its 5-hour window and 95% of its weekly - one has almost nothing left. Profiles using the usage strategy now select on - these figures. -
- ) : null} - + {/* + What the figures mean belongs to the LIST, not to the page, so it is the + panel's description: it appears directly above the rows it explains and + disappears with them, instead of floating as a stray paragraph the + moment somebody clicks Measure. + */} + + Measured {new Date(usage.checkedAt).toLocaleTimeString()}. “% left” is the tighter of + the two windows, never their average — an account at 5% of its 5-hour window and 95% + of its weekly one has almost nothing left. Profiles using the usage{' '} + strategy now select on these figures. + + ) : undefined + } + > {accounts.length === 0 ? ( No accounts yet. An account is a provider plus the credential that pays for it. ) : ( - accounts.map(([name, a]) => { - // The reverse index: a profile lists its accounts, but only this - // view can say which profiles an account backs — the question you - // have before deleting one or rotating a key. - const usedBy = accountsUsedBy(data.state.profiles, name) - const login = a.configDir ? (data.logins?.[name] ?? null) : null - const measured = usage?.accounts.find((m) => m.name === name) - return ( -
- {/* - A session account is "ready" when someone has LOGGED IN there, - not when a path is set. A directory nobody has logged into - looks identical in config.json and fails only after execve, so - the dot tracks the login rather than the field. - */} - -
-
- {name} - {a.label ? ( - - {a.label} - - ) : null} -
-
- {a.provider} - {' · '} - {credentialLine(a, login)} - {' · '} - {usedBy.length > 0 ? `used by ${usedBy.join(', ')}` : 'unused'} -
- {measured ? ( -
- {measured.mode === 'key' - ? 'key account — no subscription window' - : measured.remaining === null - ? 'could not be measured' - : `${measured.remaining}% left · 5h ${formatWindow(measured.fiveHour)} · 7d ${formatWindow(measured.sevenDay)}`} + + {accounts.map(([name, a]) => { + // The reverse index: a profile lists its accounts, but only this + // view can say which profiles an account backs — the question you + // have before deleting one or rotating a key. + const usedBy = accountsUsedBy(data.state.profiles, name) + const login = a.configDir ? (data.logins?.[name] ?? null) : null + const measured = usage?.accounts.find((m) => m.name === name) + const conflict = credentialSource(a) === 'conflict' + return ( + + } + title={ + + {name} + {a.label ? {a.label} : null} + {conflict ? conflict : null} + + } + meta={ +
+
{a.provider}
+
+ {credentialLine(a, login)} +
+
{usedBy.length > 0 ? `used by ${usedBy.join(', ')}` : 'unused'}
+ {measured ? ( + measured.mode === 'key' ? ( +
key account — no subscription window
+ ) : measured.remaining === null ? ( +
could not be measured
+ ) : ( + <> +
+ {measured.remaining}% left +
+
+ 5h{' '} + {formatWindow(measured.fiveHour)} +
+
+ 7d{' '} + {formatWindow(measured.sevenDay)} +
+ + ) + ) : null}
- ) : null} -
- - -
- ) - }) + } + actions={ + <> + + + + } + /> + ) + })} + )} diff --git a/web/src/routes/AgentProfiles.tsx b/web/src/routes/AgentProfiles.tsx index 54ff3d0..4e411d7 100644 --- a/web/src/routes/AgentProfiles.tsx +++ b/web/src/routes/AgentProfiles.tsx @@ -1,9 +1,65 @@ -import { useState } from 'react' -import { css } from '../../styled-system/css' +import { Fragment, useState } from 'react' +import { css, cva, cx } from '../../styled-system/css' import { ApiError, api, type Bootstrap } from '../api' -import { Banner, Button, Dot, Empty, Field, Panel, inputStyle, monoInput } from '../ui' +import { + Badge, + Banner, + Button, + Checkbox, + Code, + DataList, + DataRow, + Dot, + Empty, + Field, + FormActions, + Inline, + KeyValue, + KeyValueList, + Mono, + Note, + PageHeader, + Panel, + SectionLabel, + Stack, + inputStyle, + labelStyle, + monoInput, + selectStyle, +} from '../ui' import { ModelPicker } from './ModelPicker' +/** + * The four tiers as ONE grid rather than four independent rows. + * + * A `Field` per tier gave every row its own label width and its own input edge, + * so four controls describing one thing lined up on nothing. Sharing the tracks + * is what makes a blank tier visible at a glance, and that is the entire point + * of this panel: Claude Code reads the extended-context marker per variable, so + * the tier nobody filled in is the one that silently runs narrow. + * + * A `cva` rather than a template string chosen at render time, because Panda is + * a build-time extractor: a value it can only learn at runtime emits no CSS at + * all. The third track exists only when there is a catalog to browse — with no + * Browse button the auto-placement would wrap the next tier's label into the + * empty column and undo the alignment this grid is for. + */ +const modelGrid = cva({ + base: { display: 'grid', alignItems: 'center', columnGap: '3', rowGap: '2' }, + variants: { + browsable: { + true: { gridTemplateColumns: '[auto minmax(0, 1fr) auto]' }, + false: { gridTemplateColumns: '[auto minmax(0, 1fr)]' }, + }, + }, + defaultVariants: { browsable: false }, +}) + +// The same type as a `Field` label, because that is what it is — so it names the +// shared class rather than restating it. What differs is that it sits in a +// shared column instead of owning its own row, and that it is clickable. +const tierLabel = cx(labelStyle, css({ cursor: 'pointer' })) + /** * Agent profiles — what runs. * @@ -80,15 +136,14 @@ export function AgentProfiles({ data, reload }: { data: Bootstrap; reload: () => ? data.state.providerAccounts?.[viaProfile.accounts[0]] : undefined const provider = data.providers.find((p) => p.id === viaAccount?.provider) + const browsable = Boolean(provider?.catalogId) return ( <> -
- -

- {isNew ? 'New agent profile' : `Agent profile · ${editing}`} -

-
+ setEditing(null)} + /> {error ? {error} : null} {users.length > 1 ? ( @@ -116,7 +171,7 @@ export function AgentProfiles({ data, reload }: { data: Bootstrap; reload: () => put('models', { ...models, [tier]: e.target.value })} - placeholder={provider?.defaultModels?.[tier] ?? '—'} - /> - {provider?.catalogId ? ( - - ) : null} - - - ))} - {!provider ? ( -

- No profile uses this setup yet, so there is no provider to browse a catalog from. - Type ids by hand, or attach it to a profile first. -

- ) : null} - - {picking && provider?.catalogId ? ( - setPicking(null)} - onPick={(model) => { - setDraft((d) => ({ - ...d, - models: { ...((d.models as Record) ?? {}), [picking]: model.id }, - // Capture the MEASURED window alongside the id — the only - // moment it is known, and what later lets swisscode set an - // auto-compact window without ever guessing one. - ...(model.context - ? { - contextWindows: { - ...((d.contextWindows as Record) ?? {}), - [model.id]: model.context, - }, - } - : {}), - })) - setPicking(null) - }} - /> - ) : null} + + + {/* The label is not wrapped around its input the way `Field` wraps + one, because the two live in different grid columns — hence the + explicit htmlFor, which buys back the click target. */} +
+ {data.tiers.map((tier) => ( + + + put('models', { ...models, [tier]: e.target.value })} + placeholder={provider?.defaultModels?.[tier] ?? '—'} + /> + {browsable ? : null} + + ))} +
+ {!provider ? ( + + No profile uses this setup yet, so there is no provider to browse a catalog from. + Type ids by hand, or attach it to a profile first. + + ) : null} +
+ {picking && provider?.catalogId ? ( + setPicking(null)} + onPick={(model) => { + setDraft((d) => ({ + ...d, + models: { ...((d.models as Record) ?? {}), [picking]: model.id }, + // Capture the MEASURED window alongside the id — the only + // moment it is known, and what later lets swisscode set an + // auto-compact window without ever guessing one. + ...(model.context + ? { + contextWindows: { + ...((d.contextWindows as Record) ?? {}), + [model.id]: model.context, + }, + } + : {}), + })) + setPicking(null) + }} + /> + ) : null} + - -
- Gateway compatibility -
- {data.compatFlags.map((flag) => ( - - ))} + ))} + +
-
+ -
+ ) } return ( <> -
-

Agent profiles

- -
+ open(null)}> + New agent profile + + } + /> {error ? {error} : null} {warnings.map((w) => ( @@ -247,51 +312,60 @@ export function AgentProfiles({ data, reload }: { data: Bootstrap; reload: () => ))} - + {agentProfiles.length === 0 ? ( - None yet. An agent profile is a coding CLI plus how it should behave. + No agent profiles yet. An agent profile is a coding CLI plus how it should behave. ) : ( - agentProfiles.map(([name, ap]) => { - const users = usersOf(name) - const pinned = data.tiers.filter((t) => ap.models?.[t]).length - return ( -
- 0 ? 'ok' : 'faint'} /> -
-
- {name} - {users.length > 1 ? ( - - shared - - ) : null} -
-
- {ap.agent ?? 'claude-code'} - {' · '} - {pinned > 0 ? `${pinned}/${data.tiers.length} tiers pinned` : 'provider defaults'} - {' · '} - {users.length > 0 ? `used by ${users.join(', ')}` : 'unused'} -
-
- - -
- ) - }) + + {agentProfiles.map(([name, ap]) => { + const users = usersOf(name) + const pinned = data.tiers.filter((t) => ap.models?.[t]).length + return ( + 0 ? 'ok' : 'neutral'} />} + title={ + + {name} + {users.length > 1 ? shared : null} + + } + /* + A `KeyValueList`, the same as Profiles, because this row is + the same shape: several facts about one entity that do not + say what they are on their own. "2/4 tiers pinned" was prose + doing a label's job, and a middot run made the reader parse + where each fact started. Labelled, they share one column down + the list and the eye finds the same fact on every row. + */ + meta={ + + + {ap.agent ?? 'claude-code'} + + + {pinned > 0 + ? `${pinned} of ${data.tiers.length} tiers pinned` + : 'provider defaults'} + + 0 ? 'default' : 'neutral'}> + {users.length > 0 ? users.join(', ') : 'nothing yet'} + + + } + actions={ + <> + + + + } + /> + ) + })} + )}
diff --git a/web/src/routes/Doctor.tsx b/web/src/routes/Doctor.tsx index a773b80..fe013d2 100644 --- a/web/src/routes/Doctor.tsx +++ b/web/src/routes/Doctor.tsx @@ -1,9 +1,207 @@ import { useState } from 'react' -import { css } from '../../styled-system/css' -import { ApiError, api, type DoctorReport } from '../api' -import { Banner, Button, Dot, Empty, Panel } from '../ui' +import { css, cva } from '../../styled-system/css' +import { ApiError, api, type DoctorCheck, type DoctorReport } from '../api' +import { + Badge, + Banner, + Button, + DataList, + DataRow, + Dot, + Empty, + Inline, + Note, + PageHeader, + Panel, + Stack, +} from '../ui' +import type { Tone } from '../ui' -const TONE = { ok: 'ok', warn: 'warn', error: 'danger', skip: 'faint' } as const +// `satisfies` rather than a bare `as const`: it pins the map to the Tone union, +// so a status that gains a tone with no matching colour fails here rather than +// rendering an invisible dot. +const TONE = { + ok: 'ok', + warn: 'warn', + error: 'danger', + skip: 'neutral', +} as const satisfies Record + +/** + * The advice line under a failing check. + * + * Built here rather than in `ui.tsx` because nothing else in the app has an + * "and here is what to type" block, and a primitive with one caller is not a + * primitive. A `cva` rather than `css({ borderColor: TONE[status] })` for the + * reason the whole codebase uses recipes for status colour: Panda extracts + * literals at build time, so a colour it can only learn at runtime emits no CSS. + * + * It owns its own `mt` — the exception the spacing rule allows nowhere else — + * because it renders in `DataRow`'s children slot, below `meta`, and a `Stack` + * cannot put a gap between two slots of a component it does not wrap. + */ +const fixBlock = cva({ + base: { + mt: '2', + textStyle: 'meta', + color: 'content.secondary', + bg: 'surface.raised', + borderRadius: 'xs', + // WIDTH AND STYLE ONLY. `borderLeft: '[2px solid]'` is a shorthand with no + // colour in it, which resets `border-left-color` to `currentColor` whenever + // Panda emits it after the variant's `borderColor` — and it did, for four of + // these five tones. The rule beside every FAILED check drew in the text + // colour; only `ok`, the tone used for checks that need no attention, + // happened to land after it and render. Longhands reset nothing. + borderInlineStartWidth: 'marker', + borderInlineStartStyle: 'solid', + px: '2.5', + py: '1.5', + maxW: 'content', + }, + variants: { + tone: { + ok: { borderColor: 'ok.default' }, + warn: { borderColor: 'warn.default' }, + danger: { borderColor: 'danger.default' }, + accent: { borderColor: 'accent.default' }, + neutral: { borderColor: 'border.strong' }, + }, + }, + defaultVariants: { tone: 'neutral' }, +}) + +// A passed check is one line, and both halves of it sit below the weight of a +// title that needs reading: the panel it is in already says it passed. +const quietTitle = css({ fontWeight: 'normal', color: 'content.secondary' }) +const quietDetail = css({ textStyle: 'meta', color: 'content.tertiary' }) +const countLabel = css({ textStyle: 'meta', color: 'content.tertiary' }) + +const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}` + +/** A check that failed: status word, detail, and the fix set apart from both. */ +function FailedCheck({ check }: { check: DoctorCheck }) { + const tone = TONE[check.status] + return ( + } + title={ + + {check.title} + {check.status} + + } + meta={check.detail} + > + {check.fix ?

↳ {check.fix}

: null} +
+ ) +} + +/** A check that passed or never ran. Title and detail share one line, quietly. */ +function QuietCheck({ check }: { check: DoctorCheck }) { + const tone = TONE[check.status] + return ( + } + title={ + + {check.title} + {check.detail} + + } + > + {check.fix ?

↳ {check.fix}

: null} +
+ ) +} + +/** + * The report, split by severity. + * + * One list of twenty rows at one weight is a list nobody reads: the two + * findings that are the entire reason to run the doctor sit somewhere in the + * middle of eighteen that need nothing. Grouping puts them first and lets the + * passes recede to a line each. + * + * `skip` gets its own group rather than being folded in with the passes, for + * the same reason the port keeps it a distinct status: a check that did not run + * is not a check that succeeded, and filing it under "Passed" would report all + * clear for work nobody did. + * + * WITHIN a group the doctor's own order survives, because `filter` is stable — + * that is the part worth not losing. The three groups reorder the report + * relative to what the CLI prints, on purpose; the checks inside one never do, + * so a row's neighbours are still the rows the doctor put next to it. + */ +function Report({ report }: { report: DoctorReport }) { + const failed = report.checks.filter((c) => c.status === 'error' || c.status === 'warn') + const passed = report.checks.filter((c) => c.status === 'ok') + const skipped = report.checks.filter((c) => c.status === 'skip') + const errors = failed.filter((c) => c.status === 'error').length + const warnings = failed.length - errors + + return ( + <> + {report.checks.length === 0 ? ( + + No checks ran. + + ) : null} + + {failed.length > 0 ? ( + + {errors > 0 ? {plural(errors, 'error')} : null} + {warnings > 0 ? {plural(warnings, 'warning')} : null} + + } + flush + > + + {failed.map((c) => ( + + ))} + + + ) : null} + + {passed.length > 0 ? ( + {passed.length}} flush> + + {passed.map((c) => ( + + ))} + + + ) : null} + + {skipped.length > 0 ? ( + {skipped.length}} flush> + + {skipped.map((c) => ( + + ))} + + + ) : null} + + {report.notes.length > 0 ? ( + + + {report.notes.map((n) => ( + {n} + ))} + + + ) : null} + + ) +} /** * The doctor, on demand. @@ -11,6 +209,10 @@ const TONE = { ok: 'ok', warn: 'warn', error: 'danger', skip: 'faint' } as const * Offline is the default and the network run is a separate, clearly-labelled * button: the probes are real inference requests, and a UI that spends money on * a click nobody understood would be a worse bug than anything it diagnoses. + * Which is why the sentence about being billed sits in the same row as the + * button that does the billing, where it is read at the moment of the click + * rather than in a caption above both runs — and why the filled accent is on + * the free run. The screen's one call to action must not be the one that costs. */ export function Doctor() { const [report, setReport] = useState(null) @@ -34,73 +236,48 @@ export function Doctor() { return ( <> -

Doctor

+ {error ? {error} : null} - - - - - } - > -

- Offline checks read your config, resolve the agent binary and inspect the environment. - Live probes additionally send a real request to the endpoint — at most one per distinct - model plus one tool-calling probe — which your provider will bill you for. -

+ + + void run(true)} disabled={busy}> + {busy ? 'Running…' : 'Check offline'} + + } + /> + + Live probes + billable + + } + meta="Live probes additionally send a real request to the endpoint — at most one per distinct model plus one tool-calling probe — which your provider will bill you for." + actions={ + + } + /> + - {report ? ( - - {report.checks.length === 0 ? ( - No checks ran. - ) : ( - report.checks.map((c) => ( -
- - - -
-
{c.title}
-
- {c.detail} -
- {c.fix ? ( -
- ↳ {c.fix} -
- ) : null} -
-
- )) - )} - {report.notes.map((n) => ( -

- {n} -

- ))} -
- ) : null} + {report ? : null} ) } diff --git a/web/src/routes/Environment.tsx b/web/src/routes/Environment.tsx new file mode 100644 index 0000000..304dfd3 --- /dev/null +++ b/web/src/routes/Environment.tsx @@ -0,0 +1,267 @@ +import { useEffect, useMemo, useState } from 'react' +import type { ReactNode } from 'react' +import { css } from '../../styled-system/css' +import { ApiError, api, type ClaudeEnvCatalog, type ClaudeEnvKind, type ClaudeEnvVar } from '../api' +import { + Badge, + Banner, + Code, + CopyButton, + DataList, + DataRow, + Dot, + Empty, + Inline, + Mono, + PageHeader, + Panel, + SearchInput, + ToggleChip, + Toolbar, +} from '../ui' +import type { Tone } from '../ui' + +/** + * Every environment variable Claude Code references. + * + * THE CLASSIFICATION IS THE FEATURE, not a caveat bolted onto it. The list is + * extracted from the shipped binary, which yields names and nothing else — no + * meaning, no defaults, no proof a name is still wired to anything. So the + * screen leads with how much is known about each entry rather than presenting + * 495 strings as if they were equally supported knobs: + * + * documented described by hand, safe to act on + * undocumented the name is real, the meaning is not known + * internal test hooks, profiling, cloud auth, unreleased codenames + * + * A screen that hid that distinction would be actively harmful: someone would + * set an unreleased feature codename in a profile and wonder why nothing + * happened — or why it stopped working after an agent update. + * + * That is also why the classification is carried TWICE and in two registers: + * once as a legend in the panel header, where each active filter states its + * caveat in words, and once per row as the leading `Dot` in the same tone. A + * 495-row list is scanned, not read, and a colour with a legend above it + * survives scanning where a sentence repeated 338 times does not. + */ + +const KIND_COPY: Record = { + documented: { + label: 'Documented', + blurb: ( + <> + Described from Anthropic’s docs, claude --help, or swisscode’s own adapter. + Safe to act on. + + ), + tone: 'ok', + }, + undocumented: { + label: 'Undocumented', + blurb: + 'The name is real — Claude Code references it. What it does is NOT known. Deliberately shipped without a description: a plausible guess is worse than a blank, because someone acts on it.', + tone: 'warn', + }, + internal: { + label: 'Internal', + blurb: + 'Test hooks, profiling switches, third-party cloud auth, and unreleased feature codenames. Listed for completeness; not knobs to set.', + tone: 'neutral', + }, +} + +/** Filter and legend order. Fixed, so the legend cannot reshuffle as filters toggle. */ +const KINDS = Object.keys(KIND_COPY) as ClaudeEnvKind[] + +const emphasis = css({ color: 'content.primary' }) + +// The legend renders inside `Panel description`, which is a

— a

there +// is invalid nesting, so the lines are spans that happen to lay out as a column. +// Keeping it in the panel header rather than above the panel is the point: three +// paragraphs of caveat stacked over a toolbar pushed all 495 rows off the screen. +const legendList = css({ display: 'flex', flexDirection: 'column', gap: '1.5' }) +const legendLine = css({ display: 'flex', gap: '2', alignItems: 'baseline' }) + +export function Environment() { + const [data, setData] = useState(null) + const [error, setError] = useState(null) + const [query, setQuery] = useState('') + const [kinds, setKinds] = useState>(new Set(['documented'])) + + useEffect(() => { + let live = true + api + .claudeEnv() + .then((r) => live && setData(r)) + .catch((err) => live && setError(err instanceof ApiError ? err.message : String(err))) + return () => { + live = false + } + }, []) + + const rows = useMemo(() => { + if (!data) return [] + const terms = query.toLowerCase().split(/\s+/).filter(Boolean) + return data.variables.filter((v) => { + if (!kinds.has(v.kind)) return false + if (terms.length === 0) return true + // Search the description too — "how do I cap output tokens" is a likelier + // question than "what does MAX_OUTPUT_TOKENS do". + const hay = `${v.name} ${v.category ?? ''} ${v.description ?? ''}`.toLowerCase() + return terms.every((t) => hay.includes(t)) + }) + }, [data, query, kinds]) + + const counts = useMemo(() => { + const c: Record = { documented: 0, undocumented: 0, internal: 0 } + for (const v of data?.variables ?? []) c[v.kind] = (c[v.kind] ?? 0) + 1 + return c + }, [data]) + + const toggle = (kind: ClaudeEnvKind) => + setKinds((s) => { + const next = new Set(s) + if (next.has(kind)) next.delete(kind) + else next.add(kind) + return next + }) + + const active = KINDS.filter((kind) => kinds.has(kind)) + + return ( + <> + + {data.variables.length} variables · extracted from{' '} + + {data.source.agent} {data.source.version} + + + ) : undefined + } + description={ + <> + Every environment variable this Claude Code build references. Set any of them on a + profile’s env block, which is applied last and can override anything the + provider sets. The list is extracted from the binary + , so it is complete on names and silent on meaning — which is what the filters below are + for. + + } + /> + + {error ? {error} : null} + + + + {/* + Chips, not buttons: these three filters combine, and three filled + accent buttons in a row would claim the screen has three primary + actions. The count rides along so the cost of enabling one is visible + before it is enabled. + */} + {KINDS.map((kind) => ( + toggle(kind)} + > + {KIND_COPY[kind].label} + + ))} + + + 0 ? ( + + {active.map((kind) => ( + + {KIND_COPY[kind].label} + {KIND_COPY[kind].blurb} + + ))} + + ) : undefined + } + flush + > + {!data && !error ? Loading… : null} + {data && rows.length === 0 ? ( + Nothing matches. Try a different search, or enable another category above. + ) : null} + + {rows.map((v) => ( + + ))} + + + + ) +} + +// Why an internal name is internal ("test hook", "unreleased feature codename") +// is a qualifier on the name, not a description of the variable, so it stays on +// the identifier line at `micro` rather than competing with real prose below it. +const rowWhy = css({ textStyle: 'micro', color: 'content.tertiary' }) + +// A real description gets the readable step: `content.secondary`, measure-limited. +const rowDescription = css({ display: 'block', maxW: 'content', color: 'content.secondary' }) + +// The absence of one gets the step below, at `micro`. It is the same sentence on +// 338 consecutive rows, and it was previously set in italics — which pulls the +// eye to the one thing on the row that carries no information. The legend in the +// panel header now makes the claim once, in words; this line is the reminder. +const rowUnknown = css({ display: 'block', textStyle: 'micro', color: 'content.tertiary' }) + +function Row({ variable }: { variable: ClaudeEnvVar }) { + return ( + } + title={ + + {variable.name} + {/* + "swisscode sets this" is the most useful badge on the screen: it tells + someone the knob is already wired to a profile field, so hand-setting + it in `env` would fight the launcher rather than configure it. It is + the only coloured thing on the row besides the classification dot. + */} + {variable.managed ? swisscode sets this : null} + {variable.category ? {variable.category} : null} + {variable.kind === 'internal' ? ( + {variable.why} + ) : null} + + } + meta={ + variable.description ? ( + {variable.description} + ) : ( + /* + An explicit blank, not an empty cell. The absence is the finding: we + know the name exists and we do not know what it does, and saying so is + the honest version of a catalog built by reading a binary. + */ + + {variable.kind === 'internal' + ? 'Not a user-facing setting.' + : 'No description — swisscode does not know what this does.'} + + ) + } + actions={} + /> + ) +} diff --git a/web/src/routes/ModelPicker.tsx b/web/src/routes/ModelPicker.tsx index 6b33db5..a5eddc0 100644 --- a/web/src/routes/ModelPicker.tsx +++ b/web/src/routes/ModelPicker.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState } from 'react' -import { css } from '../../styled-system/css' +import { css, cva, cx } from '../../styled-system/css' import { ApiError, api, type CatalogModel, type CatalogResult } from '../api' -import { Banner, Button, inputStyle } from '../ui' +import { Badge, Banner, Button, Empty, Inline, Modal, SearchInput, Stack, ToggleChip } from '../ui' // The SAME filtering, ranking and formatting the Ink picker uses. These are // properties of the data, not of the widget, and the browser had drifted: // its own copy dropped the free-only filter, never sorted at all, and rendered @@ -9,6 +9,97 @@ import { Banner, Button, inputStyle } from '../ui' import { filterModels, rank } from '../../../src/core/catalog' import { formatContext, formatPrice } from '../../../src/core/format' +/** + * The catalog's tracks, shared by the column header and every row. + * + * Scanning three hundred models is a comparison, and a comparison needs edges: + * the id runs on the flexible track, the numbers sit on fixed ones and are set + * right-aligned in mono so the digits stack. Prose separated by middots — which + * is what this list used to be — makes each row a sentence to read instead of a + * line to compare. + * + * A `cva` rather than a template string picked at render time, because Panda is + * a build-time extractor: a value it can only learn at runtime emits no CSS at + * all. The price tracks exist only when the catalog publishes prices, since two + * columns of em dashes on every row is not a fact, it is furniture. + */ +const catalogGrid = cva({ + base: { display: 'grid', alignItems: 'center', gap: '3' }, + variants: { + pricing: { + true: { gridTemplateColumns: '[minmax(0, 1fr) 5rem 5rem 4.5rem]' }, + false: { gridTemplateColumns: '[minmax(0, 1fr) 4.5rem]' }, + }, + }, + defaultVariants: { pricing: false }, +}) + +// The `DataRow` gutter and hairline, on a button — the row IS the control here, +// so it cannot be a `DataRow`. Tighter vertically than a `DataRow` on purpose: +// a provider catalog is hundreds of rows long and every extra pixel is a scroll. +const catalogRow = css({ + width: 'full', + textAlign: 'left', + font: 'inherit', + bg: 'transparent', + // Three sides off, one on — rather than `border: 'none'` followed by a + // `borderBottom`. `border` is a shorthand that covers the bottom too, so the + // pair is a race decided by which class Panda emitted last; these three do not + // overlap, so there is nothing to decide. + borderTop: 'none', + borderInline: 'none', + borderBottom: 'hairline', + px: '4', + py: '2', + cursor: 'pointer', + transitionProperty: 'colors', + transitionDuration: 'fast', + _hover: { bg: 'surface.hover' }, + _last: { borderBottom: 'none' }, +}) + +// Sticky INSIDE the scroller rather than above it: a header in a separate box +// is a scrollbar's width out of alignment with the columns it names, on every +// platform that reserves one. +const columnHeader = css({ + position: 'sticky', + top: '0', + zIndex: 'sticky', + bg: 'surface.panel', + px: '4', + py: '1.5', + borderBottom: 'hairline', + textStyle: 'micro', + color: 'content.tertiary', +}) + +const numericHead = css({ textAlign: 'right' }) +const numeric = css({ textStyle: 'code', color: 'content.secondary', textAlign: 'right' }) + +// A grid item is `min-width: auto` by default, so a 60-character id would push +// straight through the price columns instead of ellipsing inside its own track. +const idCell = css({ minW: '0' }) +const idLine = css({ display: 'flex', gap: '2', alignItems: 'baseline', minW: '0' }) +const idText = css({ + textStyle: 'code', + color: 'content.primary', + minW: '0', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', +}) +const nameLine = css({ + textStyle: 'meta', + color: 'content.tertiary', + mt: '0.5', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', +}) +// Banners are bordered boxes, so they need the row's gutter around them rather +// than the full bleed the rows want. +const notice = css({ px: '4', pt: '4' }) + /** * The browsable model list, for providers that publish one. * @@ -20,21 +111,11 @@ import { formatContext, formatPrice } from '../../../src/core/format' * not publish capability at all. * * Nothing missing is rendered as a number. A catalog with no prices shows * no prices, rather than "$0.00", which would read as free. - */ -/** - * The per-million price pair, or just "free". * - * `formatPrice` already returns "free" for zero — appending "/M in" to that - * gives "free/M in", which is not a thing anyone says. The FORMATTING is - * core's; only this bit of phrasing is the browser's, which is exactly the - * split that should exist between them. + * The second rule is also why the unit lives in the column header and not in the + * cell: `formatPrice` returns "free" for zero, and "free/M in" — which is what + * appending the unit per row produced — is not a thing anyone says. */ -function priceLabel(pricing: { prompt: number; completion: number }): string { - const input = formatPrice(pricing.prompt) - const output = formatPrice(pricing.completion) - return input === 'free' && output === 'free' ? 'free' : `${input}/M in · ${output}/M out` -} - export function ModelPicker({ catalogId, tier, @@ -72,137 +153,126 @@ export function ModelPicker({ return filterModels(data.models, { query, toolsOnly, freeOnly }, data.capabilities).sort(rank) }, [data, query, toolsOnly, freeOnly]) + // The catalog's own declaration, not an inference from a page of nulls — the + // same fact the free-only filter branches on, and the SINGLE authority for + // whether the two price columns exist at all. A catalog that says it does not + // publish prices shows none, even if some individual model carries one: the + // columns are grid tracks shared by every row, so "some rows have a price" is + // not a state this table can be in. Registries that do publish prices declare + // it, and the alternative — inferring the column from the page you happen to + // have scrolled to — is how a table's shape starts depending on its filter. + const pricing = data?.capabilities.pricing ?? false + return ( -
-
e.stopPropagation()} + +
-
- setQuery(e.target.value)} - /> - -
+ + + +

Model for {tier}

+ + {rows.length} + {data ? `/${data.models.length}` : ''} shown + + {data?.stale ? stale cache : null} + {data?.fromCache && !data.stale ? cached : null} +
+ +
-
- - {rows.length} - {data ? `/${data.models.length}` : ''} shown - - {data?.capabilities.toolSupportKnown ? ( - - ) : null} - {data?.capabilities.pricing ? ( - - ) : null} - {data?.stale ? stale cache : null} - {data?.fromCache && !data.stale ? cached : null} -
+ + + {data?.capabilities.toolSupportKnown ? ( + setToolsOnly(!toolsOnly)}> + tools only + + ) : null} + {data?.capabilities.pricing ? ( + setFreeOnly(!freeOnly)}> + free only + + ) : null} + +
+
-
- {error ? {error} : null} - {data?.error && data.models.length === 0 ? ( +
+ {error ? ( +
+ {error} +
+ ) : null} + {data?.error && data.models.length === 0 ? ( +
Could not fetch the catalog: {data.error} - ) : null} - {!data && !error ? ( -

loading catalog…

- ) : null} +
+ ) : null} + {!data && !error ? Loading catalog… : null} + {data && data.models.length > 0 && rows.length === 0 ? ( + Nothing matches. Try a different search, or turn off a filter above. + ) : null} - {rows.map((m) => ( - - ))} -
+
{m.name}
+
+ {pricing ? ( + <> + {/* Absent data stays absent: `formatPrice` renders a dash for a + missing price, never "$0.00", which would read as free. */} + {formatPrice(m.pricing?.prompt ?? null)} + {formatPrice(m.pricing?.completion ?? null)} + + ) : null} + {formatContext(m.context)} + + ))}
-
+ ) } diff --git a/web/src/routes/Profiles.tsx b/web/src/routes/Profiles.tsx index 3356971..0fa6b78 100644 --- a/web/src/routes/Profiles.tsx +++ b/web/src/routes/Profiles.tsx @@ -1,7 +1,30 @@ import { useState } from 'react' -import { css } from '../../styled-system/css' +import { css, cva } from '../../styled-system/css' import { ApiError, api, type Bootstrap, type SelectionStrategy } from '../api' -import { Banner, Button, Dot, Empty, Field, Panel, inputStyle } from '../ui' +import { + Badge, + Banner, + Button, + Checkbox, + Code, + DataList, + DataRow, + Dot, + Empty, + Field, + FormActions, + Inline, + KeyValue, + KeyValueList, + Mono, + Note, + PageHeader, + Panel, + Radio, + Stack, + inputStyle, + selectStyle, +} from '../ui' /** * Profiles — the pairing, and the only screen that expresses MULTIPLE accounts. @@ -30,6 +53,65 @@ const STRATEGIES: { id: SelectionStrategy; label: string; note: string }[] = [ }, ] +const strategyLabel = (id: SelectionStrategy): string => + STRATEGIES.find((s) => s.id === id)?.label ?? id + +/** + * A reference and the thing it dereferences to, drawn as ONE chip. + * + * A profile is nothing except its references, so they are the row's content + * rather than a sentence about it — and `work → anthropic` has to stay readable + * as one unit when it is the third of four on a wrapping line, which a run of + * middot-separated identifiers does not. Local to this route because it is the + * only screen that shows a reference: `Badge` is a status and `Code` is a + * literal inside prose, and a pointer is neither. + */ +const refChip = cva({ + base: { + display: 'inline-flex', + alignItems: 'baseline', + gap: '1', + px: '1.5', + py: '0.5', + borderRadius: 'xs', + textStyle: 'code', + whiteSpace: 'nowrap', + }, + variants: { + tone: { + default: { bg: 'surface.hover', color: 'content.secondary' }, + danger: { bg: 'danger.subtle', color: 'danger.default' }, + }, + }, + defaultVariants: { tone: 'default' }, +}) + +// Only the intact chip dims its resolved half; a dangling one stays one solid +// red unit, because the part that is wrong is the arrow, not the target. +const refResolved = css({ color: 'content.tertiary' }) + +function Ref({ + name, + to, + missing = false, +}: { + name: string + /** What the name resolves to. Omitted when the target names nothing further. */ + to?: string | undefined + missing?: boolean +}) { + return ( + + {name} + {to ? → {to} : null} + + ) +} + +// `Checkbox` renders its label into a , so this is inline-flex rather +// than an `Inline` — whose
would be invalid markup in that slot. +const choiceLabelRow = css({ display: 'inline-flex', alignItems: 'baseline', gap: '2' }) + export function Profiles({ data, reload }: { data: Bootstrap; reload: () => Promise }) { const names = Object.keys(data.state.profiles ?? {}) const accountNames = Object.keys(data.state.providerAccounts ?? {}) @@ -83,12 +165,10 @@ export function Profiles({ data, reload }: { data: Bootstrap; reload: () => Prom const canCreate = agentProfileNames.length > 0 && accountNames.length > 0 return ( <> -
- -

- {isNew ? 'New profile' : `Profile · ${editing}`} -

-
+ setEditing(null)} + /> {error ? {error} : null} {!canCreate ? ( @@ -113,7 +193,7 @@ export function Profiles({ data, reload }: { data: Bootstrap; reload: () => Prom ) : null} - put('accounts', e.target.checked ? [...accounts, n] : accounts.filter((x) => x !== n)) - } - /> - - {n} - - {a.provider} - {a.hasKey || a.apiKeyFromEnv ? '' : ' · no key'} - - - - ) - })} - {accounts.length === 0 ? ( -

- A profile with no account has nothing to authenticate with and will not launch. -

- ) : null} + + + + {accountNames.map((n) => { + const a = data.state.providerAccounts[n]! + return ( + + put('accounts', on ? [...accounts, n] : accounts.filter((x) => x !== n)) + } + label={ + + {n} + + {a.provider} + + {a.hasKey || a.apiKeyFromEnv ? null : no key} + + } + /> + ) + })} + + {accounts.length === 0 ? ( + + A profile with no account has nothing to authenticate with and will not launch. + + ) : null} + {accounts.length > 1 ? ( - {STRATEGIES.map((s) => ( - - ))} + + {STRATEGIES.map((s) => ( + put('strategy', s.id)} + label={s.label} + note={s.note} + /> + ))} + ) : null} -
+ -
+ ) } return ( <> -
-

Profiles

- -
+ + The pairing of what runs with who pays. Whichever profile is marked default is the one{' '} + swisscode launches when nothing else names one — no argument, no flag, no + directory binding. + + } + actions={ + + } + /> {error ? {error} : null} - + {names.length === 0 ? ( No profiles yet. A profile pairs an agent profile with one or more accounts. ) : ( - names.map((name) => { - const p = data.state.profiles[name]! - 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 first = p.accounts?.[0] - const account = first ? data.state.providerAccounts?.[first] : undefined - const broken = - !data.state.agentProfiles?.[p.agentProfile] || (p.accounts ?? []).length === 0 || !account - return ( -
- -
-
- {name} - {isDefault ? ( - - default - - ) : null} -
-
- {broken - ? 'broken reference — open to repair' - : `${p.agentProfile} · ${first} → ${account!.provider}` + - ((p.accounts?.length ?? 0) > 1 - ? ` (+${p.accounts!.length - 1}, ${p.strategy ?? 'single'})` - : '')} -
-
- {!isDefault ? ( - - ) : null} - - -
- ) - }) + + {names.map((name) => { + const p = data.state.profiles[name]! + 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 attached = p.accounts ?? [] + const first = attached[0] + const account = first ? data.state.providerAccounts?.[first] : undefined + const broken = !agentProfile || attached.length === 0 || !account + return ( + } + title={ + + {name} + {isDefault ? default : null} + + } + meta={ + + + + + + {attached.length === 0 ? ( + + none + + ) : ( + + + {attached.map((n) => { + const a = data.state.providerAccounts?.[n] + return + })} + + + )} + {attached.length > 1 ? ( + + {/* + The stored id first, the sentence second. Every + other fact in this list is the literal that is in + config.json, and the whole use for this row is + reconciling the screen against that file — a label + on its own means looking up which of three strings + produced it. + */} + {p.strategy ?? 'single'}{' '} + + {strategyLabel(p.strategy ?? 'single')} + + + ) : null} + + {broken ? broken reference — open to repair : null} + + } + actions={ + <> + {!isDefault ? ( + + ) : null} + + + + } + /> + ) + })} + )}
diff --git a/web/src/routes/Providers.tsx b/web/src/routes/Providers.tsx index 2c572e2..d3e3e20 100644 --- a/web/src/routes/Providers.tsx +++ b/web/src/routes/Providers.tsx @@ -1,13 +1,34 @@ import { useState } from 'react' import { css } from '../../styled-system/css' import { ApiError, api, type Bootstrap } from '../api' -import { Banner, Button, Dot, Empty, Field, Panel, inputStyle, monoInput } from '../ui' +import { + Badge, + Banner, + Button, + Checkbox, + Code, + DataList, + DataRow, + Empty, + Field, + FormActions, + Mono, + PageHeader, + Panel, + inputStyle, + monoInput, + selectStyle, +} from '../ui' /** * Shipped presets are read-only here and say so. They are constants in source, * guarded by tests a config file cannot reach; presenting them as editable * would imply a capability that does not exist. * + * That difference is the screen's whole layout argument: the two lists are + * separate panels, and only the editable one has an actions column. Anything + * that reads the same on both sides is a promise the shipped list cannot keep. + * * Custom providers are editable, and every refusal from the server is rendered * verbatim — those messages are the runtime twin of the shipped descriptors' * test suite, so they are the most useful thing on the screen when a save fails. @@ -72,17 +93,16 @@ export function Providers({ data, reload }: { data: Bootstrap; reload: () => Pro const isNew = !data.customProviders[editing] return ( <> -
- -

- {isNew ? 'New provider' : `Provider · ${editing}`} -

-
+ {editing}} + onBack={() => setEditing(null)} + /> {error ? ( {errors.length > 1 ? ( -
    +
      {errors.map((e) => (
    • {e}
    • ))} @@ -118,9 +138,26 @@ export function Providers({ data, reload }: { data: Bootstrap; reload: () => Pro placeholder="https://gateway.example.com/anthropic" /> + + + {/* + Its own panel because the choice is not cosmetic: the variable name is + what decides the header the agent sends, so it belongs beside the + "no credential at all" switch rather than trailing the URL fields. + */} + + The variable your key is exported as, which is also what picks the header:{' '} + ANTHROPIC_API_KEY is sent as x-api-key,{' '} + ANTHROPIC_AUTH_TOKEN as Authorization: Bearer. + + } + > - + put('credentialOptional', v)} + label="This endpoint does not require a credential" + /* + What the flag DOES is stop the wizard and the doctor demanding a + credential. The launch still sends a key when there is one to send: + the no-key path is taken only when no account for this provider + exists and the variable is unset in the shell. + */ + note="swisscode stops asking for a credential, so a profile pointed here can launch with no key at all — which is what a local server wants and what anything on the public internet does not." + /> - -

      - Optional. A profile can override any of these. Do not type an extended-context - marker — that suffix is derived from a verified capability, and an id the endpoint - does not recognise fails hard. -

      + {data.tiers.map((tier) => ( Pro ))} -
      + -
      + ) } return ( <> -
      -

      Providers

      - -
      + open(null)}> + New provider + + } + /> {error ? {error} : null} {warnings.map((w) => ( @@ -184,68 +227,59 @@ export function Providers({ data, reload }: { data: Bootstrap; reload: () => Pro ))} - + {custom.length === 0 ? ( - None yet. Add one for a gateway or a local server swisscode does not ship a preset for. + No custom providers yet. Add one for a gateway or a local server swisscode does not ship a preset for. ) : ( - custom.map(([id, p]) => ( -
      - -
      -
      {p.label}
      -
      - {id} · {p.baseUrl} -
      -
      - - -
      - )) + + {custom.map(([id, p]) => ( + + {id} · {p.baseUrl} + + } + actions={ + <> + + + + } + /> + ))} + )}
      - -

      - Read-only. These are constants in swisscode's source, checked by tests that a config - file cannot reach — including verified extended-context claims. -

      - {shipped.map((p) => ( -
      -
      -
      {p.label}
      -
      - {p.baseUrl ?? 'agent default'} -
      -
      - {p.catalogId ? ( - browsable catalog - ) : null} -
      - ))} + read-only} + flush + > + + {shipped.map((p) => ( + {p.baseUrl} : 'agent default'} + actions={p.catalogId ? browsable catalog : null} + /> + ))} + ) diff --git a/web/src/routes/Settings.tsx b/web/src/routes/Settings.tsx index fb3f8e6..5a8bc60 100644 --- a/web/src/routes/Settings.tsx +++ b/web/src/routes/Settings.tsx @@ -1,8 +1,32 @@ import { useState } from 'react' -import { css } from '../../styled-system/css' +import { css, cx } from '../../styled-system/css' import { ApiError, api, type Bootstrap } from '../api' -import { Banner, Button, Field, Panel, inputStyle } from '../ui' +import { + Banner, + Button, + Checkbox, + Code, + Field, + FormActions, + Note, + PageHeader, + Panel, + inputStyle, +} from '../ui' +// A two-digit integer in a 100%-wide box reads as a field somebody forgot to +// finish. `inputStyle` still supplies the box, so this input keeps the same +// height, radius and focus ring as every other control; only the measure moves. +const depthInput = cx(inputStyle, css({ maxW: 'keyColumn' })) + +/** + * The two settings that belong to no profile. + * + * It is the smallest screen in the app, so its whole job is saying what these + * cost: `quiet` decides whether a launch ever speaks, and the walk depth decides + * how far a binding lookup reaches. Neither is guessable from its name, which is + * why each carries a line of prose rather than a bare label. + */ export function Settings({ data, reload }: { data: Bootstrap; reload: () => Promise }) { const [quiet, setQuiet] = useState(Boolean(data.state.settings.quiet)) const [depth, setDepth] = useState(String(data.state.settings.bindingWalkDepth ?? '')) @@ -26,33 +50,52 @@ export function Settings({ data, reload }: { data: Bootstrap; reload: () => Prom return ( <> -

      Settings

      + {error ? {error} : null} - - -

      - swisscode writes to stderr only; stdout belongs to the agent. A clean environment - already prints nothing, which is what makes the lines it does print worth reading. -

      + + + Suppresses every line a launch would print — the profile banner, the config and + selection warnings, and the notice that a newer version is out.{' '} + SWISSCODE_QUIET=1 does the same for one shell without saving anything. + + } + /> - + - setDepth(e.target.value)} placeholder="40" /> + setDepth(e.target.value)} + placeholder="40" + /> -
      - - {saved ? saved : null} -
      + saved : null}> + + ) } diff --git a/web/src/theme.ts b/web/src/theme.ts new file mode 100644 index 0000000..ee6677f --- /dev/null +++ b/web/src/theme.ts @@ -0,0 +1,69 @@ +// Light, dark, or whatever the machine says. +// +// THE RESOLVED VALUE IS THE ONLY THING THAT REACHES CSS. `data-theme` on +// is always `light` or `dark`, never `system`: components then style against a +// single condition rather than each having to reason about a three-state +// preference, which is the difference between one code path and two. +// +// The preference is stored; the resolution is derived. Those are different +// values and conflating them is what produces a UI that ignores the machine +// switching to dark at sunset because the user once clicked "light". + +const STORAGE_KEY = 'swisscode-theme' + +/** What the user asked for. `system` is a real answer, and the default. */ +export type ThemePreference = 'system' | 'light' | 'dark' + +/** What the page actually renders. Never `system`. */ +export type ResolvedTheme = 'light' | 'dark' + +const PREFERENCES: readonly ThemePreference[] = ['system', 'light', 'dark'] + +export function isThemePreference(value: unknown): value is ThemePreference { + return typeof value === 'string' && (PREFERENCES as readonly string[]).includes(value) +} + +/** The stored preference, or `system` when there is none or it is unusable. */ +export function readPreference(): ThemePreference { + try { + const stored = localStorage.getItem(STORAGE_KEY) + return isThemePreference(stored) ? stored : 'system' + } catch { + // Private browsing, a blocked origin, a disabled storage API. A theme is + // not worth failing a page over. + return 'system' + } +} + +export function writePreference(preference: ThemePreference): void { + try { + if (preference === 'system') localStorage.removeItem(STORAGE_KEY) + else localStorage.setItem(STORAGE_KEY, preference) + } catch { + /* the theme still applies for this page load */ + } +} + +export function systemTheme(): ResolvedTheme { + return typeof matchMedia === 'function' && matchMedia('(prefers-color-scheme: dark)').matches + ? 'dark' + : 'light' +} + +export function resolveTheme(preference: ThemePreference): ResolvedTheme { + return preference === 'system' ? systemTheme() : preference +} + +/** + * Stamp the resolved theme where CSS can see it. + * + * Also sets `color-scheme`, which is what makes the browser's OWN chrome match + * — form controls, scrollbars, the flash of background before paint. Styling + * every surface and leaving that unset is why an otherwise-dark app renders a + * white scrollbar. + */ +export function applyTheme(theme: ResolvedTheme): void { + const root = document.documentElement + root.setAttribute('data-theme', theme) + root.style.colorScheme = theme +} diff --git a/web/src/ui.tsx b/web/src/ui.tsx index 2ad02f1..4417c5d 100644 --- a/web/src/ui.tsx +++ b/web/src/ui.tsx @@ -1,25 +1,548 @@ -// Shared primitives. Small on purpose — the Linear look is mostly restraint, -// so there are few components and they are reused rather than varied. +// The component layer. +// +// Small on purpose. The look these were measured from is mostly restraint, so +// there are few primitives and they are reused rather than varied — a codebase +// with fifteen button variants has no design system, it has fifteen buttons. +// +// EVERY VALUE HERE NAMES A TOKEN, which is not a convention but a compile +// error: `strictTokens` is on in panda.config.ts, so a stray `'13px'` or +// `'#fff'` fails typecheck. The `Dot` below used to set its colour through an +// inline `style` with an interpolated CSS variable — the one construct that +// escapes the type system entirely — and that is exactly what this rule exists +// to catch. +// +// NOTHING HERE OWNS ITS OUTER MARGIN except the three things that mark a +// screen's skeleton — `PageHeader`, `Panel`, `Toolbar`, `FormActions`. Spacing +// between siblings is `Stack`'s job. A component that carries its own `mb` is a +// component every caller has to fight the moment it appears somewhere else, and +// it is how seven screens end up with seven vertical rhythms. import type { ReactNode } from 'react' -import { css, cx } from '../styled-system/css' +import { useEffect, useRef, useState } from 'react' +import { css, cva, cx } from '../styled-system/css' -export const row = css({ - display: 'flex', - alignItems: 'center', - gap: '2', +/** Tones that map to a status colour. Shared so a component cannot invent one. */ +export type Tone = 'ok' | 'warn' | 'danger' | 'accent' | 'neutral' + +/** + * The gaps a layout may ask for, from the spacing scale. + * + * Deliberately a short list. `Stack gap="3"` and `Stack gap="4"` are a rhythm; + * eleven choices are an invitation to eyeball it, which is the same failure as + * an arbitrary margin with extra steps. + */ +export type Gap = '0' | '1' | '1.5' | '2' | '3' | '4' | '5' | '6' + +// Shared by `Stack` and `Inline`. Written out per value rather than generated +// because Panda is a build-time extractor: a gap it can only learn at runtime +// emits no CSS at all. +const GAPS = { + '0': { gap: '0' }, + '1': { gap: '1' }, + '1.5': { gap: '1.5' }, + '2': { gap: '2' }, + '3': { gap: '3' }, + '4': { gap: '4' }, + '5': { gap: '5' }, + '6': { gap: '6' }, +} as const + +/* ------------------------------------------------------------------ layout */ + +const stack = cva({ + base: { display: 'flex', flexDirection: 'column', minW: '0' }, + variants: { + gap: GAPS, + align: { + stretch: { alignItems: 'stretch' }, + start: { alignItems: 'flex-start' }, + center: { alignItems: 'center' }, + }, + }, + defaultVariants: { gap: '3', align: 'stretch' }, +}) + +/** Vertical rhythm, named. The only sanctioned way to space siblings apart. */ +export function Stack({ + gap, + align, + children, +}: { + gap?: Gap + align?: 'stretch' | 'start' | 'center' + children: ReactNode +}) { + return
      {children}
      +} + +const inline = cva({ + base: { display: 'flex', minW: '0' }, + variants: { + gap: GAPS, + align: { + center: { alignItems: 'center' }, + baseline: { alignItems: 'baseline' }, + start: { alignItems: 'flex-start' }, + stretch: { alignItems: 'stretch' }, + }, + justify: { + start: { justifyContent: 'flex-start' }, + between: { justifyContent: 'space-between' }, + end: { justifyContent: 'flex-end' }, + }, + wrap: { true: { flexWrap: 'wrap' }, false: { flexWrap: 'nowrap' } }, + }, + defaultVariants: { gap: '2', align: 'center', justify: 'start', wrap: false }, +}) + +/** Horizontal grouping, named. `Inline` is to a row what `Stack` is to a column. */ +export function Inline({ + gap, + align, + justify, + wrap, + children, +}: { + gap?: Gap + align?: 'center' | 'baseline' | 'start' | 'stretch' + justify?: 'start' | 'between' | 'end' + wrap?: boolean + children: ReactNode +}) { + return
      {children}
      +} + +/** + * The top of every screen. + * + * One component so that seven screens cannot disagree about how far the title + * sits above the content, which is the kind of difference nobody can name and + * everybody sees. The title is `title` (20px) rather than the `heading` (15px) + * the panels use, so the page reads title → panel → row without anything + * needing a rule under it. + */ +export function PageHeader({ + title, + meta, + description, + actions, + onBack, +}: { + title: ReactNode + /** A short fact about the page — a count, a source, a version. Sits beside the title. */ + meta?: ReactNode + /** A sentence or two. Measure-limited, because prose the width of the page is unreadable. */ + description?: ReactNode + actions?: ReactNode + /** When set, renders the standard back control. Editor views pass their cancel handler. */ + onBack?: () => void +}) { + return ( +
      +
      + {onBack ? ( + + + + ) : null} +
      +

      {title}

      + {meta ? ( + {meta} + ) : null} +
      + {actions ? ( +
      + {actions} +
      + ) : null} +
      + {description ? ( +

      + {description} +

      + ) : null} +
      + ) +} + +/** + * A row of controls: search, filters, buttons. + * + * Everything in it is 32px tall, so the row has one baseline rather than three. + * `end` is pushed to the right edge; put the count or the destructive action + * there, not in the middle of the group. + */ +export function Toolbar({ children, end }: { children: ReactNode; end?: ReactNode }) { + return ( +
      + {children} + {end ? ( +
      + {end} +
      + ) : null} +
      + ) +} + +/** The footer of an editor: primary action first, then the way out. */ +export function FormActions({ children, end }: { children: ReactNode; end?: ReactNode }) { + return ( +
      + {children} + {end ? ( +
      + {end} +
      + ) : null} +
      + ) +} + +/* ------------------------------------------------------------------ panels */ + +export function Panel({ + title, + description, + action, + flush, + children, +}: { + title?: string + /** Explains the panel, not a field in it. Renders inside the header, above the hairline. */ + description?: ReactNode + action?: ReactNode + /** + * Drop the body padding, so a `DataList` runs edge to edge. + * + * A `DataRow` carries its own gutter; nesting one inside a padded body insets + * it twice and stops the separators short of the panel's own hairline, which + * is the difference between a list and a stack of boxes. + */ + flush?: boolean + children: ReactNode +}) { + return ( +
      + {title || description || action ? ( +
      +
      + {title ? ( +

      {title}

      + ) : null} + {action ? ( +
      + {action} +
      + ) : null} +
      + {description ? ( +

      + {description} +

      + ) : null} +
      + ) : null} +
      {children}
      +
      + ) +} + +/** + * A dialog over the page. + * + * Closes on the backdrop only: there is deliberately no Escape handler here, + * because the one dialog in the app does not have one today and this is a + * visual pass. Add it in the route if you want it, and say so. + */ +export function Modal({ + onClose, + label, + children, +}: { + onClose: () => void + label?: string + children: ReactNode +}) { + return ( +
      +
      e.stopPropagation()} + className={css({ + bg: 'surface.panel', + border: 'strong', + borderRadius: 'lg', + w: 'full', + maxW: 'content', + maxH: '[80vh]', + display: 'flex', + flexDirection: 'column', + overflow: 'hidden', + })} + > + {children} +
      +
      + ) +} + +/* -------------------------------------------------------------------- lists */ + +/** + * The container for `DataRow`s. + * + * Its real job is making the rows SIBLINGS, which is what lets each row drop + * its own bottom hairline through `_last` without a divider ending up flush + * against the panel's border. + */ +export function DataList({ children }: { children: ReactNode }) { + return
      {children}
      +} + +const dataRow = cva({ + base: { + display: 'flex', + gap: '3', + // The gutter lives on the row, not on a padded panel body, so the hover + // tint and the separator reach the panel's own edge. + px: '4', + py: '2.5', + borderBottom: 'hairline', + _last: { borderBottom: 'none' }, + }, + variants: { + align: { center: { alignItems: 'center' }, start: { alignItems: 'flex-start' } }, + hover: { + true: { + transitionProperty: 'colors', + transitionDuration: 'fast', + _hover: { bg: 'surface.hover' }, + }, + false: {}, + }, + }, + defaultVariants: { align: 'center', hover: false }, +}) + +// A dot is 6px against a first line that is 17-19px tall, so top-aligning it +// literally puts it above the text. 6px down is the optical centre of that line. +const rowLeading = cva({ + base: { flexShrink: 0, display: 'flex', alignItems: 'center' }, + variants: { align: { center: {}, start: { alignSelf: 'flex-start', mt: '1.5' } } }, + defaultVariants: { align: 'center' }, }) +const rowActions = cva({ + base: { display: 'flex', alignItems: 'center', gap: '2', flexShrink: 0 }, + variants: { align: { center: {}, start: { alignSelf: 'flex-start' } } }, + defaultVariants: { align: 'center' }, +}) + +/** + * One row of a list — the shape every screen here repeats. + * + * Three columns: a status marker, the body, and the actions. Fixing the columns + * is the point: a list whose left edge moves by two pixels per row reads as + * unstyled no matter what the rows contain. + */ +export function DataRow({ + leading, + title, + meta, + actions, + align = 'center', + hover, + children, +}: { + /** Usually a `Dot`. Omit it and the body starts at the gutter. */ + leading?: ReactNode + /** The identifier. One line, `body` weight medium. */ + title?: ReactNode + /** The line under it: what it resolves to, who uses it, why it is broken. */ + meta?: ReactNode + actions?: ReactNode + /** `start` for rows whose body is several lines tall; it also drops the dot onto the first line. */ + align?: 'center' | 'start' + /** Only when the row itself is a target. A hover tint on an inert row is a lie. */ + hover?: boolean + /** Anything below `meta` — a measurement, a description, a fix. */ + children?: ReactNode +}) { + return ( +
      + {leading ?
      {leading}
      : null} +
      + {title ? ( +
      + {title} +
      + ) : null} + {meta ? ( +
      + {meta} +
      + ) : null} + {children} +
      + {actions ?
      {actions}
      : null} +
      + ) +} + +/** Wraps a `DataRow` list. Shares the row's gutter so the text starts on the same edge. */ +export function Empty({ children }: { children: ReactNode }) { + return ( +

      + {children} +

      + ) +} + +/* --------------------------------------------------------------- key/value */ + +/** The container for `KeyValue`. A `
      `, because that is what this is. */ +export function KeyValueList({ children }: { children: ReactNode }) { + return ( +
      + {children} +
      + ) +} + +const keyValueValue = cva({ + base: { flex: '1', minW: '0' }, + variants: { + // Mono and sans are both 12px here, so an id and a sentence sit on the same + // line without either one jumping. + mono: { true: { textStyle: 'code' }, false: { textStyle: 'meta' } }, + tone: { + default: { color: 'content.primary' }, + neutral: { color: 'content.tertiary' }, + ok: { color: 'ok.default' }, + warn: { color: 'warn.default' }, + danger: { color: 'danger.default' }, + accent: { color: 'accent.default' }, + }, + }, + defaultVariants: { mono: false, tone: 'default' }, +}) + +/** + * A label and what it is. Describes an account, a profile, a report. + * + * The labels share one column (`sizes.keyColumn`) so the values line up; that + * alignment is the whole reason to reach for this over two spans and a middot. + */ +export function KeyValue({ + label, + mono, + tone, + children, +}: { + label: ReactNode + /** For paths, ids, hostnames — anything meant to be read character by character. */ + mono?: boolean + /** Colour the VALUE only, and only when the value is a status. */ + tone?: 'default' | Tone + children: ReactNode +}) { + return ( +
      +
      + {label} +
      + {/* + `minW: '0'` because a
      is a flex item, and a flex item's automatic + minimum size is its MIN-CONTENT: one unbreakable account name or path + sets a floor the whole page cannot shrink below. That floor is what made + the Profiles screen the only one to force a document-wide horizontal + scrollbar under an 864px viewport. + */} +
      {children}
      +
      + ) +} + +/* ----------------------------------------------------------------- controls */ + export function Button({ children, onClick, variant = 'default', disabled, + size = 'md', type = 'button', }: { children: ReactNode onClick?: () => void - variant?: 'default' | 'primary' | 'danger' + variant?: 'default' | 'primary' | 'danger' | 'ghost' disabled?: boolean + size?: 'md' | 'sm' type?: 'button' | 'submit' }) { return ( @@ -28,36 +551,85 @@ export function Button({ onClick={onClick} disabled={disabled} className={css({ - font: 'inherit', - fontSize: '13px', - fontWeight: 500, - px: '3', - height: '28px', - borderRadius: 'md', - border: '1px solid', + display: 'inline-flex', + alignItems: 'center', + gap: '1.5', + textStyle: 'body', + fontWeight: 'medium', + px: size === 'sm' ? '2' : '3', + height: size === 'sm' ? 'controlSm' : 'control', + borderRadius: 'sm', + // WIDTH AND STYLE ONLY, never the `border` shorthand: the colour is a + // variant here, so it has to stay a `borderColor` of its own, and a + // shorthand beside it would reset that colour to `currentColor` the + // moment the extractor happened to emit it last. Longhands reset + // nothing, so this composition is order-independent. + borderWidth: 'hairline', + borderStyle: 'solid', cursor: 'pointer', - transition: 'background 120ms ease, border-color 120ms ease', - _disabled: { opacity: 0.45, cursor: 'not-allowed' }, + whiteSpace: 'nowrap', + // Only the properties that actually change. `transition: all` animates + // layout too, which is how a hover state becomes a repaint of the row. + transitionProperty: 'colors', + transitionDuration: 'fast', + /** + * MUTE THE FILL, NOT THE PAIR. + * + * This was `opacity: 0.45`, which fades a button and its label together + * toward the page behind them — it does not mute a control, it dissolves + * it. Measured off the rendered pixels, a disabled primary button read + * 1.87:1 in light and 2.01:1 in dark, and that is the state the "Create + * account" button is in for the entire time the form is being filled in. + * Naming the disabled surface and the disabled ink instead keeps the + * label at 4.5:1 in both themes while the flat grey still says inactive. + * + * `&:disabled:hover` IS NOT REDUNDANT. `:hover` still matches a disabled + * button — the pointer is over it, it merely cannot be clicked — and + * Panda compiles `_hover` and `_disabled` to selectors of EQUAL + * specificity, so a variant's hover would repaint the fill this block + * just muted while the label stayed tertiary. Which of the two won would + * come down to emission order again. The compound selector settles it by + * specificity, which is the thing that does not move. + */ + '&:disabled, &:disabled:hover': { + cursor: 'not-allowed', + bg: 'surface.hover', + borderColor: 'border.default', + color: 'content.tertiary', + }, ...(variant === 'primary' ? { - bg: 'accent', - borderColor: 'accent', - color: 'white', - _hover: { bg: 'accentHover', borderColor: 'accentHover' }, + bg: 'accent.default', + borderColor: 'accent.default', + // Not `white`: in light mode the accent is dark and the label + // must be light, in dark mode the reverse. That is what the + // inverse token is for — and this used to name `surface.panel`, + // a SURFACE role deciding a text colour, which happens to + // resolve to nearly the same two values and would have silently + // repainted every primary label the day a panel changed. + color: 'content.inverse', + _hover: { bg: 'accent.hover', borderColor: 'accent.hover' }, } : variant === 'danger' ? { bg: 'transparent', - borderColor: 'line', - color: 'danger', - _hover: { bg: 'hover', borderColor: 'lineStrong' }, + borderColor: 'border.default', + color: 'danger.default', + _hover: { bg: 'danger.subtle', borderColor: 'danger.default' }, } - : { - bg: 'raised', - borderColor: 'line', - color: 'text', - _hover: { bg: 'hover', borderColor: 'lineStrong' }, - }), + : variant === 'ghost' + ? { + bg: 'transparent', + borderColor: 'transparent', + color: 'content.secondary', + _hover: { bg: 'surface.hover', color: 'content.primary' }, + } + : { + bg: 'surface.raised', + borderColor: 'border.default', + color: 'content.primary', + _hover: { bg: 'surface.hover', borderColor: 'border.strong' }, + }), })} > {children} @@ -65,6 +637,280 @@ export function Button({ ) } +const segmentTrack = cva({ + base: { + display: 'inline-flex', + alignItems: 'stretch', + bg: 'surface.hover', + borderRadius: 'sm', + p: '0.5', + gap: '0.5', + }, + variants: { + // The TRACK owns the height and the segments stretch into it, so `md` is + // exactly 32px — the same as every button beside it in a `Toolbar`. + size: { sm: {}, md: { height: 'control' } }, + // `stretch`, not `fill`: `fill` is an SVG CSS property, and a boolean prop + // named after one is a trap for any build-time extractor reading JSX props. + stretch: { true: { display: 'flex', width: 'full' }, false: {} }, + }, + defaultVariants: { size: 'md', stretch: false }, +}) + +const segment = cva({ + base: { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + font: 'inherit', + border: 'none', + borderRadius: 'xs', + cursor: 'pointer', + whiteSpace: 'nowrap', + transitionProperty: 'colors', + transitionDuration: 'fast', + bg: 'transparent', + color: 'content.tertiary', + _hover: { color: 'content.primary' }, + }, + variants: { + size: { sm: { textStyle: 'micro', px: '2', py: '1' }, md: { textStyle: 'meta', px: '3' } }, + // The selected segment is raised out of the track rather than tinted: this + // is a choice of view, not a status, and colour is reserved for status. + selected: { true: { bg: 'surface.panel', color: 'content.primary' }, false: {} }, + stretch: { true: { flex: '1' }, false: {} }, + }, + defaultVariants: { size: 'md', selected: false, stretch: false }, +}) + +/** + * One choice out of a few, all visible at once. + * + * For MUTUALLY EXCLUSIVE views — light/dark, a sort order, a mode. A filter the + * user can turn on alongside another one is not this; that is `ToggleChip`. + */ +export function SegmentedControl({ + options, + value, + onChange, + size, + stretch, + label, + labelledBy, +}: { + options: readonly { id: T; label: ReactNode }[] + value: T + onChange: (id: T) => void + /** `sm` for the sidebar, `md` (default, 32px) for anything on a page. */ + size?: 'sm' | 'md' + /** Stretch to the container's width, splitting it evenly. */ + stretch?: boolean + /** Accessible name, when the group has no visible caption of its own. */ + label?: string + /** + * The id of the visible caption naming this group — a `SectionLabel`, usually. + * + * Preferred over `label`. A group is not a form control, so there is no + * `htmlFor` that reaches it: pointing at the caption that is already on screen + * is what keeps the two from drifting into two different sentences, which is + * exactly what a duplicated `aria-label` produces. + */ + labelledBy?: string +}) { + return ( +
      + {options.map((o) => ( + + ))} +
      + ) +} + +const toggleChip = cva({ + base: { + display: 'inline-flex', + alignItems: 'center', + gap: '1.5', + height: 'control', + px: '2.5', + borderRadius: 'sm', + // Longhands, because `pressed` owns the colour. See `Button`. + borderWidth: 'hairline', + borderStyle: 'solid', + cursor: 'pointer', + whiteSpace: 'nowrap', + textStyle: 'body', + fontWeight: 'medium', + transitionProperty: 'colors', + transitionDuration: 'fast', + }, + variants: { + // A TINT, not a filled accent button. Three filled buttons in a row is + // three primary actions, and a screen with three primary actions has none. + pressed: { + true: { bg: 'accent.subtle', borderColor: 'accent.default', color: 'accent.default' }, + false: { + bg: 'surface.raised', + borderColor: 'border.default', + color: 'content.secondary', + _hover: { borderColor: 'border.strong', color: 'content.primary' }, + }, + }, + }, + defaultVariants: { pressed: false }, +}) + +/** An independently on/off filter. Several may be pressed at once. */ +export function ToggleChip({ + pressed, + onClick, + count, + children, +}: { + pressed: boolean + onClick: () => void + /** How many rows this filter would admit. Rendered subdued, after the label. */ + count?: number + children: ReactNode +}) { + return ( + + ) +} + +/** + * The one input box, as a STYLE OBJECT rather than a class string. + * + * `css.raw` is the difference between composing styles and concatenating + * classes. `cx(inputStyle, css({ textStyle: 'code' }))` put TWO composition + * classes on one element — `.textStyle_body` and `.textStyle_code`, which + * disagree about font-size and tracking — and left the winner to emission + * order inside `@layer utilities`, which is EXTRACTION order: not config order, + * not the order of the `cx` arguments. It resolved correctly only because + * App.tsx happens to mention `body` before anything mentions `code`, and + * deleting an unrelated line elsewhere could flip it with no type error and no + * failing test. Handing Panda the objects lets it merge them at build time into + * one class, which has one answer. + */ +const inputBase = css.raw({ + width: 'full', + bg: 'surface.canvas', + border: 'default', + borderRadius: 'sm', + color: 'content.primary', + textStyle: 'body', + px: '2.5', + height: 'control', + outline: 'none', + transitionProperty: 'colors', + transitionDuration: 'fast', + _hover: { borderColor: 'border.strong' }, + _focus: { borderColor: 'accent.default', bg: 'surface.panel' }, + _placeholder: { color: 'content.tertiary' }, +}) + +export const inputStyle = css(inputBase) + +/** For paths, ids and keys. One class, so there is nothing left to race. */ +export const monoInput = css(inputBase, { textStyle: 'code' }) + +/** A ` onChange(e.target.value)} + className={css(inputBase, { pl: '7' })} + /> +
+ ) +} + +/** + * The type of a control's label, spelled once. + * + * Exported because a label is not always a `Field`: a grid that shares one + * column across four inputs needs a real `