Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
d5daece
feat(store): content-hash revision, for lost-update detection
jellologic Jul 22, 2026
1a2f0af
feat(web): local config UI backend — server, security gate, JSON API
jellologic Jul 22, 2026
84bc66f
feat(web): CRUD for providers and profiles, and machine detection
jellologic Jul 22, 2026
a7c9075
feat(web): the SPA — React + TanStack-era tooling, Panda CSS, size bu…
jellologic Jul 22, 2026
aa2d2e2
feat(web): doctor on demand, model catalogs, and a working --no-open
jellologic Jul 22, 2026
241fbb1
refactor: split Profile into provider-account / agent-profile / profi…
jellologic Jul 22, 2026
c7d9f43
feat: rotation cursor, config accounts/agents, and resolution tests
jellologic Jul 22, 2026
f1e2754
feat: provider usage capability (OpenRouter), verified against the li…
jellologic Jul 22, 2026
8f1ccca
feat(web): SPA screens for accounts and agent profiles
jellologic Jul 22, 2026
874eca7
feat(accounts): session mode — authenticate with a login the agent al…
jellologic Jul 22, 2026
ea19d7c
feat(accounts): read who a session is logged in as, and adopt one wit…
jellologic Jul 22, 2026
7bd18fa
fix(session): naming the default directory must UNSET CLAUDE_CONFIG_D…
jellologic Jul 22, 2026
c2153d7
feat(usage): read a subscription's real remaining capacity
jellologic Jul 22, 2026
e5a6164
feat(usage): wire measurement to selection — `config accounts usage`
jellologic Jul 22, 2026
cae77fc
feat(doctor): diagnose a session login, and actually refresh the usag…
jellologic Jul 22, 2026
9017eb7
feat(web): session accounts and measured usage in the browser
jellologic Jul 22, 2026
04b7b48
feat(accounts): targeted swap — move one login into one session direc…
jellologic Jul 22, 2026
fce943c
docs: subscription accounts, the v3 shape, and the credential-writing…
jellologic Jul 22, 2026
ed78d9f
test: use the sanctioned fixtures instead of `as unknown as State`
jellologic Jul 22, 2026
ff606fd
build: halve the published artifact — 239.9 kB packed to 118.4 kB
jellologic Jul 22, 2026
ee72886
docs: document Bun support, and refresh npm/GitHub metadata for what …
jellologic Jul 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,16 @@ jobs:
run: npm ci

# `npm test` is the whole gate on purpose: tsc --noEmit (which is what
# runs test/ports.conformance.ts), then the two-stage build, then
# node --test over the suite. Splitting it into separate steps would let
# the three UI suites run against a stale dist/.
# runs test/ports.conformance.ts), then the build, then node --test over
# the suite. Splitting it into separate steps would let the three UI
# suites run against a stale dist/.
- name: Test
run: npm test

# What users actually download. Shipping the web UI means everyone gets a
# React bundle they may never open; the budget keeps that a decision that
# is re-made on every PR rather than one that drifts. One cell is enough —
# the tarball is identical across the matrix.
- name: Size budget
if: matrix.os == 'ubuntu-latest' && matrix.node == 24
run: npm run size
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,8 @@ dist/
*.log
.DS_Store
.playwright-mcp/
web/styled-system/

# Playwright MCP writes screenshots here during manual verification
.playwright-mcp/
profiles.png
25 changes: 20 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,8 @@ runtime assertions and is checked by `tsc`. `npm run typecheck` runs it.
|---|---|
| `src/core/**` | Pure domain logic. No I/O, no top-level `let`/`var`, imports nothing outside `core/` and `node:` builtins at runtime. |
| `src/ports/**` | Interfaces only. Every file must erase to exactly `export {}`. |
| `src/adapters/**` | Implementations: providers, agent CLIs, catalogs, fs, process, net, clock, doctor probe, Ink UI. |
| `src/composition/**` | Four composition roots: `launch-root` (hot path), `config-root`, `doctor-root`, `ui-root`. |
| `src/adapters/**` | Implementations: providers, agent CLIs, catalogs, fs, process, net, clock, doctor probe, session logins, usage, web API, Ink UI. |
| `src/composition/**` | Five composition roots: `launch-root` (hot path), `config-root`, `doctor-root`, `ui-root`, `web-root`. |
| `bin/swisscode.js` | Published entry shim. Plain JS, never compiled, imports exactly `../dist/cli.js`. |
| `test/**` | `.ts`, run from source, never compiled, never packed. |

Expand All @@ -63,9 +63,13 @@ not a patch.

1. **The launch path** — the static import closure rooted at `src/cli.ts` — never
reaches React, Ink, any `.tsx`, `node_modules`, `adapters/ui`,
`adapters/catalog`, `config-root`, or the doctor. It never calls `fetch` and
never imports `node:http`/`https`/`net`/`tls`/`dgram`. It stays under 40
modules.
`adapters/catalog`, `adapters/usage`, `adapters/claude-session`,
`config-root`, or the doctor. It never calls `fetch` and never imports
`node:http`/`https`/`net`/`tls`/`dgram`. It stays at or under 42 modules.
Session inspection is excluded for a sharper reason than bundle size:
`.claude.json` runs to ~200 kB on a well-used account and reading a
credential can raise a keychain prompt, so a launch lowers a session
directory to an env var **without opening it**.
**The only sanctioned escape hatch is a dynamic `import()` from `src/cli.ts`.**
2. **`core/` is pure**: no I/O, no clock, no `process.env`, no top-level mutable
state. `dist/ui.js` inlines its own copy of `core/`, so module state would
Expand Down Expand Up @@ -205,6 +209,17 @@ neutral `LaunchIntent` is missing something — propose that instead.
- CI (`.github/workflows/ci.yml`) runs `npm test` on every push to `main` and
every PR, across Node 22 + 24 on ubuntu and macOS. `publish.yml` runs it again
on a `v*` tag before publishing via npm trusted publishing (OIDC, no token).
- A Claude subscription is a **login**, not a key: an account carries either
`apiKey`/`apiKeyFromEnv` or `configDir`, never both, and the validator refuses
the combination rather than picking one. Naming the default `~/.claude`
**unsets** `CLAUDE_CONFIG_DIR` rather than writing the path — Claude Code
branches on whether the variable is set, not on its value, so writing it is a
different and empty login. `isDefaultConfigDir` owns that question; do not
re-derive it.
- Exactly one module writes a credential (`adapters/claude-session/swap.ts`) and
it writes a `0600` file, never the keychain: `security add-generic-password`
either puts the secret in argv or truncates stdin at 128 bytes. Read
`docs/SECURITY.md` before touching it.
- A provider needing no real credential sets `credentialOptional: true` plus
`defaultCredential` — the placeholder ships in source, is explicitly **not** a
secret, and is excluded from the doctor's redaction set for that reason.
178 changes: 167 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,19 @@ invocation, which you'll feel on a tool you launch dozens of times a day:
npm install -g swisscode
```

**Bun works too**, both as a runner and as the runtime:

```sh
bunx swisscode
bun install -g swisscode
```

Nothing special was needed for this: swisscode is plain ESM with no native
addons, and `process.execve` — the call that makes handoff free — exists on Bun
as well as Node. Verified by resolving a full launch plan under both and
diffing: same profile, same agent, same provider, and a byte-identical set of
`ANTHROPIC_*` / `CLAUDE_CODE_*` variables. Deno is untested.

Every argument that isn't listed below is forwarded to `claude` untouched:

```sh
Expand Down Expand Up @@ -304,6 +317,135 @@ stderr when it is on — it is not shipped on by default, because a preset that
quietly removed a feature to dodge an upstream bug would be the kind of silent
behaviour this tool exists to avoid.

## Claude subscription accounts

Everything above assumes an API key. A Claude Pro or Max subscription is not a
key — it is a **login**, stored by Claude Code in your keychain and pointed at by
`CLAUDE_CONFIG_DIR`. swisscode can hold several of them and pick between them.

An account is one or the other, never both; a config naming a key *and* a
session directory is refused rather than resolved by precedence.

```sh
swisscode config accounts login work # make a session dir, run /login inside
swisscode config accounts login personal --dir ~/.claude # adopt the login you already have
swisscode config accounts # who each account is, no keychain prompt
```

`login` creates `~/.config/swisscode/accounts/<name>` at `0700`, then runs the
agent there so you can complete `/login` once. After that the account is a
normal thing profiles can reference.

> **Naming `~/.claude` means *unsetting* `CLAUDE_CONFIG_DIR`, not setting it to
> that path.** Claude Code chooses its keychain item on whether the variable is
> *set*, not on its value — so `CLAUDE_CONFIG_DIR="$HOME/.claude"` is a
> different, empty login from the one you use every day. swisscode handles this
> for you; it is documented because the failure is silent. Identity comes from a
> shared `~/.claude.json`, so a tool that gets this wrong reports the *correct*
> email for a session that cannot authenticate.

### How much is left

```sh
swisscode config accounts usage
```

Reads each subscription's real 5-hour and 7-day windows from Anthropic — the
same figures `/usage` shows you, for every account at once, without switching
into each one to look. It caches them, and a profile with
`"strategy": "usage"` then launches on whichever account has the most left:

```json
{ "agentProfile": "default", "accounts": ["personal", "work"], "strategy": "usage" }
```

Ranking uses 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,
and averaging to 50% would send work straight at it. An account that cannot be
measured is treated as *unknown* and passed over, never as *full*.

`swisscode config doctor` refreshes the same cache, and the Accounts screen in
the web UI has a button for it. Nothing measures on the launch path — a launch
resolves a directory and hands it to the agent without opening it, which is why
an ordinary `swisscode` never raises a keychain prompt.

### Switching a running session

```sh
swisscode config accounts swap --into work personal
```

`/login` writes **one global slot**, and every running Claude Code re-reads it
within about 30 seconds — which is why switching accounts in one terminal
switches all of them, and why an artifact created just after a switch can land
on the wrong account. `swap` writes **one directory's** slot instead. Sessions
using any other directory are unaffected.

Three things to know:

- A session already running in that directory picks it up **within ~30s**, not
instantly. swisscode cannot reach into a running process; the agent re-reads
its credential on its own timer.
- Anything that session creates *after* that point belongs to the new account.
- Overwriting a *different* account's login needs `--yes`.

### Where this sits with Anthropic

Stated plainly, because you are entitled to decide for yourself:

- **Rotating between subscriptions you pay for is fine. Sharing or reselling one
is not.** swisscode is structurally incapable of the second: it never sits in
the request path, holds no server, and proxies nothing. It sets environment
variables and calls `execve`.
- **`CLAUDE_CONFIG_DIR` and the usage endpoint are undocumented.** Both work,
and per-directory isolation is the mechanism Claude Code itself implements,
but neither is a contract. If Anthropic changes them, expect the measurement
to degrade to "unknown" rather than to lie — every parse here fails to null.
- **Per-launch selection is the well-trodden part; `swap` is not.** Choosing an
account before starting the agent is ordinary. Overwriting a live session's
credential is doing programmatically what `/login` already does by hand — same
official client, same accounts you pay for — but it is not something Anthropic
has explicitly blessed.
- **`claude -p` bills a credit pool**, not the subscription window, so the
5-hour figure describes interactive sessions. No behaviour change; just don't
read the number as covering headless runs.

## Web UI

The CLI is not the only way in. `swisscode config web` serves a local
configuration UI — profiles, providers, agents, model tiers, compatibility
flags and settings, everything the CLI can express:

```sh
swisscode config web # prints a URL; Ctrl-C to stop
swisscode config web --port 7391
```

It is a **singleton by construction**: the port bind is the mutex, so a second
instance tells you one is already running rather than racing it. It binds
`127.0.0.1` only, never `0.0.0.0`.

**Your keys never reach the browser.** The page is told whether a key is *set*,
never what it is, and editing is write-only: leaving the field blank keeps the
stored key, and clearing one is a separate, explicit action. That is the same
rule `config doctor` follows.

Because a browser will talk to `localhost` on behalf of any page you have open,
the server checks the `Host` header against an exact allowlist (this is the
DNS-rebinding defence — the connection genuinely comes from loopback, so nothing
at the socket level can tell an attack apart), validates `Origin`, and requires a
per-run token in a custom header, which forces a CORS preflight it never answers.

Edits are checked against the revision they were based on, so a browser tab left
open while you run `swisscode config work` in a terminal gets a conflict rather
than silently overwriting it.

You can also define **your own providers** here — a gateway or local server
swisscode ships no preset for. They are validated on save with the same rules the
shipped presets are tested against: no `/v1` suffix, no hand-typed `[1m]`, real
compatibility flags. Shipped presets stay read-only, and a custom provider cannot
shadow one.

## Agents

The **provider** is which model backend you talk to; the **agent** is which
Expand Down Expand Up @@ -399,11 +541,14 @@ holds an API key in plaintext.

```json
{
"version": 2,
"profiles": {
"openrouter": {
"provider": "openrouter",
"apiKey": "sk-or-…",
"version": 3,
"providerAccounts": {
"openrouter": { "provider": "openrouter", "apiKey": "sk-or-…" },
"personal": { "provider": "anthropic", "configDir": "/Users/me/.claude" }
},
"agentProfiles": {
"default": {
"agent": "claude-code",
"models": {
"opus": "openrouter/fusion",
"sonnet": "…",
Expand All @@ -416,21 +561,32 @@ holds an API key in plaintext.
"env": { "ANY_EXTRA_VAR": "value" }
}
},
"defaultProfile": "openrouter",
"profiles": {
"work": { "agentProfile": "default", "accounts": ["openrouter"], "strategy": "single" }
},
"defaultProfile": "work",
"bindings": { "/Users/me/clients/acme": "acme" },
"settings": { "quiet": false, "bindingWalkDepth": 40 }
}
```

Three separate things, because they vary independently. A **provider account**
is who pays — a key, or a subscription login. An **agent profile** is what runs
— which CLI, which model per tier, which flags. A **profile** pairs them and
says how to choose when it names more than one account (`single`, `round-robin`,
or `usage`). One agent profile can be shared by several profiles that bill
different accounts, which is the arrangement the older flat shape could not
express.

`bindings` records absolute paths, which means client names and project layout.
That's new non-credential information in this file — worth remembering before
pasting it into a bug report.

A config written by swisscode 0.1.0 — a single flat object with a top-level
`provider` — is migrated to this shape automatically the first time a newer
version reads it. The original is kept beside it as `config.v1.bak.json`, and a
migration that cannot be written to disk is used in memory rather than blocking
the launch.
Older configs migrate automatically the first time a newer version reads them —
v1's single flat object with a top-level `provider`, and v2's profiles that
carried credential and agent settings together. The original is kept beside it
as `config.v1.bak.json`, and a migration that cannot be written to disk is used
in memory rather than blocking the launch.

Claude Code has **four** model tiers. A tier you leave out inherits the
provider's default; a tier set to the empty string is explicitly unset. Setting
Expand Down
39 changes: 38 additions & 1 deletion build.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,12 @@
// allowed to reach.
import { execFileSync } from 'node:child_process'
import { existsSync, rmSync } from 'node:fs'
import { join } from 'node:path'
import * as esbuild from 'esbuild'

const TSC = 'node_modules/typescript/bin/tsc'
const PANDA = '../node_modules/@pandacss/dev/bin.js'
const VITE = '../node_modules/vite/bin/vite.js'

// Stale output is worse than no output: a deleted module would otherwise linger
// in dist/ and keep resolving.
Expand Down Expand Up @@ -41,7 +44,41 @@ await esbuild.build({
format: 'esm',
target: 'node22',
jsx: 'automatic',
// Minified, unlike stage 1's output. This one is already a single opaque
// blob that nothing on the launch path may reach and no human reads module by
// module, so the auditability argument that keeps `dist/` unbundled does not
// apply to it — and it halves, 74.5 kB to 38.2 kB.
minify: true,
external: ['ink', 'react', 'react/jsx-runtime', 'ink-select-input', 'ink-text-input'],
})

console.log(`built dist/ (tsc) and dist/ui.js (esbuild, from ${uiRoot})`)
// Stage 3. The web UI, built by Vite into dist/web.
//
// Its whole toolchain — vite, react-dom, Panda — is a devDependency and none of
// it ships: `files` is bin/dist/README, so users receive the emitted assets and
// nothing that produced them. The runtime dependency count is unchanged.
//
// Skipped when the toolchain is absent so `npm ci --omit=dev` and a published
// tarball rebuild both still work; the server falls back to a page that says so
// rather than 404ing.
const webRoot = 'web'
let webBuilt = false
if (existsSync(join(webRoot, 'node_modules', '.bin', 'vite')) || existsSync('node_modules/vite')) {
// Panda is CODEGEN, and it has to run before Vite: the generated
// styled-system/ is what the app imports, and its PostCSS plugin is what
// fills the @layer declarations. Skipping it produces a build that succeeds
// and a page that renders completely unstyled.
execFileSync(process.execPath, [PANDA, 'codegen', '--config', 'panda.config.ts'], {
cwd: webRoot,
stdio: 'inherit',
})
execFileSync(process.execPath, [VITE, 'build'], { cwd: webRoot, stdio: 'inherit' })
webBuilt = true
} else {
console.log('skipped dist/web (frontend toolchain not installed)')
}

console.log(
`built dist/ (tsc), dist/ui.js (esbuild, from ${uiRoot})` +
(webBuilt ? ' and dist/web (vite)' : ''),
)
Loading
Loading