This is not a formatting guide with a personality section bolted on. Formatting is the last two pages. The rest is the set of judgements that make this codebase look the way it does, written down so a contributor — human or agent — can make a change that reads like it was always there.
Read this once. After that, the codebase itself is the reference: it is small
(~7k lines of src/), consistent, and the comments explain themselves.
swisscode does something simple with something dangerous. It handles API keys, it constructs the environment another program runs in, and then it disappears. The worst bugs it can have are not crashes — they are silent successes:
- a z.ai token POSTed to OpenRouter because a flag retargeted the provider and kept the key
- an Anthropic account billed because a stale
ANTHROPIC_API_KEYwas inherited from the shell - three model tiers at a 1M window and the fourth at 200K, no error, no warning
- a typo'd
--cc-porfileforwarded to the agent as prompt text while the launch quietly used the wrong account
Every one of those looks like it worked. So the standing question when reviewing any change here is not "does this work?" but "what does this do wrong, silently?" If the answer is "nothing, because it cannot compile / cannot type / has a test", the change is in the house style.
Preferred, in order: compile error → test failure → runtime error → warning → comment. Reach for the leftmost one that can express the constraint.
The codebase does this constantly, and it is worth recognising the moves:
satisfiesat the definition site.registryinadapters/providers/registry.tsandadapters/agents/registry.tsassert their port where they are defined, not where a consumer happens to annotate. Drift becomes a compile error in the file that drifted.- Unions instead of strings.
ClaudeCodeCompatFlagis a union of the six real flags, so a misspelled flag in a descriptor or a profile is a compile error instead of a lookup that silently misses. - Discriminated unions instead of optional fields.
PlannedLaunchisLaunchNeedsSetup | LaunchPlan, discriminated onneedsSetup. That is what makesif (!planned.needsSetup) returna real narrowing — on the setup branchplanned.argsdoes not exist, rather than existing and beingundefined. readonlythat matches reality. The registries areObject.freezed, so they are typedreadonly. The previous annotation said mutable and nothing checked, so type and value had quietly disagreed since the array was written.- A conformance file the compiler runs.
test/ports.conformance.tshas no runtime assertions at all.
The compiler options are turned up for the same reason and are not negotiable:
strict, noUncheckedIndexedAccess, exactOptionalPropertyTypes,
noImplicitReturns, noFallthroughCasesInSwitch, noImplicitOverride,
erasableSyntaxOnly, verbatimModuleSyntax, isolatedModules.
exactOptionalPropertyTypes in particular changes how you write: an optional
field is conditionally assigned, never set to undefined.
// yes
const intent: LaunchIntent = { baseUrl, credential, models, skipPermissions }
if (profile?.contextWindows) intent.contextWindows = profile.contextWindows
// no — `{ contextWindows: undefined }` is not the same type as absent
const intent = { ..., contextWindows: profile?.contextWindows }Two rules that generate most of the behaviour users notice.
Never silently. If swisscode does something the user did not literally ask
for, it says so on stderr. A dropped model tier produces a tier-collapsed
warning naming exactly which tiers were ignored. An unknown agent id in a stored
profile falls back to the default and warns. An unrecognised --cc-* option is
exit 2 rather than a passthrough token, because forwarding it would put it in the
prompt while the launch used the wrong settings.
Warnings are structured ({severity, code, message}), not bare strings, because
the doctor maps info to an ok check and the others to warnings, which decides
its exit code.
Never guess. Where the data required to be correct is missing, do nothing:
- no measured context window →
CLAUDE_CODE_AUTO_COMPACT_WINDOWis not set at all. A guessed window that is too large means the conversation overflows instead of compacting - unknown provider and no
baseUrl→ refuse to launch, rather than defaulting to Anthropic and billing the wrong account --cc-providerwith no credential for the new host → exit 2. There is no "just send the key we have" fallback, and models are dropped alongside the key becauseglm-5.2sent to OpenRouter is a guaranteed 404 wearing the costume of a working config
A "sensible default" that can be wrong in a way the user cannot see is not sensible. Prefer the refusal with a message naming the fix.
This is the most visible thing about the codebase and the easiest to get wrong. The comments here are long, and they are long for one reason: they preserve the reasoning that constrains the next edit. They do not narrate the code.
A comment earns its place if it records at least one of:
- the failure it prevents — "a stale key left in your shell makes Claude Code fall back to Anthropic and bill that account"
- the alternative that was rejected, and why —
test/support/fixtures.tsenumerates three ways to type an incomplete fixture and explains what each one loses. Nobody will re-litigate that now - a non-obvious constraint — "
excludeonly filters theincludeglobs, so a type-only import silently re-adds the module to the emit" - why the obvious thing is wrong here —
LaunchErrordeclaresexitCodeby interface merging rather than as a class field, because a field declaration emits an extra statement underuseDefineForClassFields
It does not earn its place if it says what the next line says. // increment i
has no home here, and neither does a JSDoc block that restates the signature.
Two supporting habits:
- Load-bearing detail gets stated as load-bearing. "Both spellings are
Anthropic's. The choice is load-bearing rather than cosmetic —
ANTHROPIC_API_KEYtriggers Claude Code's one-time approval prompt." - Rejections are recorded where someone would re-add them.
REJECTED_PROVIDERSis a shipped constant, not a wiki page, so the reason iFlow is absent is next to the list it is absent from.
When you delete code, delete its comment. When you change what code does, the comment is part of the change — a stale "why" is worse than none, because it is believed.
Deduplicate behaviour aggressively. agents/shared.ts exists because Kilo and
OpenCode lower an intent almost identically; makeEnvWriter is the one env-write
primitive every adapter shares; LaunchDeps is declared once and imported by all
four composition roots.
The exception: prefer a checked duplicate over an import that breaks a boundary. Two live examples, both deliberate:
src/cli.tsspellsWizardModelocally instead of importing it fromconfig-root, and declares the UI bundle's shape structurally instead of queryingtypeof import(...). A staticimport typeof either would put those modules into the launch path's source graph, which is what the architecture test reads.config-root.tsdeclares itsOpenUicallback type locally for the same reason.
Neither is a blind copy. openUi is passed to runConfigCommand, so if the two
WizardMode unions ever drift the call stops compiling; and
test/ports.conformance.ts checks the structural UiModule against the real
ui-root. A duplicate is acceptable when something fails if the copies
disagree. An unchecked duplicate is a bug waiting to be found by a user.
core/ is pure because two copies of it run in one process — dist/ui.js
inlines its own — and because pure functions are the only part of a launcher you
can test exhaustively in 0.3 seconds. That is the payoff, and it is why the rules
are mechanical rather than tasteful:
- no I/O, no clock, no randomness, no
process.envreads - no top-level
letorvar— no mutable module state, ever - imports nothing outside
core/andnode:builtins at runtime
Adapters own everything the core is not allowed to touch, and they own it
narrowly: adapters/process is the only module that resolves a binary or reaches
the filesystem on behalf of a launch, which is why AgentCliPort.binary is
declarative data (name + fallbacks + override env var) rather than a function
that goes looking. That keeps adapters/agents/** filesystem-free and cheap
enough to sit on the launch path.
When something needs a dependency the core cannot have, the answer is a port, not an exception.
The reserved namespace is config | setup | --safe | --yolo | -- plus the
--cc- prefix. It does not grow. Everything else is forwarded verbatim,
which is what makes the tool a drop-in — swisscode fix the login bug has to
keep working, and so does a profile named after whatever word you like.
That constraint is why every subcommand lives under config: swisscode use
would claim an English word from the agent's prompt space forever. The cost is
six characters; the alternative is unrecoverable.
Apply the same instinct to APIs inside the codebase. core/args.ts opens with
"deliberately tiny — every token not in the table below belongs to Claude Code
verbatim", and that is a design statement, not modesty.
The full threat model is in SECURITY.md. The habits it produces:
- A credential is scoped to the host it was entered for. Any code path that moves a key must answer "to which host, and did the user enter it for that host?" Retargeting borrows the key, endpoint and models together, or fails.
- Nothing prints a key. Not masked, not truncated, not length-hinted. The doctor redacts anything a gateway echoes back, so its report is safe to paste into a bug thread. New output paths inherit this obligation.
- Clear what you do not own. A non-Anthropic launch strips
ANTHROPIC_API_KEYfrom the child environment. Both agent families implement that guard. - Secrets on disk are
0600in a0700directory, written atomically via a temp file. - Refuse, do not warn, when the cost is money or a leaked key.
See TESTING.md for mechanics. The style points:
- Architectural claims get architectural tests. Every invariant in
ARCHITECTURE.md is enforced by
test/architecture.test.ts, and it asserts on the import graph, not on wall-clock startup time, so it cannot flake on a loaded CI box. If you state a rule in a doc, add the test that keeps it true. - Golden tests are contracts. The env map each provider produces is pinned in
test/golden.test.ts. Changing one is allowed; changing one without updating the golden map is not. The diff is the point — it forces a human to look. - Test what the code actually tolerates. Fixtures are deliberately
incomplete, because
resolveProfilereally does open withstate?.profiles ?? {}and the tests exercise that tolerance on purpose. - A bug fix ships with the test that fails without it.
| Kind | Convention | Example |
|---|---|---|
| Port type | *Port |
ConfigStorePort, AgentCliPort |
| Adapter factory | create* |
createFsConfigStore, createNodeProcess |
| Pure builder | make* / build* |
makeEnvWriter, buildIntent, buildEnvPlan |
| Predicate | is* / has* |
isInsecureRemoteBaseUrl, isTier |
| Resolver | resolve* |
resolveProfile, resolveCredential |
| Constant | SCREAMING_SNAKE, frozen |
TIERS, CC_FLAGS, REJECTED_PROVIDERS |
| File | kebab-case.ts; PascalCase.tsx for components |
env-plan.ts, ModelPicker.tsx |
Names say what a thing is in the domain, not what shape it has. borrowedFrom,
consumedPositional, skipOverride and needsSetup all read as sentences at
the call site, which is the test.
There is no formatter or linter configured. That is a deliberate consequence of the dependency budget, and it means the conventions are held by hand and by review — match the file you are editing. The house style, as it actually exists:
- No semicolons. Single quotes. Trailing commas in multi-line literals.
- 2-space indent. Lines wrap around 100 columns; comment prose wraps around 80.
constby default,letonly where genuinely reassigned (and never at module top level incore/).- Named exports only. No default exports anywhere in
src/. - Arrow functions for callbacks and one-liners;
functiondeclarations for module-level definitions. - Explicit return types on exported functions. Inference is fine internally.
import typefor every type-only import —verbatimModuleSyntaxrequires it, and it is what keeps a type-only reference from becoming a runtime edge.- Relative imports name the file on disk:
'./format.ts', not'./format.js'. Node's type stripper does not remap.js→.ts, so the usual TypeScript-ESM convention would make the sources unrunnable;rewriteRelativeImportExtensionsrewrites the specifier on the way todist/. - Banned by
erasableSyntaxOnly: enums, namespaces, parameter properties,declarefields. Use unions and plain objects. - Comment style:
//for prose and file headers,/** */for a type or function whose contract needs stating. File headers are common and start by saying what the file is, then what constrains it.
If a formatter is ever added, it goes in as its own PR that touches nothing else.
Subject lines are imperative and specific, with a lowercase area prefix where one
helps (docs:, CI:). The body explains why, in the same spirit as the
comments — recent history reads Make the agent CLI a port: Claude Code as one adapter, add Kilo & OpenCode, not update files.
One concern per PR. A refactor and a behaviour change in the same diff makes the behaviour change invisible, and the behaviour change is the one that can cost someone money.
Sign off your commits: git commit -s. See
CONTRIBUTING.md.
If you remember five things:
- Ask what the change does wrong and silently, not whether it works.
- Push every constraint as far left as it goes — compile error beats test beats comment.
- Comments record decisions and rejected alternatives. Delete stale ones.
- Never guess when the correct answer is unavailable. Refuse, and name the fix.
- A credential never reaches a host it was not entered for.