Skip to content

Share the core between CLI and web, notify on stale installs, and make web a workspace#27

Merged
jellologic merged 4 commits into
mainfrom
refactor/account-use-cases
Jul 22, 2026
Merged

Share the core between CLI and web, notify on stale installs, and make web a workspace#27
jellologic merged 4 commits into
mainfrom
refactor/account-use-cases

Conversation

@jellologic

Copy link
Copy Markdown
Owner

Four related changes, all downstream of one question: do the CLI and the web UI actually share what they should? They did not, and looking properly turned up two shipped bugs.

1. The account rules had no owner

An account holding both an API key and a session directory produced four different behaviours:

web API     refused to save it
onboard     refused to create it
the launch  silently dropped the key and used the session, no warning
the doctor  "✓ credential: no ANTHROPIC_API_KEY; this provider allows that"
            — about a config that visibly contains an API key
CLI listing rendered only the session line; the key was invisible

core/account.ts now owns it: credentialSource (which models conflict as a real state rather than normalising it away), validateAccount, one shared CONFLICT_REASON sentence, and accountsUsedBy — the reverse index that existed in three copies. formatWindow joins core/format.ts, replacing two helpers written on the same day.

Consequences, not extras: the doctor gains a credential-conflict check, the CLI calls the problem out before describing the account, and the browser says the same sentence. Verified on one config across all three surfaces.

Why not a UI port, since that was the original question: driven ports (ConfigStorePort, ProcessPort, ProviderUsagePort) are interfaces the app defines and adapters implement — that side was already right. CLI and web are driving adapters; they call in, and the port on that side is the application's use-case API itself. A ports/ui.ts both implemented would be inverted, forcing terminal text, JSON and DOM into one rendering vocabulary. What was missing was the use-case layer, smeared across config-root.ts and web/api.ts.

2. Nobody knew they were running an old swisscode

bunx swisscode serves a stale cached version — measured against the live registry: with 0.4.0 published, npx resolved 0.4.0 while plain bunx kept running a cached 0.3.0, which cannot read a v3 config and reports provider undefined.

The launch path may not check for updates: test/architecture.test.ts forbids fetch and every socket module there, and proc.replace is execve, so there is no "after launch" either. A detached spawn would put a network call in the launch's causal chain for a notice.

So it is split. config/doctor/web refresh a cached "latest version" at most once a day as a side errand; a launch reads that one file and prints a single line. Documented consequence: someone who only ever launches never refreshes the cache and never sees the notice.

Adds swisscode config upgrade and --cc-version — deliberately not --version, which belongs to the agent and which scripts depend on.

3. The browser model picker was the worse one

Both UIs are React, so it was easy to assume they shared what matters:

Ink Web (before)
Filtering core.filterModels inline copy, no free-only filter
Sorting core.rank (benchmark) none — registry order
A free model free $0.00/M
1M context 1M 1000K

That file's own header says "a catalog with no prices shows no prices, rather than '$0.00', which would read as free" — and eleven lines down it rendered exactly that.

Root cause was a hand-written type: web/src/api.ts mirrored NormalizedModel narrowly, so the browser could not call rank, which sorts on benchmarks — a field the server was already sending and the type simply hid. Same for CatalogCapabilities, whose mirror had lost requiresAuth. Both are type-only imports now. Verified in a browser against the live OpenRouter catalog.

4. web/ is a workspace

It had a tsconfig and a vite config but no manifest, so vite, panda, preact and @types/react-dom sat in the root manifest advertising themselves as dependencies of a launcher that never loads them.

One lockfile, and it has to be package-lock.json. bun install does not read it and resolves fresh — 5 transitive packages disagreed in a measured diff — and bun publish has no OIDC/provenance, so npm is permanently in the release path. bun.lock is gitignored. Bun remains a first-class runtime: bun run test passes 770/770 against an npm-installed tree.

Two things this shook out, fixed rather than worked around: npm run --workspace web x breaks under bun (which rewrites npm run to bun run and spells it --filter), and build.js hard-coded a hoisting layout the two installers do not agree on.

Verification

770 tests, 122.8 kB packed (ceiling 150). Verified under both npm and bun: install, npm ci, build, full suite, packed artifact. The browser work was driven through a real browser against a live catalog. No change to the published artifact's shape.

The CLI, the web API and the browser screen each answered the same three
questions about a provider account for themselves. That was not a tidiness
problem; the copies had already diverged into a wrong answer that shipped.

For an account holding BOTH an API key and a session directory:

  web API     refused to save it
  onboard     refused to create it
  the launch  silently dropped the key and used the session, no warning
  the doctor  reported "no ANTHROPIC_API_KEY; this provider allows that"
              about a config that visibly contains an API key
  CLI listing rendered only the session line — the stored key was invisible

Four behaviours, one rule, no owner. `core/account.ts` now owns it:
`credentialSource` (which models `conflict` as a real state rather than
normalising it away), `validateAccount`, `CONFLICT_REASON` as one shared
sentence, and `accountsUsedBy` — the reverse index that existed in three copies.
`formatWindow` joins core/format.ts, replacing `pctUsed` and `pct`, which I had
written twice on the same day.

Consequences, not extras: the doctor gains a `credential-conflict` check, the
CLI listing calls the problem out before describing the account, and the browser
says the same sentence. Verified on one config across all three surfaces.

WHY NOT A UI PORT, since that was the question. Driven ports (ConfigStorePort,
ProcessPort, ProviderUsagePort) are interfaces the app defines and adapters
implement — that side was already right. CLI and web are DRIVING adapters: they
call in, and the port on that side is the application's use-case API itself. A
`ports/ui.ts` both implemented would be inverted, and would force terminal text,
JSON and DOM into one rendering vocabulary they do not share. What was missing
was the use-case layer, which had been smeared across config-root.ts and
web/api.ts. Each adapter still owns its own words; none owns the decision.

The browser imports core/ directly and Vite bundles it, which is sound because
core/ is pure — no I/O, no node builtins — and is already inlined into dist/ui.js
for the same reason. `credentialSource` understands the redacted `hasKey: boolean`
shape the API sends to the browser, so the browser can ask the shared classifier
instead of keeping a fourth private copy.

749 tests, 119.8 kB packed. Launch path untouched: core/account.ts is reached by
the config and doctor roots, both lazy.

Signed-off-by: jellologic <[email protected]>
`bunx swisscode` serves a STALE CACHED VERSION. Measured against the live
registry: with 0.4.0 published, `npx swisscode` resolved 0.4.0 while plain
`bunx swisscode` kept running a cached 0.3.0 — which cannot read a config 0.4.0
has migrated, so it reports `provider undefined`. The config survives (an older
build refuses to write over a newer file) but the tool looks broken. Global
`npm i -g` installs have the same staleness with no cache to blame.

THE LAUNCH PATH CANNOT CHECK FOR UPDATES, and that is not a limitation to work
around — `test/architecture.test.ts` forbids `fetch` and every socket module
there, and `proc.replace` is `execve`, so there is no "after launch" to do
background work in either. A detached spawn would put a network call in the
launch's causal chain for a notice, which is the trade this project exists to
refuse.

So the check is split. `config`/`doctor`/the web UI refresh a cached "latest
published version" at most once a day as a side errand; a launch reads that one
small file and prints a single line if it is behind. No network in the launch,
direct or spawned. The honest consequence, documented rather than hidden:
someone who only ever launches never refreshes the cache and never sees the
notice.

- `core/version.ts` — comparison that answers NO for everything uncertain
  (unparseable, prerelease, missing). This interrupts a launch, so the bar is
  "we are sure". A missed notice costs nothing; a wrong one trains people to
  ignore the next.
- `adapters/store/fs-version-store.ts` — the cache, plus `installedVersion()`
  read from the manifest rather than baked in by the build, because a
  build-time constant is exactly the thing that goes stale and this value's
  only job is to be compared against the registry.
- `adapters/net/latest-version.ts` — the abbreviated packument npm's own
  installer requests. Never throws; every failure is null.
- `config upgrade` — prints what it is about to run before running it, and
  infers the install method from the module path, checking ephemeral runners
  FIRST because npx and bun caches both live inside paths containing
  "node_modules". An unrecognised location says so instead of guessing a
  command that might do something unexpected to someone's machine.

`--cc-version`, deliberately NOT `--version`: `--version` belongs to the agent
and has always printed its version, so scripts depend on it. Taking a flag we do
not own for our own convenience would be the trap a drop-in launcher must not
set.

The refresh runs with a 1500ms timeout rather than the usual 3000ms: the
command's own output is already printed by then, so the budget really buys how
long the shell prompt is held by a request nobody asked for.

770 tests, 122.5 kB packed. Launch path still under its module ceiling.

Signed-off-by: jellologic <[email protected]>
…e one

Both UIs are React, so it is easy to assume they already share what matters.
They did not. The Ink picker called `core/catalog`'s `filterModels` and `rank`
and `core/format`'s helpers; the browser picker reimplemented filtering inline
and skipped the rest. The copy was not merely duplicated, it was WORSE:

  filtering   core.filterModels          vs  inline copy, no free-only filter
  sorting     core.rank (benchmark)      vs  none — whatever order the registry
                                             happened to return
  a free model            "free"         vs  "$0.00/M"
  a 1M-context model      "1M"           vs  "1000K"

The "$0.00" case is the one worth pausing on: that file's own header says "a
catalog with no prices shows no prices, rather than '$0.00', which would read as
free" — and eleven lines down it rendered exactly that. The rule was written
once and enforced in one of the two places it applies.

Root cause was a hand-written type. `web/src/api.ts` mirrored `NormalizedModel`
as a narrower `CatalogModel` — id, name, context, pricing, tools — so the
browser could not call `rank`, which sorts on `benchmarks`, a field the server
was already sending and the type simply hid. Same for `CatalogCapabilities`,
whose mirror had silently lost `requiresAuth`. Both are now type-only imports
from `src/ports/`: they erase, cost the bundle nothing, and cannot drift.

Verified in a browser against the live OpenRouter catalog: ranked by benchmark
rather than registry order, "1.1M context" not "1100K", a working free-only
filter (14 of 342), and free models rendering as "free".

What stays local to each UI is presentation, which is the right seam: the
browser keeps `priceLabel`, because `formatPrice` correctly returns "free" and
appending "/M in" to that gives "free/M in", which is not a thing anyone says.

770 tests. Bundle unchanged at 69.6 kB — core is pure and tree-shakes.

Signed-off-by: jellologic <[email protected]>
… working

`web/` had a tsconfig and a vite config but no manifest, so its build tooling —
vite, panda, preact, @types/react-dom — sat in the root manifest advertising
itself as a dependency of a launcher that never loads any of it. It is now
`@swisscode/web`, private, owning its own devDeps.

Nothing about the published artifact changes: `files` decides that, not the
layout. Still 75 files, 122.8 kB packed.

ONE LOCKFILE, AND IT HAS TO BE package-lock.json. Not preference: `bun install`
does not read it and resolves fresh — I diffed the resulting trees and found 5
transitive packages disagreeing (lightningcss, postcss, picomatch,
@babel/code-frame) while every declared dependency matched. Small, but that is
how "works on my machine" starts. And the choice is forced anyway: `bun publish`
has no OIDC/provenance equivalent, so npm stays in the release path permanently.
`bun.lock` is gitignored so the drift cannot be committed.

Bun loses nothing by this. It remains a first-class runtime — `bun run test`
passes 770/770 against an npm-installed tree, `bunx swisscode` works, and
swisscode runs under bun. It is simply not the installer.

Two things this shook out, both fixed rather than worked around:

- `npm run --workspace web typecheck` BREAKS UNDER BUN. Bun rewrites `npm run`
  to `bun run` inside scripts and spells the same idea `--filter`, so the script
  failed with "Script not found web". The fix is to not need workspace machinery
  for what is one tsc invocation: `tsc --noEmit -p web/tsconfig.json` works
  identically everywhere. No package-manager-specific flags in `scripts`.
- build.js hard-coded `../node_modules/vite/bin/vite.js`. npm and bun do not
  agree on where workspace deps land, and either can nest on a version
  conflict, so it now looks in both `web/node_modules` and the root — the
  "works on my machine" this project runs two runtimes to avoid.

Verified end to end under both: npm install, npm ci, bun install, build, full
suite, and the packed artifact.

Signed-off-by: jellologic <[email protected]>
@jellologic
jellologic merged commit 35591ad into main Jul 22, 2026
4 checks passed
@jellologic
jellologic deleted the refactor/account-use-cases branch July 22, 2026 18:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant