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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,11 @@ web/styled-system/
# Playwright MCP writes screenshots here during manual verification
.playwright-mcp/
profiles.png

# Bun resolves fresh rather than reading package-lock.json, so a committed
# bun.lock would drift from it silently — measured at 5 transitive packages.
# package-lock.json is canonical: `npm ci` gates CI, and npm trusted publishing
# (OIDC + provenance) keeps the npm CLI in the release path permanently.
# Bun stays a first-class RUNTIME — `bun run`, `bunx` — just not the installer.
bun.lock
bun.lockb
12 changes: 12 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ runtime assertions and is checked by `tsc`. `npm run typecheck` runs it.
| `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. |
| `web/**` | An npm WORKSPACE (`@swisscode/web`, private). Owns the browser-only devDeps — vite, panda, preact, `@types/react-dom`. Ships as built assets inside the swisscode package, never as its own release. |

## Hard invariants

Expand Down Expand Up @@ -196,6 +197,17 @@ neutral `LaunchIntent` is missing something — propose that instead.

## Useful facts

- **`package-lock.json` is the only lockfile. Install with npm.** `bun install`
does not read it and resolves fresh — measured drift of 5 transitive packages
— and `bun publish` has no OIDC/provenance, so the npm CLI is permanently in
the release path (`publish.yml`). `bun.lock` is gitignored.
- **Bun is a first-class RUNTIME, not the installer.** `bun run <script>`,
`bunx swisscode` and running swisscode under bun all work against an
npm-installed tree, and CI-equivalent `bun run test` passes. Keep it that way:
no package-manager-specific flags in `scripts`. `npm run --workspace web x`
breaks under bun, which rewrites `npm run` to `bun run` inside scripts and
spells the same idea `--filter`. Plain invocations (`tsc -p web/tsconfig.json`)
work everywhere.
- Config: `~/.config/swisscode/config.json` (honours `XDG_CONFIG_HOME`), `0600`
in a `0700` directory, written atomically. v1 configs migrate on read.
- Env vars swisscode reads: `SWISSCODE_CLAUDE_BIN`, `SWISSCODE_KILO_BIN`,
Expand Down
30 changes: 29 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,16 +62,44 @@ npm install -g swisscode
**Bun works too**, both as a runner and as the runtime:

```sh
bunx swisscode
bunx swisscode@latest # the @latest matters — see below
bun install -g swisscode
```

> **`bunx swisscode` can run a stale cached version.** Measured, not assumed:
> with 0.4.0 published, `npx swisscode` resolved 0.4.0 while plain
> `bunx swisscode` kept serving a cached 0.3.0 — which cannot read a config the
> newer version has migrated, so it reports `provider undefined`. Your config is
> safe (an older build refuses to write over a newer file), but the tool looks
> broken. `bunx swisscode@latest` forces a fresh resolve, and a global install
> avoids the question entirely.

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.

### Staying current

```sh
swisscode --cc-version # which swisscode is this?
swisscode config upgrade # check npm, and install a newer one
```

`--cc-version`, not `--version`: `--version` belongs to the agent and has always
printed *its* version, so scripts depend on it. swisscode answers on the
reserved `--cc-` prefix instead of taking a flag it does not own.

When a newer release exists, a launch prints one line telling you. That notice
comes from a **cached** answer, never from a live request — the launch path is
structurally forbidden from touching the network, which is a property this tool
sells rather than an implementation detail. `swisscode config …`, the doctor and
the web UI refresh that cache (at most once a day) as a side errand. The honest
consequence: if you *only* ever launch and never run a config command, you will
not see the notice. A launcher that phoned home on every run would be a
different and worse tool.

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

```sh
Expand Down
30 changes: 24 additions & 6 deletions build.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,28 @@
// allowed to reach.
import { execFileSync } from 'node:child_process'
import { existsSync, rmSync } from 'node:fs'
import { join } from 'node:path'
import { join, resolve } 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'

/**
* Find a workspace tool, WHEREVER THE INSTALLER PUT IT.
*
* `web` is a workspace now, and npm and bun do not agree on where its
* dependencies land: npm usually hoists them to the root `node_modules`, bun
* may leave them in `web/node_modules`, and either can change its mind when a
* version conflict forces nesting. Hard-coding one path made the build work
* under one package manager and fail under the other, which is exactly the
* "works on my machine" this project runs two runtimes to avoid.
*/
function workspaceTool(relative) {
for (const base of ['web/node_modules', 'node_modules']) {
const candidate = join(base, relative)
if (existsSync(candidate)) return resolve(candidate)
}
return null
}

// Stale output is worse than no output: a deleted module would otherwise linger
// in dist/ and keep resolving.
Expand Down Expand Up @@ -63,16 +79,18 @@ await esbuild.build({
// rather than 404ing.
const webRoot = 'web'
let webBuilt = false
if (existsSync(join(webRoot, 'node_modules', '.bin', 'vite')) || existsSync('node_modules/vite')) {
const panda = workspaceTool('@pandacss/dev/bin.js')
const vite = workspaceTool('vite/bin/vite.js')
if (panda && 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'], {
execFileSync(process.execPath, [panda, 'codegen', '--config', 'panda.config.ts'], {
cwd: webRoot,
stdio: 'inherit',
})
execFileSync(process.execPath, [VITE, 'build'], { 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)')
Expand Down
25 changes: 19 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 5 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
@@ -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 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 \u2014 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"
Expand All @@ -14,6 +14,9 @@
"dist",
"README.md"
],
"workspaces": [
"web"
],
"scripts": {
"build": "node build.js",
"dev": "node build.js && tsc -p tsconfig.build.json --watch --preserveWatchOutput",
Expand Down Expand Up @@ -75,15 +78,10 @@
"react": "^19.2.0"
},
"devDependencies": {
"@pandacss/dev": "^1.11.4",
"@types/node": "^22.20.1",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.2.0",
"esbuild": "^0.28.1",
"ink-testing-library": "^4.0.0",
"preact": "^10.29.7",
"typescript": "^7.0.2",
"vite": "^8.1.5"
"typescript": "^7.0.2"
}
}
60 changes: 60 additions & 0 deletions src/adapters/net/latest-version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// Asking npm what the latest swisscode is.
//
// CONFIGURATION-TIME ONLY. This module reaches the network, so it may never
// appear on the launch path — `test/architecture.test.ts` enforces that for
// `adapters/net` already. The launcher reads the cached answer instead.
//
// Uses the ABBREVIATED packument (`application/vnd.npm.install-v1+json`), which
// is what npm's own installer requests: it omits READMEs and per-version
// metadata, so the response is a few kB rather than a few hundred. We want one
// string out of it.

/** The registry, overridable so a private mirror or a test can stand in. */
export const DEFAULT_REGISTRY = 'https://registry.npmjs.org'

export type LatestVersionOptions = {
packageName?: string
registry?: string
timeoutMs?: number
/** injected in tests; defaults to global fetch */
fetchImpl?: typeof fetch
}

/**
* The published `latest` dist-tag, or null.
*
* NEVER THROWS, and null covers every failure — offline, rate-limited, a
* private registry that does not carry this package, a shape change. This runs
* as a side errand of some command the user actually asked for, and an update
* check that could fail `config list` would be a worse bug than a stale
* version.
*/
export async function fetchLatestVersion({
packageName = 'swisscode',
registry = DEFAULT_REGISTRY,
timeoutMs = 3000,
fetchImpl = fetch,
}: LatestVersionOptions = {}): Promise<string | null> {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
try {
const response = await fetchImpl(
`${registry.replace(/\/+$/, '')}/${encodeURIComponent(packageName)}`,
{
headers: { accept: 'application/vnd.npm.install-v1+json' },
signal: controller.signal,
},
)
if (!response.ok) return null
const body: unknown = await response.json()
if (!body || typeof body !== 'object') return null
const tags = (body as { 'dist-tags'?: unknown })['dist-tags']
if (!tags || typeof tags !== 'object') return null
const latest = (tags as Record<string, unknown>).latest
return typeof latest === 'string' && latest !== '' ? latest : null
} catch {
return null
} finally {
clearTimeout(timer)
}
}
Loading