From 3275bf5ca190577e6355bb13aaf966d50de90baf Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 1 Aug 2026 15:09:29 +0900 Subject: [PATCH 1/7] feat(export): add client-neutral config export core Phase 010 of devlog/_plan/260731_client_config_export. Lifts the OpenCode provider-block builder out of the launcher into a client-neutral module and adds a Pi serializer beside it. `ocx opencode` keeps every export it had and its launch path is untouched; the builder now has one body shared by the launcher and future export surfaces. The OpenCode output is pinned by a golden captured from the pre-refactor implementation. Pi emits a models array, always omits `cost` (we have no price data and zeros would assert "free"), and omits context/maxTokens when no authoritative context window exists. Neither serializer ever emits a live key: OpenCode carries `{env:OPENCODEX_OPENCODE_API_KEY}`, Pi carries `$OPENCODEX_API_KEY`. --- .../260731_client_config_export/000_plan.md | 99 +++++ .../001_client_config_survey.md | 155 +++++++ .../002_existing_surface_inventory.md | 91 +++++ .../003_export_ux_design.md | 190 +++++++++ .../010_export_core.md | 176 ++++++++ .../020_cli_surface.md | 103 +++++ .../030_management_api.md | 95 +++++ .../040_gui_panel.md | 139 +++++++ src/cli/opencode.ts | 187 ++------- src/clients/config-export.ts | 377 ++++++++++++++++++ tests/client-config-export.test.ts | 335 ++++++++++++++++ 11 files changed, 1796 insertions(+), 151 deletions(-) create mode 100644 devlog/_plan/260731_client_config_export/000_plan.md create mode 100644 devlog/_plan/260731_client_config_export/001_client_config_survey.md create mode 100644 devlog/_plan/260731_client_config_export/002_existing_surface_inventory.md create mode 100644 devlog/_plan/260731_client_config_export/003_export_ux_design.md create mode 100644 devlog/_plan/260731_client_config_export/010_export_core.md create mode 100644 devlog/_plan/260731_client_config_export/020_cli_surface.md create mode 100644 devlog/_plan/260731_client_config_export/030_management_api.md create mode 100644 devlog/_plan/260731_client_config_export/040_gui_panel.md create mode 100644 src/clients/config-export.ts create mode 100644 tests/client-config-export.test.ts diff --git a/devlog/_plan/260731_client_config_export/000_plan.md b/devlog/_plan/260731_client_config_export/000_plan.md new file mode 100644 index 000000000..f6ee73c9f --- /dev/null +++ b/devlog/_plan/260731_client_config_export/000_plan.md @@ -0,0 +1,99 @@ +# 000 — Client config export for Pi and OpenCode + +Unit: `devlog/_plan/260731_client_config_export/` +Opened: 2026-07-31 · Work class: C3 · Branch target: `dev` + +## Objective + +Let a user or an agent take the opencodex model catalog out of the proxy as a +ready-to-use custom-provider config for Pi and OpenCode, through four surfaces +that all serve the same bytes: CLI text, CLI `--json`, management API, and a GUI +panel with copy + JSON download. + +## Why this unit exists + +The proxy already speaks the wire every client understands. What it does not do is +hand over the *metadata* — which models exist, what they are called, how large +their context is — in the dialect each client's config file expects. Today a user +wanting opencodex in Pi writes that JSON by hand, and gets it wrong, because model +ids are namespaced and context windows are not guessable. + +`ocx opencode` solves half of this already, in memory, at launch, for one client +(`002`). This unit turns that private capability into an artifact and adds Pi. + +## Constraints + +| Constraint | Source | +|-----------|--------| +| Never serialize a live key; emit an env reference | AGENTS.md security boundary; `001` §3 | +| Never write or rewrite a user's client config file | existing `ocx opencode` design; `002` §2 | +| Never guess model metadata; omit what we do not know | existing `limit`-drop rule; `002` §4 | +| One payload, four presentations | `003` §2 | +| OpenCode V1 schema only | `001` §2 | +| Bun-native TypeScript, `dev` branch | AGENTS.md | + +## Scope boundary + +IN +- Client-neutral export core with OpenCode + Pi serializers. +- `ocx export --client [--json] [--out]`. +- `GET /api/client-config?client=`. +- GUI panel: client switch, JSON preview, copy, download. +- Tests at each phase; docs-site update when the CLI surface lands. + +OUT +- OpenCode V2 schema migration (recorded in `001`, not built). +- Automatic merging into an existing user config file. +- Any client beyond Pi and OpenCode. Claude Desktop already has its own path. +- Changes to routing, adapters, or the catalog itself. +- Pricing metadata. We do not have it; `cost` is omitted rather than faked. + +## Work-phase map + +Dependency-ordered. Each phase is one full PABCD cycle and closes with something +independently verifiable. + +| Phase | Doc | Delivers | Verified by | +|-------|-----|----------|-------------| +| 1 | `010_export_core.md` | `src/clients/config-export.ts`; OpenCode builder relocated, Pi serializer added | unit tests + golden diff against pre-refactor output | +| 2 | `020_cli_surface.md` | `ocx export` with `--json` / `--out` | CLI tests; stdout parses clean under `--json` | +| 3 | `030_management_api.md` | `GET /api/client-config` | route test; payload identical to CLI `--json` | +| 4 | `040_gui_panel.md` | GUI panel with copy + download | GUI test + rendered check | + +The order is the build order, not a schedule: `020`, `030`, `040` each import the +core, and `040` renders what `030` returns. Nothing later can be built first. + +## Research documents + +| Doc | Contents | +|-----|----------| +| `001_client_config_survey.md` | Pi + OpenCode V1/V2 schemas, injection points, absence of a standard, V1 decision | +| `002_existing_surface_inventory.md` | What `ocx opencode` already does, the corrected gap, catalog metadata availability | +| `003_export_ux_design.md` | Design Read, dials, Lazy-User Gate, five UX states, download rules, placement | + +## Open assumptions + +1. **Pi schema is unverified against a real install.** Taken from published docs + only (`001` §2). The cycle that ships Pi must diff against a real + `~/.pi/agent/models.json` or ship the Pi client marked experimental. +2. **The API page layout rework may or may not have landed** when `040` runs. + `003` §6 gives both placements; the panel is layout-independent. +3. **32k output budget** is a schema stand-in inherited from `ocx opencode`, not a + per-model truth. If an authoritative max-output field ever reaches + `CatalogModel`, both serializers should switch to it. + +## Acceptance + +The unit is DONE when all four phases have closed with their evidence, `bun run +typecheck` and `bun run test` are green, `bun run privacy:scan` is green, and a +config exported from each of the four surfaces is byte-identical for the same +client and catalog state. + +Terminal outcomes other than DONE: `BLOCKED` if Pi's real schema contradicts the +survey badly enough to need a new design; `NEEDS_HUMAN` if a decision outside this +plan's boundary appears. + +## Status + +Roadmap written 2026-07-31 during the docs-only cycle. No implementation has +started; no production file has been modified by this unit. diff --git a/devlog/_plan/260731_client_config_export/001_client_config_survey.md b/devlog/_plan/260731_client_config_export/001_client_config_survey.md new file mode 100644 index 000000000..6f9b47da0 --- /dev/null +++ b/devlog/_plan/260731_client_config_export/001_client_config_survey.md @@ -0,0 +1,155 @@ +# 001 — External client config survey + +Research only. No diffs here; implementation designs live in the decade docs. + +Scope of the survey: where a user (or an agent) drops a custom OpenAI-compatible +provider so that Pi and OpenCode can call the opencodex proxy, what JSON those +files require, and whether any cross-vendor standard governs the shape. + +## 1. Is there a standard? + +No. There is no formal specification body governing agent-client provider +configuration. What exists instead is a stack of de-facto layers: + +| Layer | What it standardizes | Who honors it | +|-------|----------------------|---------------| +| OpenAI `/v1` wire (`/chat/completions`, `/responses`, `/models`) | request/response bytes | effectively everyone | +| Anthropic `/v1/messages` wire | request/response bytes | Claude clients, our Messages surface | +| `@ai-sdk/openai-compatible` (Vercel AI SDK) | adapter package name + option names | OpenCode, several TS agents | +| models.dev | model metadata registry (context, cost, modality) | OpenCode, Pi metadata seeds | +| per-client JSON Schema (`https://opencode.ai/config.json`) | one client's config file only | that client | + +The consequence for this unit: the transport half is already solved — the proxy +speaks `/v1` and clients speak `/v1`. Everything left is **metadata translation** +into per-client dialects, and each dialect is a moving target owned by one vendor. + +Critically, **models.dev does not populate custom providers.** A user-defined +provider block gets no inherited context window, no pricing, no modality flags. +Whatever we do not emit ourselves, the client simply does not know. + +## 2. Where custom providers are injected + +### OpenCode + +Three layers, highest priority first: + +| Layer | Location | Notes | +|-------|----------|-------| +| Inline runtime | `OPENCODE_CONFIG_CONTENT` env var | outranks every file; what `ocx opencode` uses today | +| Project | `/opencode.json` or `.jsonc` | committed per repo | +| Global | `~/.config/opencode/opencode.json`, XDG-aware via `XDG_CONFIG_HOME` | the file most users hand-edit | + +JSONC is accepted for the file layers (comments must survive a round trip, which +is why we never rewrite a user's file — see `002`). + +V1 schema (current, what we generate): + +```json +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "opencodex": { + "npm": "@ai-sdk/openai-compatible", + "name": "opencodex", + "options": { "baseURL": "http://127.0.0.1:10100/v1", "apiKey": "{env:OPENCODEX_OPENCODE_API_KEY}" }, + "models": { + "anthropic/claude-opus-5": { "name": "Claude Opus 5", "limit": { "context": 200000, "output": 32000 } } + } + } + } +} +``` + +V2 schema (documented at `opencode.ai/v2/docs/models`) renames nearly every key: + +| Concept | V1 | V2 | +|---------|----|----| +| container | `provider` | `providers` | +| adapter | `npm: "@ai-sdk/openai-compatible"` | `package: "aisdk:@ai-sdk/openai-compatible"` | +| endpoint | `options.baseURL` | `settings.baseURL` | +| upstream id | `models..id` | `models..modelID` | +| credentials | `options.apiKey` / auth store | `env[]` | +| new in V2 | — | `capabilities`, `variants`, `disabled` | + +**Decision: stay on V1.** The shipped launcher already emits V1 and that path is +exercised by tests; switching would break it in exchange for `capabilities` and +`variants` fields we have no authoritative data to fill. V2 is recorded here so a +later migration starts from a written diff rather than a re-survey. + +### Pi + +Single global file: `~/.pi/agent/models.json`. + +```json +{ + "providers": { + "opencodex": { + "baseUrl": "http://127.0.0.1:10100/v1", + "api": "openai-completions", + "apiKey": "$OPENCODEX_API_KEY", + "models": [ + { + "id": "anthropic/claude-opus-5", + "name": "Claude Opus 5", + "reasoning": false, + "input": ["text"], + "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }, + "contextWindow": 200000, + "maxTokens": 32000 + } + ] + } + } +} +``` + +Shape differences that matter for the serializer: + +- `models` is an **array**, not a keyed object. Model identity lives in `id`. +- `cost` demands all four fields when present. +- `apiKey` supports `$ENV_VAR` interpolation (bare `$NAME`, not `{env:NAME}`). +- `api` selects the wire dialect; `openai-completions` is ours. + +**Verification status: UNVERIFIED against a real installation.** The shape above +comes from Pi's published `models.md` and `pi.dev/docs/latest/custom-provider`. +No `~/.pi/agent/models.json` was read on this machine. The first implementation +cycle touching Pi must diff this against a real file before shipping. + +### Cross-client field map + +| Concept | Pi | OpenCode V1 | +|---------|----|-------------| +| container | `providers` | `provider` | +| adapter | `api: "openai-completions"` | `npm: "@ai-sdk/openai-compatible"` | +| endpoint | `baseUrl` | `options.baseURL` | +| model container | array | keyed object | +| context | `contextWindow` | `limit.context` | +| max output | `maxTokens` | `limit.output` | +| price | `cost{4}` | absent | +| modality | `input[]` | absent | +| key reference | `$VAR` | `{env:VAR}` | + +Only four concepts are universal: endpoint, adapter kind, model id, and the +context/output pair. Everything else is one client's luxury. + +## 3. Credential handling + +All three injection paths support an environment reference, so no exporter needs +to serialize a live `ocx_...` key. Pi takes `$VAR`, OpenCode takes `{env:VAR}`, +and OpenCode additionally has an auth store. AGENTS.md treats token serialization +as a release blocker, so emitting a reference is not merely polite — writing the +plaintext key into an exported file would be a security-review failure. + +The exported artifact therefore carries a variable name, and every surface that +hands the file over must also tell the user which variable to export. + +## 4. Sources + +- Pi: `github.com/earendil-works/pi` `packages/coding-agent/docs/models.md`; `pi.dev/docs/latest/custom-provider` +- OpenCode V1: `opencode.ai/docs/providers`, `opencode.ai/config.json` +- OpenCode V2: `opencode.ai/v2/docs/models` +- AI SDK adapter: `@ai-sdk/openai-compatible` +- Metadata registry: `models.dev` + +All external claims were read during the 2026-07-31 P phase. Client schemas drift; +re-verify before any cycle that changes serializer output. diff --git a/devlog/_plan/260731_client_config_export/002_existing_surface_inventory.md b/devlog/_plan/260731_client_config_export/002_existing_surface_inventory.md new file mode 100644 index 000000000..ea431b20d --- /dev/null +++ b/devlog/_plan/260731_client_config_export/002_existing_surface_inventory.md @@ -0,0 +1,91 @@ +# 002 — What already exists, and what the real gap is + +Research only. Written because the first pass of this unit's P phase proposed +building things that were already shipped. Recording the correction here so a +later cycle's P does not repeat the same miss. + +## 1. Correction to the initial survey + +The opening research turn concluded that OpenCode export had to be built from +scratch and listed three "design blockers" (slash-bearing model ids, credential +leakage, missing `limit.output`). All three were already solved in +`src/cli/opencode.ts`, a 701-line module that predates this unit. + +| Claimed blocker | Actual state | +|-----------------|--------------| +| `provider/model` ids produce a double slash | `opencodeModelKey()` emits `provider/id` for routed and a bare slug for native; OpenCode splits on the FIRST slash, so the nested id survives | +| Exported config would leak `ocx_...` | Only `{env:OPENCODEX_OPENCODE_API_KEY}` is serialized; the real value rides the child process env | +| `limit.output` has no authoritative source | `SCHEMA_REQUIRED_OUTPUT_BUDGET` (32k, clamped to context) fills the schema-required half, and the whole `limit` block is dropped when no authoritative context exists | + +The lesson is procedural: a survey of external formats is not a survey of our own +tree. Both halves are required before a phase map is credible. + +## 2. Inventory of the shipped surface + +### `src/cli/opencode.ts` + +| Export | Role | +|--------|------| +| `buildOpencodeProviderBlockFromCatalog` | catalog rows -> V1 provider block. The real serializer core | +| `buildOpencodeProviderBlock` | test-facing wrapper over the same builder | +| `opencodeModelKey` | `provider/id` vs bare native slug | +| `opencodeProxyBaseUrl` | `http://:/v1` | +| `opencodeCatalogFromProxyRows` | `GET /api/models` rows -> catalog entries, drops disabled + dupes | +| `mergeOpencodeRuntimeConfig` | merges inherited inline config, overrides only `provider.opencodex` | +| `buildOpencodeConfig` / `serializeOpencodeRuntimeConfig` | assemble + stringify the inline runtime layer | +| `opencodeGlobalConfigPath` / `opencodeProviderOverridePath` / `projectConfigOverridesProvider` | detect (never write) user files | + +Design properties worth preserving verbatim: + +- **Never writes user config.** File layers are read for detection only; injection + goes through `OPENCODE_CONFIG_CONTENT`. This avoids clobbering comments, + relative `{file:...}` paths, and unrelated MCP credentials. +- **Never guesses metadata.** Absent context window means no `limit` block, not a + fabricated one. +- **Never serializes a secret.** + +### Adjacent precedents + +- `src/cli/claude-desktop.ts` — writes a Claude Desktop 3P config. +- `gui/src/pages/ClaudeDesktop.tsx:313` — `exportProfile()` does Blob + + `URL.createObjectURL` + `anchor.download`, then announces to a live region. + This is the house pattern for browser-side JSON download and the 040 phase + reuses it rather than inventing a second one. +- `src/cli/access.ts` — the `ocx access ...` family establishes the CLI contract + this unit extends: `--json` on every subcommand, `printData(value, wantsJson, lines)` + emitting either raw JSON or human lines from the SAME value. +- `src/server/management/model-routes.ts:115` — `GET /api/models`, the authenticated + route whose rows already feed the OpenCode builder. + +## 3. The actual gap + +`ocx opencode` produces a provider block **only in memory, only at launch**, and +only for OpenCode. Three things are missing: + +1. **No artifact.** A user who wants the provider block in their own + `~/.config/opencode/opencode.json` cannot get it out of opencodex. There is no + command that prints it and no file to download. +2. **No Pi support at all.** No serializer, no command, no surface. +3. **No shared abstraction.** The builder is private to the launcher module and + shaped around one client's schema, so a second client cannot reuse it as-is. + +Everything this unit builds is downstream of closing those three. The transport, +the catalog, and the security posture are already correct. + +## 4. Catalog metadata availability + +Checked because every serializer depends on it: + +| Field | Source | Available? | +|-------|--------|-----------| +| context window | `CatalogModel.contextWindow`; `NATIVE_OPENAI_CONTEXT_OVERRIDES` + upstream `context_window` for native | yes, with a documented 128k fallback in `parsing.ts:287` | +| display name | `CatalogModel.displayName` | yes, optional | +| max output | none | **no** — 32k schema stand-in only | +| price | not on `CatalogModel` | **no** | +| input modality | `CatalogModel.inputModalities` | yes | +| reasoning | `CatalogModel.reasoningEfforts` | yes, as effort list | + +Consequence for Pi: `cost` cannot be filled honestly. Emitting zeros would assert +"this model is free," which is false for routed providers. The 010 phase must +decide omit-vs-zero, and the survey's field table says `cost` is optional when the +key is absent — so omission is the honest option. diff --git a/devlog/_plan/260731_client_config_export/003_export_ux_design.md b/devlog/_plan/260731_client_config_export/003_export_ux_design.md new file mode 100644 index 000000000..dd05ad220 --- /dev/null +++ b/devlog/_plan/260731_client_config_export/003_export_ux_design.md @@ -0,0 +1,190 @@ +# 003 — Export UX design (Design Read + surface contracts) + +Design judgment only. Component-level implementation belongs to `040`. +Governs every surface that hands a client config to a caller: CLI text, CLI +`--json`, management API, GUI panel, GUI file download. + +## 1. Design Read + +```yaml +--- +name: opencodex client config export +colors: + primary: "light-dark(#0d0d0d, #ececec)" # existing --accent, unchanged + accent: "light-dark(#0d0d0d, #ececec)" + background: "light-dark(#ffffff, #212121)" # existing --bg +typography: + heading: { fontFamily: OpenAI Sans, fontSize: 14px } # --text-body / --weight-semibold + body: { fontFamily: OpenAI Sans, fontSize: 13px } # --text-control + machine: { fontFamily: ui-monospace, fontSize: 12px } # --font-code / --text-label +iconography: + system: "existing gui/src/icons" + weight: "regular" + domain: "library-subset" +--- +``` + +Reading this as: a **handoff surface inside a developer console** — the screen +where a machine-readable artifact leaves the product and enters someone else's +config file. The reference is not a marketing "integrations" page; it is closer +to a CI provider's "copy this token" step or a package registry's install +snippet. The whole job is to make an exact string transfer without error. + +Do's: reuse the API page's existing copy-on-click grammar; keep the generated JSON +visible before it is taken; name the destination path explicitly. + +Don'ts: no new visual language for this feature; no illustrations, no per-client +brand marks, no gradients; never present a download as if it were applied. + +### Dial setting + +``` +DESIGN_VARIANCE: 3 +MOTION_INTENSITY: 1 +Product density profile: D6 +Reasoning: dense developer console, preserve redesign — this is an additive panel on an +existing monochrome infra surface, so variance and motion match the host and density +stays high because the payload is machine data. +``` + +Domain gate applies: this is an admin/ops surface, so the Liquid Editorial default +kit is explicitly NOT used. The host design system in `gui/src/styles.css` governs. + +### Concept generation: skipped + +UX-CONCEPT-GEN-01 exempts utility CRUD/dashboard surfaces, and a governing design +system already exists (monochrome OpenAI-console grammar, tokens in `styles.css`). +No new brand-visible composition is introduced. Skip recorded per the rule. + +## 2. The one design decision + +Export has two legitimate consumer shapes and they pull in opposite directions: + +| Consumer | Wants | Failure if served the other | +|----------|-------|------------------------------| +| Agent / script | one exact JSON on stdout, no prose | prose in stdout breaks the parse | +| Human | to know WHERE the file goes and WHICH env var to set | bare JSON leaves them guessing | + +**Resolution: one payload, four presentations.** A single pure function produces +the artifact; each surface decides only how to frame it. This is the existing +`printData(value, wantsJson, lines)` contract in `src/cli/runtime-api.ts:285` +generalized — human lines and machine JSON are two renderings of the same value, +never two code paths that can drift. + +``` + buildClientConfig(client, catalog, endpoint) + | + +---------------+---------------+ + | | | + CLI --json CLI human GET /api/client-config + (stdout JSON) (JSON + path (same JSON + metadata) + + env hint) | + +-----+-----+ + | | + GUI preview GUI download + (+ copy) (.json file) +``` + +## 3. Lazy-User Gate + +Applied to every decision point the feature introduces: + +| Decision | Gate outcome | +|----------|--------------| +| Which client? | **Keep.** Genuinely divergent formats; no correct default exists. Segmented control, not a dropdown — two options should never hide behind a click. | +| Which OpenCode schema version? | **Delete.** V1 only (see `001` §2). Not user-facing. | +| Which models to include? | **Absorb.** System emits every visible non-disabled model. No picker. | +| Where to write the file? | **Absorb + demote.** Show the canonical path as copyable text; never write it for them. | +| Copy or download? | **Keep both, unequal.** Copy is primary (the common case is paste-into-existing-file); download is secondary. | +| Which env var? | **Absorb.** Fixed name per client, shown as a copyable export line. | + +Primary action on the panel: **Copy JSON.** Everything else is secondary weight. + +## 4. UX state contract + +Per UX-STATE-01, meaning before styling. The panel has five states. + +**Empty — no keys generated yet.** The config is still valid (the proxy admits +loopback callers), but a config referencing an unset env var will fail the moment +the user leaves loopback. So this is informational, not blocking: show the JSON, +plus a line stating that the referenced variable has no key behind it yet and +pointing at Generate key. Never disable export here — an agent may legitimately +want the shape before the key exists. + +**Loading — catalog in flight.** Skeleton, because the structure is known +(panel head, code block, action row). The existing `DataSurfaceSkeleton` +in `gui/src/components/data-surface` is the house component. No spinner, and +never render a partial JSON that could be copied mid-flight. + +**Ready.** JSON visible, model count stated, copy primary, download secondary, +destination path and env line present. + +**Error — catalog fetch failed.** The client config cannot be honestly produced +without the model list, so do not emit a half-config. State that the model list +could not be read, offer retry, and keep the endpoint/base-URL portion visible +since it is known independently of the catalog. Never dead-end. + +**Degraded — catalog partial.** Some models carry no authoritative context window +and therefore ship without a `limit` block (`002` §4). This is invisible in the +JSON, so the panel states it in one line: N of M models omit context limits, and +the client will apply its own defaults. Silence here would look like data loss. + +### Progressive disclosure + +Hidden by default: the destination-path detail (XDG resolution, project vs global +precedence) and the raw curl-equivalent. Reason: correct for the 90% case without +reading. Revealed in the same disclosure the API page already uses for +Authentication and Usage examples, so the grammar is consistent. + +## 5. Download-specific design + +The download is the surface most likely to mislead, because a file appearing in +`~/Downloads` feels like an applied change. Three rules. + +**Filename encodes the client, not the product.** `opencode.json` and +`pi-models.json` — a file named `opencodex-export.json` tells the user nothing +about where it belongs. The name should match what the destination file is called +so the mapping is obvious. + +**Downloading is never presented as applying.** The success announcement says the +file was downloaded and names the path it must be moved or merged into. Reuse of +the ClaudeDesktop precedent covers the mechanics; the wording is this unit's +responsibility. + +**Merge semantics are stated, not implied.** The downloaded file contains ONLY our +provider block plus `$schema`. A user with an existing `opencode.json` must merge, +not replace — replacing would destroy their other providers and MCP config. One +line of text prevents a support issue that would otherwise be data loss. + +Accessibility: the download button is a real ` + + + + +
+ {CLIENTS.map(option => ( + + ))} +
+ + {loading ? ( + // Owns the only live region for this transition; the announcement region below is not + // rendered while cold (data-surface.tsx header rule). + + ) : error !== null || !data ? ( +
+ {/* No partial JSON here: without the model list the config cannot be produced honestly. */} +

{error ?? t("api.clientConfig.loadFailed")}

+ +
+ ) : ( + <> +
{json}
+

+ {t("api.clientConfig.modelCount", { count: data.modelCount })} +

+ {data.modelsWithoutLimits > 0 && ( +

+ {t("api.clientConfig.missingLimits", { count: data.modelsWithoutLimits, total: data.modelCount })} +

+ )} + {!hasKeys && ( + // Informational, never blocking: an agent may legitimately want the shape first, + // so both actions stay enabled (003 §3). +

+ {t("api.clientConfig.noKeyYet", { env: data.apiKeyEnv })} +

+ )} +
+ {t("api.clientConfig.destination")} + +
+
+ {t("api.clientConfig.envHint")} + +
+

{t("api.clientConfig.mergeWarning")}

+ + )} + +
+ {/* Known without the catalog, so it survives the error state. */} + {t("api.baseUrl")} + +
+ +
+ {t("api.clientConfig.whereDisclosure")} +

{t("api.clientConfig.whereBody")}

+
+ + {/* Copy/download announcements only once the surface is ready, so the skeleton and this + region never speak for the same transition. */} + {!loading && data !== null && ( +
{announcement}
+ )} + + ); +} diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index dcb621f57..d20ebeece 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -887,6 +887,26 @@ export const de: Record = { "api.confirm": "Bestätigen", "api.deleteAria": "API-Schlüssel löschen", "api.usageSampleInput": "Hallo, Welt!", + "api.clientConfig.title": "Client-Konfiguration", + "api.clientConfig.clientLabel": "Client", + "api.clientConfig.clientOpencode": "OpenCode", + "api.clientConfig.clientPi": "Pi", + "api.clientConfig.copy": "JSON kopieren", + "api.clientConfig.download": "Herunterladen", + "api.clientConfig.loading": "Client-Konfiguration wird erstellt…", + "api.clientConfig.jsonLabel": "{client}-Konfigurations-JSON", + "api.clientConfig.destination": "Zieldatei", + "api.clientConfig.envHint": "Schlüssel vor dem Start setzen", + "api.clientConfig.mergeWarning": "Führe dies in die Zieldatei ein. Ein Ersetzen würde deine anderen Provider und MCP-Einstellungen entfernen.", + "api.clientConfig.modelCount": "{count} Modell(e) exportiert", + "api.clientConfig.missingLimits": "{count} von {total} Modell(en) haben kein Kontextlimit; der Client verwendet dafür seine eigenen Vorgaben.", + "api.clientConfig.noKeyYet": "Für {env} existiert noch kein Schlüssel. Erzeuge oben einen Schlüssel, bevor du diese Konfiguration außerhalb von Loopback nutzt.", + "api.clientConfig.loadFailed": "Die Modellliste konnte nicht gelesen werden, daher wurde keine Client-Konfiguration erzeugt.", + "api.clientConfig.copiedAnnounce": "Client-Konfigurations-JSON in die Zwischenablage kopiert.", + "api.clientConfig.copyFailed": "Client-Konfigurations-JSON konnte nicht kopiert werden.", + "api.clientConfig.downloadedAnnounce": "{filename} heruntergeladen. Es hat sich noch nichts geändert — führe die Datei selbst in {destination} ein.", + "api.clientConfig.whereDisclosure": "Wohin diese Datei gehört", + "api.clientConfig.whereBody": "Der Pfad oben ist der globale Speicherort. Eine projektlokale Konfigurationsdatei im Arbeitsverzeichnis hat Vorrang, und der Schlüssel wird aus der in der Konfiguration genannten Umgebungsvariable gelesen — nie aus dieser Datei.", "api.keysLoadFailed": "API-Schlüssel konnten nicht geladen werden.", "api.createFailed": "API-Schlüssel konnte nicht erstellt werden.", "api.deleteFailed": "API-Schlüssel konnte nicht gelöscht werden.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index e130743dc..0d8a8a9b5 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1335,6 +1335,26 @@ export const en = { "api.usageResponsesTitle": "Responses example", "api.usageMessagesTitle": "Messages example", "api.usageSampleInput": "Hello, world!", + "api.clientConfig.title": "Client config", + "api.clientConfig.clientLabel": "Client", + "api.clientConfig.clientOpencode": "OpenCode", + "api.clientConfig.clientPi": "Pi", + "api.clientConfig.copy": "Copy JSON", + "api.clientConfig.download": "Download", + "api.clientConfig.loading": "Building client config…", + "api.clientConfig.jsonLabel": "{client} config JSON", + "api.clientConfig.destination": "Destination file", + "api.clientConfig.envHint": "Set the key before launching", + "api.clientConfig.mergeWarning": "Merge this into the destination file. Replacing it would drop your other providers and MCP settings.", + "api.clientConfig.modelCount": "{count} model(s) exported", + "api.clientConfig.missingLimits": "{count} of {total} model(s) ship without a context limit; the client applies its own defaults.", + "api.clientConfig.noKeyYet": "{env} has no key behind it yet. Generate a key above before using this config off loopback.", + "api.clientConfig.loadFailed": "Could not read the model list, so no client config was produced.", + "api.clientConfig.copiedAnnounce": "Client config JSON copied to the clipboard.", + "api.clientConfig.copyFailed": "Could not copy the client config JSON.", + "api.clientConfig.downloadedAnnounce": "Downloaded {filename}. Nothing changed yet — merge it into {destination} yourself.", + "api.clientConfig.whereDisclosure": "Where this file goes", + "api.clientConfig.whereBody": "The destination above is the global path. A project-local config file in the working directory takes precedence over it, and the client reads the key from the environment variable named in the config — never from this file.", "api.keysLoadFailed": "Could not load API keys.", "api.createFailed": "Could not create API key.", "api.deleteFailed": "Could not delete API key.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index ae8109ed7..45a3dc33d 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1283,6 +1283,26 @@ export const ja: Record = { "api.confirm": "確認", "api.deleteAria": "API キーを削除", "api.usageSampleInput": "こんにちは、世界!", + "api.clientConfig.title": "クライアント設定", + "api.clientConfig.clientLabel": "クライアント", + "api.clientConfig.clientOpencode": "OpenCode", + "api.clientConfig.clientPi": "Pi", + "api.clientConfig.copy": "JSON をコピー", + "api.clientConfig.download": "ダウンロード", + "api.clientConfig.loading": "クライアント設定を生成中…", + "api.clientConfig.jsonLabel": "{client} 設定 JSON", + "api.clientConfig.destination": "配置先ファイル", + "api.clientConfig.envHint": "起動前にキーを設定", + "api.clientConfig.mergeWarning": "配置先ファイルにマージしてください。置き換えると既存のプロバイダーや MCP 設定が失われます。", + "api.clientConfig.modelCount": "{count} 件のモデルを書き出しました", + "api.clientConfig.missingLimits": "{total} 件中 {count} 件のモデルにコンテキスト上限がないため、クライアント側の既定値が使われます。", + "api.clientConfig.noKeyYet": "{env} に対応するキーがまだありません。ループバック外で使う前に上でキーを発行してください。", + "api.clientConfig.loadFailed": "モデル一覧を読み取れなかったため、クライアント設定を生成できませんでした。", + "api.clientConfig.copiedAnnounce": "クライアント設定 JSON をクリップボードにコピーしました。", + "api.clientConfig.copyFailed": "クライアント設定 JSON をコピーできませんでした。", + "api.clientConfig.downloadedAnnounce": "{filename} をダウンロードしました。まだ何も変わっていません。{destination} に自分でマージしてください。", + "api.clientConfig.whereDisclosure": "このファイルの置き場所", + "api.clientConfig.whereBody": "上のパスはグローバル設定の場所です。作業ディレクトリのプロジェクト設定ファイルが優先され、キーは設定に書かれた環境変数から読み込まれ、このファイルには保存されません。", "api.keysLoadFailed": "APIキーを読み込めませんでした。", "api.createFailed": "APIキーを作成できませんでした。", "api.deleteFailed": "APIキーを削除できませんでした。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index d5f16aae2..4e2679857 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -914,6 +914,26 @@ export const ko: Record = { "api.confirm": "확인", "api.deleteAria": "API 키 삭제", "api.usageSampleInput": "안녕하세요, 세계!", + "api.clientConfig.title": "클라이언트 설정", + "api.clientConfig.clientLabel": "클라이언트", + "api.clientConfig.clientOpencode": "OpenCode", + "api.clientConfig.clientPi": "Pi", + "api.clientConfig.copy": "JSON 복사", + "api.clientConfig.download": "다운로드", + "api.clientConfig.loading": "클라이언트 설정 생성 중…", + "api.clientConfig.jsonLabel": "{client} 설정 JSON", + "api.clientConfig.destination": "대상 파일", + "api.clientConfig.envHint": "실행 전 키 설정", + "api.clientConfig.mergeWarning": "대상 파일에 병합하세요. 덮어쓰면 기존 프로바이더와 MCP 설정이 사라집니다.", + "api.clientConfig.modelCount": "모델 {count}개 내보냄", + "api.clientConfig.missingLimits": "{total}개 중 {count}개 모델에 컨텍스트 한도가 없어 클라이언트 기본값이 적용됩니다.", + "api.clientConfig.noKeyYet": "{env}에 연결된 키가 아직 없습니다. 루프백 밖에서 쓰려면 위에서 키를 발급하세요.", + "api.clientConfig.loadFailed": "모델 목록을 읽지 못해 클라이언트 설정을 만들지 못했습니다.", + "api.clientConfig.copiedAnnounce": "클라이언트 설정 JSON을 클립보드에 복사했습니다.", + "api.clientConfig.copyFailed": "클라이언트 설정 JSON을 복사하지 못했습니다.", + "api.clientConfig.downloadedAnnounce": "{filename} 파일을 다운로드했습니다. 아직 아무것도 바뀌지 않았으니 {destination}에 직접 병합하세요.", + "api.clientConfig.whereDisclosure": "이 파일이 들어갈 위치", + "api.clientConfig.whereBody": "위 경로는 전역 설정 경로입니다. 작업 디렉터리의 프로젝트 설정 파일이 우선하며, 키는 설정에 적힌 환경 변수에서 읽고 이 파일에는 저장되지 않습니다.", "api.keysLoadFailed": "API 키를 불러오지 못했습니다.", "api.createFailed": "API 키를 만들지 못했습니다.", "api.deleteFailed": "API 키를 삭제하지 못했습니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index a0bf4eb01..eae71ca8f 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1325,6 +1325,26 @@ export const ru: Record = { "api.confirm": "Подтвердить", "api.deleteAria": "Удалить API-ключ", "api.usageSampleInput": "Привет, мир!", + "api.clientConfig.title": "Конфигурация клиента", + "api.clientConfig.clientLabel": "Клиент", + "api.clientConfig.clientOpencode": "OpenCode", + "api.clientConfig.clientPi": "Pi", + "api.clientConfig.copy": "Копировать JSON", + "api.clientConfig.download": "Скачать", + "api.clientConfig.loading": "Формируется конфигурация клиента…", + "api.clientConfig.jsonLabel": "JSON конфигурации {client}", + "api.clientConfig.destination": "Целевой файл", + "api.clientConfig.envHint": "Задайте ключ перед запуском", + "api.clientConfig.mergeWarning": "Объедините это с целевым файлом. Замена удалит ваши другие провайдеры и настройки MCP.", + "api.clientConfig.modelCount": "Экспортировано моделей: {count}", + "api.clientConfig.missingLimits": "У {count} из {total} моделей нет лимита контекста; клиент применит свои значения по умолчанию.", + "api.clientConfig.noKeyYet": "Для {env} пока нет ключа. Создайте ключ выше, прежде чем использовать конфигурацию вне loopback.", + "api.clientConfig.loadFailed": "Не удалось прочитать список моделей, поэтому конфигурация клиента не создана.", + "api.clientConfig.copiedAnnounce": "JSON конфигурации клиента скопирован в буфер обмена.", + "api.clientConfig.copyFailed": "Не удалось скопировать JSON конфигурации клиента.", + "api.clientConfig.downloadedAnnounce": "Файл {filename} скачан. Пока ничего не изменилось — объедините его с {destination} самостоятельно.", + "api.clientConfig.whereDisclosure": "Куда положить этот файл", + "api.clientConfig.whereBody": "Путь выше — глобальное расположение. Файл конфигурации проекта в рабочем каталоге имеет приоритет, а ключ читается из переменной окружения, указанной в конфигурации, и никогда не хранится в этом файле.", "api.keysLoadFailed": "Не удалось загрузить API-ключи.", "api.createFailed": "Не удалось создать API-ключ.", "api.deleteFailed": "Не удалось удалить API-ключ.", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 03e48ddc4..f563e3415 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -907,6 +907,26 @@ export const zh: Record = { "api.confirm": "确认", "api.deleteAria": "删除 API 密钥", "api.usageSampleInput": "你好,世界!", + "api.clientConfig.title": "客户端配置", + "api.clientConfig.clientLabel": "客户端", + "api.clientConfig.clientOpencode": "OpenCode", + "api.clientConfig.clientPi": "Pi", + "api.clientConfig.copy": "复制 JSON", + "api.clientConfig.download": "下载", + "api.clientConfig.loading": "正在生成客户端配置…", + "api.clientConfig.jsonLabel": "{client} 配置 JSON", + "api.clientConfig.destination": "目标文件", + "api.clientConfig.envHint": "启动前设置密钥", + "api.clientConfig.mergeWarning": "请合并到目标文件中。直接替换会丢失你已有的其他提供商和 MCP 配置。", + "api.clientConfig.modelCount": "已导出 {count} 个模型", + "api.clientConfig.missingLimits": "{total} 个模型中有 {count} 个没有上下文上限,客户端将使用自己的默认值。", + "api.clientConfig.noKeyYet": "{env} 目前还没有对应的密钥。离开回环地址使用前,请先在上方生成密钥。", + "api.clientConfig.loadFailed": "无法读取模型列表,因此没有生成客户端配置。", + "api.clientConfig.copiedAnnounce": "客户端配置 JSON 已复制到剪贴板。", + "api.clientConfig.copyFailed": "无法复制客户端配置 JSON。", + "api.clientConfig.downloadedAnnounce": "已下载 {filename}。目前还没有任何改动,请自行将其合并到 {destination}。", + "api.clientConfig.whereDisclosure": "该文件应放在哪里", + "api.clientConfig.whereBody": "上面的路径是全局配置位置。工作目录中的项目级配置文件优先级更高;密钥由配置中指定的环境变量读取,不会写入该文件。", "api.keysLoadFailed": "无法加载 API 密钥。", "api.createFailed": "无法创建 API 密钥。", "api.deleteFailed": "无法删除 API 密钥。", diff --git a/gui/src/pages/ApiKeys.tsx b/gui/src/pages/ApiKeys.tsx index 9bf316697..f6990f6dd 100644 --- a/gui/src/pages/ApiKeys.tsx +++ b/gui/src/pages/ApiKeys.tsx @@ -415,6 +415,7 @@ export default function ApiKeys({ apiBase }: { apiBase: string }) { )} .awi-clientconfig-panel, +.awi-clientconfig-panel.api-panel { + overflow: visible; +} + +.awi-clientconfig-head { + gap: var(--space-2); + flex-wrap: wrap; +} + +.awi-clientconfig-actions { + display: inline-flex; + align-items: center; + gap: 8px; + flex: 0 0 auto; +} + +/* Copy is the primary action and its label must not wrap into two lines at the + narrow breakpoint, where the head is the tightest row on the tab. */ +.awi-clientconfig-actions .btn { + white-space: nowrap; +} + +.awi-clientconfig-segmented { + display: inline-flex; + align-self: flex-start; + max-width: 100%; + border-radius: var(--radius-pill); + background: var(--surface-soft, var(--raised)); + padding: var(--space-1); + gap: var(--space-1); +} + +.awi-clientconfig-segmented .btn { + border-radius: var(--radius-pill); + min-width: 84px; + padding: var(--space-1-5) var(--space-3); + border: none; +} + +.awi-clientconfig-segmented .btn.btn-ghost { + background: transparent; + color: var(--muted); +} + +/* No vertical cap and no y-scroller of its own: the catalog table is the only capped + region on this tab, and the unscoped `.api-example-pre` rules above already give this + block `overflow-y: visible` + `overscroll-behavior: auto`. Adding a `max-height` here + would make the JSON a second wheel trap. */ +.awi-clientconfig-json { + margin: 0; +} + +.awi-clientconfig-json:focus-visible { + outline: 2px solid var(--accent-ring); + outline-offset: 1px; +} + +.awi-clientconfig-count, +.awi-clientconfig-degraded, +.awi-clientconfig-nokey, +.awi-clientconfig-merge { + margin: 0; + line-height: 1.35; +} + +.awi-clientconfig-line { + display: grid; + gap: 4px; + min-width: 0; +} + +.awi-clientconfig-error { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); + flex-wrap: wrap; +} + +.awi-clientconfig-error p { + margin: 0; + min-width: 0; +} + +.awi-clientconfig-where > summary { + cursor: pointer; + padding: 2px 0; +} + +.awi-clientconfig-where > p { + margin: 6px 0 0; + line-height: 1.4; +} + .awi-section { margin-bottom: 20px; } diff --git a/gui/tests/client-config-panel.test.tsx b/gui/tests/client-config-panel.test.tsx new file mode 100644 index 000000000..393795f32 --- /dev/null +++ b/gui/tests/client-config-panel.test.tsx @@ -0,0 +1,254 @@ +/** + * Client config panel (devlog 260731_client_config_export/040) — accept criteria 1-6. + * + * The panel renders what GET /api/client-config returns, so every assertion here drives the + * real component against a stubbed route rather than a locally rebuilt config. + */ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import { LanguageProvider } from "../src/i18n/provider"; +import ClientConfigPanel from "../src/components/apikeys-workspace/ClientConfigPanel"; + +const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previousGlobals: Record<(typeof globals)[number], unknown>; +let testWindow: Window; +const originalFetch = globalThis.fetch; + +const OPENCODE_ENVELOPE = { + client: "opencode", + filename: "opencode.json", + destination: "/home/dev/.config/opencode/opencode.json", + apiKeyEnv: "OPENCODEX_OPENCODE_API_KEY", + exportHint: "export OPENCODEX_OPENCODE_API_KEY=", + modelCount: 2, + modelsWithoutLimits: 0, + config: { + provider: { + opencodex: { + npm: "@ai-sdk/openai-compatible", + name: "OpenCodex", + options: { baseURL: "http://127.0.0.1:10100/v1", apiKey: "{env:OPENCODEX_OPENCODE_API_KEY}" }, + models: { "gpt-5.4": { name: "gpt-5.4 (native)" } }, + }, + }, + }, +}; + +const PI_ENVELOPE = { + client: "pi", + filename: "pi-models.json", + destination: "/home/dev/.pi/agent/models.json", + apiKeyEnv: "OPENCODEX_PI_API_KEY", + exportHint: "export OPENCODEX_PI_API_KEY=", + modelCount: 2, + modelsWithoutLimits: 1, + // Pi keys its models as an ARRAY — the shape swap is what proves a real refetch. + config: { providers: { opencodex: { models: [{ id: "gpt-5.4" }, { id: "claude-sonnet-4-6" }] } } }, +}; + +beforeEach(() => { + previousGlobals = Object.fromEntries(globals.map(key => [key, Reflect.get(globalThis, key)])) as typeof previousGlobals; + testWindow = new Window({ url: "http://localhost/" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + localStorage: { configurable: true, value: testWindow.localStorage }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + testWindow.close(); + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); + } +}); + +function stubRoute(handler: (client: string) => Response | Promise) { + const calls: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = new URL(String(input), "http://localhost"); + const client = url.searchParams.get("client") ?? ""; + calls.push(client); + return handler(client); + }) as typeof fetch; + return calls; +} + +async function mountPanel(props: { hasKeys?: boolean } = {}): Promise<{ root: Root; container: HTMLElement }> { + const { createRoot } = await import("react-dom/client"); + const container = document.createElement("div"); + document.body.append(container); + let root!: Root; + await act(async () => { + root = createRoot(container); + root.render( + + + , + ); + }); + return { root, container }; +} + +function button(container: HTMLElement, label: string): HTMLButtonElement { + return [...container.querySelectorAll("button")] + .find(el => el.textContent?.trim() === label)!; +} + +test("switching client refetches and swaps the rendered payload shape", async () => { + const calls = stubRoute(client => Response.json(client === "pi" ? PI_ENVELOPE : OPENCODE_ENVELOPE)); + const { root, container } = await mountPanel(); + + const opencodeJson = container.querySelector(".awi-clientconfig-json")!.textContent!; + expect(opencodeJson).toContain("\"provider\""); + expect(JSON.parse(opencodeJson)).toEqual(OPENCODE_ENVELOPE.config); + + await act(async () => { button(container, "Pi").click(); }); + + expect(calls).toEqual(["opencode", "pi"]); + const piJson = container.querySelector(".awi-clientconfig-json")!.textContent!; + const parsed = JSON.parse(piJson) as typeof PI_ENVELOPE.config; + expect(Array.isArray(parsed.providers.opencodex.models)).toBe(true); + expect(piJson).not.toContain("\"npm\""); + + await act(async () => { root.unmount(); }); +}); + +test("download emits the fetched config under the server-provided filename and never says applied", async () => { + stubRoute(() => Response.json(OPENCODE_ENVELOPE)); + const blobs: Blob[] = []; + const createObjectURL = ((blob: Blob) => { blobs.push(blob); return "blob:stub"; }) as typeof URL.createObjectURL; + const originalCreate = URL.createObjectURL; + const originalRevoke = URL.revokeObjectURL; + URL.createObjectURL = createObjectURL; + URL.revokeObjectURL = (() => {}) as typeof URL.revokeObjectURL; + + const downloaded: string[] = []; + const originalCreateElement = document.createElement.bind(document); + document.createElement = ((tag: string) => { + const element = originalCreateElement(tag); + if (tag === "a") { + // The anchor is an internal implementation detail; happy-dom does not navigate on click. + (element as HTMLAnchorElement).click = () => { downloaded.push((element as HTMLAnchorElement).download); }; + } + return element; + }) as typeof document.createElement; + + try { + const { root, container } = await mountPanel(); + await act(async () => { button(container, "Download").click(); }); + + expect(downloaded).toEqual(["opencode.json"]); + expect(blobs).toHaveLength(1); + // happy-dom appends `;charset=utf-8` to the constructed Blob type. + expect(blobs[0]!.type).toStartWith("application/json"); + expect(JSON.parse(await blobs[0]!.text())).toEqual(OPENCODE_ENVELOPE.config); + + const announcement = container.querySelector(".sr-only[aria-live='polite']")!.textContent!; + expect(announcement).toContain("Downloaded opencode.json"); + expect(announcement).toContain(OPENCODE_ENVELOPE.destination); + for (const forbidden of ["applied", "saved", "configured"]) { + expect(announcement.toLowerCase()).not.toContain(forbidden); + } + // Merge semantics are stated on the surface, not only in the announcement (003 §5). + expect(container.textContent).toContain("Merge this into the destination file."); + + await act(async () => { root.unmount(); }); + } finally { + URL.createObjectURL = originalCreate; + URL.revokeObjectURL = originalRevoke; + document.createElement = originalCreateElement as typeof document.createElement; + } +}); + +test("route failure shows a retry and no partial JSON, and keeps the base URL visible", async () => { + let attempt = 0; + stubRoute(() => { + attempt += 1; + return attempt === 1 + ? Response.json({ error: "model catalog unavailable: upstream timeout" }, { status: 503 }) + : Response.json(OPENCODE_ENVELOPE); + }); + const { root, container } = await mountPanel(); + + expect(container.textContent).toContain("model catalog unavailable: upstream timeout"); + expect(container.querySelector(".awi-clientconfig-json")).toBeNull(); + expect(container.textContent).toContain("http://127.0.0.1:10100/v1"); + + await act(async () => { button(container, "Retry").click(); }); + + expect(attempt).toBe(2); + expect(container.querySelector(".awi-clientconfig-json")).not.toBeNull(); + expect(container.querySelector(".awi-clientconfig-error")).toBeNull(); + + await act(async () => { root.unmount(); }); +}); + +test("degraded line appears only when models ship without context limits", async () => { + stubRoute(client => Response.json(client === "pi" ? PI_ENVELOPE : OPENCODE_ENVELOPE)); + const { root, container } = await mountPanel(); + + expect(container.querySelector(".awi-clientconfig-degraded")).toBeNull(); + + await act(async () => { button(container, "Pi").click(); }); + + expect(container.querySelector(".awi-clientconfig-degraded")?.textContent) + .toBe("1 of 2 model(s) ship without a context limit; the client applies its own defaults."); + + await act(async () => { root.unmount(); }); +}); + +test("no-key state is informational and leaves copy and download enabled", async () => { + stubRoute(() => Response.json(OPENCODE_ENVELOPE)); + const { root, container } = await mountPanel({ hasKeys: false }); + + expect(container.querySelector(".awi-clientconfig-nokey")?.textContent) + .toContain("OPENCODEX_OPENCODE_API_KEY has no key behind it yet"); + expect(button(container, "Copy JSON").disabled).toBe(false); + expect(button(container, "Download").disabled).toBe(false); + + await act(async () => { root.unmount(); }); +}); + +test("client switch is a segmented radio group with both options visible, not a dropdown", async () => { + stubRoute(() => Response.json(OPENCODE_ENVELOPE)); + const { root, container } = await mountPanel(); + + const group = container.querySelector(".awi-clientconfig-segmented")!; + expect(group.getAttribute("role")).toBe("radiogroup"); + expect(container.querySelector("select")).toBeNull(); + const options = [...group.querySelectorAll("button[role='radio']")]; + expect(options.map(el => el.textContent)).toEqual(["OpenCode", "Pi"]); + expect(options.map(el => el.getAttribute("aria-checked"))).toEqual(["true", "false"]); + + await act(async () => { root.unmount(); }); +}); + +test("cold load announces through the skeleton only, with no second live region", async () => { + let release: (() => void) | null = null; + const gate = new Promise(resolve => { release = resolve; }); + stubRoute(async () => { await gate; return Response.json(OPENCODE_ENVELOPE); }); + + const { root, container } = await mountPanel(); + + const liveRegions = container.querySelectorAll("[aria-live]"); + expect(liveRegions).toHaveLength(1); + expect(container.querySelector(".data-surface-skeleton")).not.toBeNull(); + expect(container.querySelector(".awi-clientconfig-json")).toBeNull(); + + await act(async () => { release!(); await gate; }); + + expect(container.querySelector(".data-surface-skeleton")).toBeNull(); + expect(container.querySelectorAll("[aria-live]")).toHaveLength(1); + + await act(async () => { root.unmount(); }); +}); From be4e1ed3a20213344fc2a18cc8340301c3b34063 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 1 Aug 2026 18:19:36 +0900 Subject: [PATCH 5/7] docs: document ocx export and the Pi client Phase 050 of devlog/_plan/260731_client_config_export. The export command shipped in the previous four commits with nothing in docs-site pointing at it, and Pi had no page at all. Adds the ocx export section to the CLI reference (English plus the four translated copies), a section in the opencode guide on getting the provider block into your own config, and a Pi guide. Three things the docs have to get right, each verified against the code while writing: the two clients use DIFFERENT env vars (OPENCODEX_OPENCODE_API_KEY vs OPENCODEX_API_KEY), a loopback bind needs no admission key at all since shouldInjectApiAuthHeader is just !isLoopbackHostname, and an exported config must be merged rather than replace an existing file. BYOK is split rather than restated: the proxy admission key is what the exported config references, provider keys stay in providers..apiKey and keep their existing home in the providers guide. The Pi schema is still unverified against a real install and the guide says so. --- .../050_docs_and_hardening.md | 93 +++++++++++++ docs-site/astro.config.mjs | 1 + docs-site/src/content/docs/guides/opencode.md | 43 ++++++ docs-site/src/content/docs/guides/pi.md | 122 ++++++++++++++++++ .../src/content/docs/ja/reference/cli.md | 50 +++++++ .../src/content/docs/ko/reference/cli.md | 50 +++++++ docs-site/src/content/docs/reference/cli.md | 53 ++++++++ .../src/content/docs/ru/reference/cli.md | 53 ++++++++ .../src/content/docs/zh-cn/reference/cli.md | 45 +++++++ 9 files changed, 510 insertions(+) create mode 100644 devlog/_plan/260731_client_config_export/050_docs_and_hardening.md create mode 100644 docs-site/src/content/docs/guides/pi.md diff --git a/devlog/_plan/260731_client_config_export/050_docs_and_hardening.md b/devlog/_plan/260731_client_config_export/050_docs_and_hardening.md new file mode 100644 index 000000000..b1223d669 --- /dev/null +++ b/devlog/_plan/260731_client_config_export/050_docs_and_hardening.md @@ -0,0 +1,93 @@ +# 050 — Phase 5: user-facing docs + hardening + +Second work-phase of this unit, appended after the four implementation phases +closed (LOOP-UNIT-CHAIN-01). The feature ships; nothing tells a user it exists. + +## Why this phase exists + +`ocx export` and the GUI panel landed in `f7ac037e0`. A grep of `docs-site/` +finds: + +- `reference/cli.md` — the command table lists `ocx access`, `ocx config`, + `ocx observe`. There is no `ocx export` row and no subcommand section. +- `guides/opencode.md` — 106 lines about `ocx opencode`. It states "your own + config is never modified", which is still true, but there is now a supported + way to GET the block into your own config and the guide does not mention it. +- Pi — no guide, no mention anywhere in the repo. +- BYOK — the term appears nowhere in `src/`, `gui/`, or `docs-site/`. + +A feature a user cannot discover is not shipped. + +## The BYOK question + +The user asked for "byok 같은 곳 설정하는 법". Read against the tree, BYOK here +is not a new subsystem — it is the **credential half** of the export story, and +it spans two different keys that are easy to confuse: + +| Key | What it is | Where it goes | +|-----|-----------|---------------| +| Proxy admission (`ocx_…`) | opencodex's own key, generated on the API tab | the exported config's env reference | +| Provider key (`sk-…`, etc.) | your Anthropic/OpenAI/OpenRouter key | `providers..apiKey` in opencodex config | + +The export flow only ever references the FIRST. The second is what "bring your +own key" normally means, is documented in `guides/providers.md` §Auth modes and +`reference/configuration.md` (`apiKey`, `apiKeyPool`, `${ENV_VAR}`), and is NOT +re-documented here — this phase links to it instead of forking a second +explanation that would drift. + +What is genuinely missing is the seam: a reader who exports a config has no +page telling them which key the `{env:...}` reference wants, how to generate +it, or that a loopback proxy does not require one at all. + +## Scope + +IN +- MODIFY `docs-site/src/content/docs/reference/cli.md` — command table row + + an `ocx export` subcommand section. +- MODIFY `docs-site/src/content/docs/guides/opencode.md` — a section on taking + the provider block into your own config, pointing at `ocx export`. +- NEW `docs-site/src/content/docs/guides/pi.md` — the Pi guide, including the + credential seam. +- Locale sync for any translated page whose English source changed, per + AGENTS.md ("keep translated locales from contradicting the English source"). + +OUT +- No `src/` or `gui/` behavior change. This phase is docs + whatever hardening + the docs review exposes, and a code change discovered here becomes its own + amendment rather than a silent edit. +- No re-documentation of provider auth modes; link to `guides/providers.md`. +- No new BYOK subsystem. BYOK is an existing capability, not a feature to add. + +## Hardening review (the docs pass doubles as an adversarial read) + +Writing the docs forces every claim to be checked against the shipped code. +Four claims to verify while writing, each a real failure if wrong: + +1. **The destination path we print is the one Pi/OpenCode actually reads.** + `EXPORT_CLIENTS.*.destination` honors `XDG_CONFIG_HOME` for OpenCode. + Pi's path is still UNVERIFIED against a real install (`001` §2) — the guide + must say so rather than assert it. +2. **The env var name in the docs matches the one in the emitted config.** + `OPENCODEX_OPENCODE_API_KEY` for OpenCode, `OPENCODEX_API_KEY` for Pi. Two + different names; a doc that mixes them sends the reader in a circle. +3. **A loopback-only user needs no key at all.** `shouldInjectApiAuthHeader` + decides this. If the docs tell every reader to generate a key, most readers + do unnecessary work; if they tell nobody, non-loopback binds break. +4. **The merge warning is in the docs, not just the CLI.** The download and the + `--out` refusal both exist because replacing an existing config destroys + other providers. The docs must carry the same warning. + +## Accept criteria + +1. `ocx export` appears in the `reference/cli.md` command table AND has a + subcommand section documenting `--client`, `--json`, `--out`, `--force`. + **Activation:** grep the built page for `ocx export`. +2. The Pi guide states the exact destination path, the exact env var, and marks + the Pi schema as unverified against a real install. +3. Every env var name in the new docs matches the string the code emits. + **Activation:** grep `OPENCODEX_OPENCODE_API_KEY` / `OPENCODEX_API_KEY` in + both `src/clients/config-export.ts` and the new docs; the sets must agree. +4. The docs state that loopback binds need no admission key, and that a config + must be merged rather than used to replace an existing file. +5. `bun run privacy:scan` green — the new docs use `~/…`, never a real home path. +6. Docs build passes. diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs index f86b7ea19..265caea73 100644 --- a/docs-site/astro.config.mjs +++ b/docs-site/astro.config.mjs @@ -89,6 +89,7 @@ export default defineConfig({ { label: "Claude Code", translations: { ko: "Claude Code", "zh-CN": "Claude Code", ru: "Claude Code", ja: "Claude Code" }, slug: "guides/claude-code" }, { label: "Grok Build", translations: { ko: "Grok Build", "zh-CN": "Grok Build", ru: "Grok Build", ja: "Grok Build" }, slug: "guides/grok-build" }, { label: "opencode", translations: { ko: "opencode", "zh-CN": "opencode", ru: "opencode", ja: "opencode" }, slug: "guides/opencode" }, + { label: "Pi", translations: { ko: "Pi", "zh-CN": "Pi", ru: "Pi", ja: "Pi" }, slug: "guides/pi" }, { label: "Sidecars: Web Search & Vision", translations: { ko: "사이드카: 웹 검색 & 비전", "zh-CN": "边车:网络搜索与视觉", ru: "Сайдкары: веб-поиск и зрение", ja: "サイドカー: ウェブ検索 & ビジョン" }, slug: "guides/sidecars" }, { label: "Web Dashboard", translations: { ko: "웹 대시보드", "zh-CN": "网页控制台", ru: "Веб-дашборд", ja: "ウェブダッシュボード" }, slug: "guides/web-dashboard" }, { label: "Sub-agent Surface", translations: { ko: "서브에이전트 서피스", "zh-CN": "子代理界面", ru: "Интерфейс подагентов", ja: "サブエージェントサーフェス" }, slug: "guides/sub-agent-surface" }, diff --git a/docs-site/src/content/docs/guides/opencode.md b/docs-site/src/content/docs/guides/opencode.md index c54be6956..cf8d63fa0 100644 --- a/docs-site/src/content/docs/guides/opencode.md +++ b/docs-site/src/content/docs/guides/opencode.md @@ -47,6 +47,43 @@ and overrides only conflicting keys for the child process. If a global or project config also defines `provider.opencodex`, the launcher prints an informational note: the runtime layer from `ocx opencode` overrides it for that launch. +## Putting the block into your own config + +`ocx opencode` injects the provider block for one launch only, which means plain `opencode` still +knows nothing about the proxy. When you want routed models available from plain `opencode` — or +from an editor extension that never goes through the launcher — `ocx export` prints the same +provider block for you to merge into your own config: + +```bash +ocx export --client opencode +``` + +The proxy must be running. The command prints the config, the canonical destination +(`~/.config/opencode/opencode.json`, or under `XDG_CONFIG_HOME` when that is set), the merge +warning, and the env export line. It never touches that file — the section above stays true, and +moving the block into your config is your explicit act. + +:::caution[Merge, never replace] +Merge the `provider.opencodex` block into your existing config. Replacing the whole file with the +exported one destroys your other providers, agents, keybinds, and MCP entries. `ocx export --out` +refuses to overwrite an existing file for exactly this reason, so point `--out` at a scratch path +and copy the block across: + +```bash +ocx export --client opencode --out ~/opencodex-opencode.json +``` +::: + +Unlike the launcher's runtime block, a merged block is a static snapshot: it does not follow your +catalog. Re-run `ocx export` after you add a provider or change model visibility. + +Once merged, export the admission key before launching opencode — unless the proxy is on loopback, +where none is needed: + +```bash +export OPENCODEX_OPENCODE_API_KEY= +``` + ## The admission key is not written to disk When the proxy requires an API key, the inline runtime config carries opencode's @@ -78,6 +115,12 @@ The real value is passed only through the child process environment. `OPENCODEX_API_AUTH_TOKEN` takes precedence, then the hardened service token file, then a configured API key — which is what a non-loopback bind requires. +A loopback bind (`127.0.0.1`, the default) authenticates nothing, so the `{env:…}` reference is +inert and you can leave the variable unset. It matters only when `hostname` is set beyond loopback; +see [Remote access](/reference/configuration/#remote-access). This admission key is opencodex's +own, and is unrelated to the upstream provider keys configured under +[Providers](/guides/providers/). + ## Reverting Nothing to undo — no generated config file is written under `~/.opencodex`. Run plain diff --git a/docs-site/src/content/docs/guides/pi.md b/docs-site/src/content/docs/guides/pi.md new file mode 100644 index 000000000..fa44d2754 --- /dev/null +++ b/docs-site/src/content/docs/guides/pi.md @@ -0,0 +1,122 @@ +--- +title: Pi +description: Use any routed model from Pi — ocx export writes a custom provider block for Pi's models.json, wired to the running proxy. +--- + +Pi reads its providers from a single global JSON file rather than environment variables, so +opencodex does not launch it. Instead, `ocx export` serializes the `opencodex` provider block — +base URL, model list, and the env reference Pi interpolates — and you merge it into your own +config. + +## Quickstart + +Start the proxy, then print the config: + +```bash +ocx start +ocx export --client pi +``` + +The output leads with the JSON, then prints the destination path, the merge warning, the env +export line, and how many models carry authoritative context limits. + +```json +{ + "providers": { + "opencodex": { + "baseUrl": "http://127.0.0.1:10100/v1", + "api": "openai-completions", + "apiKey": "$OPENCODEX_API_KEY", + "models": [ + { + "id": "anthropic/claude-opus-5", + "name": "Claude Opus 5 (anthropic)", + "input": ["text"], + "contextWindow": 200000, + "maxTokens": 32000 + } + ] + } + } +} +``` + +Model ids are the proxy's canonical selectors, so routed models appear as `provider/model` +(`anthropic/claude-opus-5`) and native OpenAI slugs stay unprefixed (`gpt-5.6-sol`). The `name` +suffix — `(anthropic)`, `(native)`, `(routed)` — is what makes two same-named models from +different upstreams distinguishable in Pi's picker. + +## Where it goes + +Pi's global model config is: + +```text +~/.pi/agent/models.json +``` + +:::caution[Merge, never replace] +`ocx export` never writes that file. Merge the `providers.opencodex` block into it — replacing the +file destroys every other provider you have configured there. `--out` exists for a scratch path +and refuses to overwrite an existing file without `--force`: + +```bash +ocx export --client pi --out ~/opencodex-pi-models.json +ocx export --client pi --json > ~/opencodex-pi-models.json # or redirect the byte-exact JSON +``` +::: + +The exported block is a static snapshot, not a live view. Re-run `ocx export` after adding a +provider or changing model visibility, and merge the new block over the old one. + +## The admission key + +Two different keys are easy to confuse here, and only the first one appears in this file: + +| Key | What it is | Where it lives | +| --- | --- | --- | +| Proxy admission key | opencodex's own credential, generated on the dashboard's **API** tab | referenced by `apiKey` as `$OPENCODEX_API_KEY`; the value stays in your environment | +| Provider key | your Anthropic / OpenAI / OpenRouter key | opencodex's own config, per [Providers](/guides/providers/) | + +The exported config carries only the reference, never a secret. Pi interpolates a bare `$NAME`, so +the variable is: + +```bash +export OPENCODEX_API_KEY= +``` + +That name is Pi's alone. opencode uses a different variable +(`OPENCODEX_OPENCODE_API_KEY`, in `{env:…}` form) — see the [opencode guide](/guides/opencode/). + +**A loopback proxy needs no key at all.** opencodex binds `127.0.0.1` by default and authenticates +nothing there, so the `$OPENCODEX_API_KEY` reference is inert and you can leave the variable unset. +It matters only when `hostname` is set beyond loopback, which is also the case where the proxy +refuses to start without a token — see [Remote access](/reference/configuration/#remote-access). + +## Model metadata + +`contextWindow` and `maxTokens` are emitted only when the catalog reports an authoritative context +window. When it does not, both fields are omitted for that model and Pi applies its own defaults; +`ocx export` prints how many rows fell into that case. + +`maxTokens` is a schema-satisfying budget of `32000`, clamped down to the context window so a +small-context model is never given more output than context. It is not a claim about any specific +model's true maximum. + +Two fields are deliberately absent. `cost` requires all four price fields and opencodex has no +price data for routed models — emitting zeros would assert that every model is free. `reasoning` is +a boolean in Pi while the catalog carries an effort ladder, and mapping one onto the other would be +a guess. + +## Schema status + +:::note[Unverified against a real install] +The shape above follows Pi's published custom-provider documentation. It has **not** been verified +against a real `~/.pi/agent/models.json` on a machine with Pi installed. If Pi rejects the exported +block, the mismatch is on our side — please +[open an issue](https://github.com/lidge-jun/opencodex/issues) with what Pi reported. +::: + +## Requirements + +A running opencodex proxy (`ocx start`) and Pi installed. `ocx export` reads the live catalog +through the proxy's management API, so a config can never be emitted with an empty model list. diff --git a/docs-site/src/content/docs/ja/reference/cli.md b/docs-site/src/content/docs/ja/reference/cli.md index 8476d4cd1..cf564b41b 100644 --- a/docs-site/src/content/docs/ja/reference/cli.md +++ b/docs-site/src/content/docs/ja/reference/cli.md @@ -321,6 +321,56 @@ security find-generic-password -w openrouter | ocx account add-key openrouter -- `--json` は `{ ok: true, id: string | null, label?: string }` を返し key を含みません。 +### `ocx export --client ` + +実行中のプロキシに接続されたクライアント設定を出力します。opencode と Pi は環境変数ではなく自分の +JSON 設定ファイルからプロバイダーを読むため、このコマンドが `opencodex` プロバイダーブロック +(base URL、モデル一覧、クライアントが解釈する環境変数参照) を直列化します。自分のファイルへの +マージはユーザーが行います。 + +プロキシが動いている必要があります。実行中のポートを解決して `/api/models` を読み、今 Codex から +見えるモデルだけを出力します。 + +| フラグ | 動作 | +| --- | --- | +| `--client ` | 必須。クライアント方言を選びます。opencode は key 付き `provider` オブジェクト、Pi は `providers` 配列です。 | +| `--json` | stdout に設定 JSON だけを出力するので、リダイレクトしてもバイト単位で正確です。`--out` の書き込み通知を含むすべての診断は stderr に出ます。 | +| `--out ` | 設定を `` に書きます。既存ファイルの置き換えは拒否します。 | +| `--force` | `--out` が既存ファイルを置き換えることを許可します。 | + +```bash +ocx export --client opencode # 設定に加えて宛先パス、マージ警告、件数 +ocx export --client pi --json > pi-models.json # パイプや diff 用のバイト正確な JSON +ocx export --client opencode --out ~/opencodex-opencode.json +``` + +`--json` なしでは JSON が先に出て、続いて正規の宛先パス、マージ警告、環境変数の export 行、モデル +件数とコンテキスト上限を持たない行数 (それらはクライアント側の既定値が使われます) が出力されます。 + +| クライアント | 正規の宛先 | ダウンロードファイル名 | 環境変数 | +| --- | --- | --- | --- | +| `opencode` | `~/.config/opencode/opencode.json` (`XDG_CONFIG_HOME` が設定されていればそちら) | `opencode.json` | `OPENCODEX_OPENCODE_API_KEY` | +| `pi` | `~/.pi/agent/models.json` | `pi-models.json` | `OPENCODEX_API_KEY` | + +2 つの環境変数名は異なり、各クライアントは自分のものだけを解釈します。opencode は +`{env:OPENCODEX_OPENCODE_API_KEY}`、Pi は `$OPENCODEX_API_KEY` を読みます。 + +:::caution[置き換えではなくマージ] +`ocx export` が実際のクライアント設定ファイルを書くことはありません。宛先パスは手動でマージする +ために表示され、`--out` も `--force` なしでは既存ファイルを上書きしません。設定ファイルを丸ごと +置き換えると、そこにあった他のプロバイダー、エージェント、MCP エントリが失われるからです。 +::: + +key が直列化されることはありません。設定にはクライアントの環境変数参照だけが入り、secret は環境に +残ります。ループバックのプロキシ (既定の `127.0.0.1`) では admission key 自体が不要で、参照は使わ +れません。プロキシをループバック外にバインドするときだけ変数を設定してください。admission key の +発行方法は [リモートアクセス](/ja/reference/configuration/#リモートアクセス) を参照してください。 +上流プロバイダーの key はまったく別物で、[プロバイダー](/ja/guides/providers/) で設定します。Pi +ガイドは英語のみです: [Pi](/guides/pi/)。 + +同じペイロードを `GET /api/client-config` が返し、ダッシュボードの API タブが描画するので、CLI と +API と GUI が異なるバイトを見せることはありません。 + ## 認証 ### `ocx login ` diff --git a/docs-site/src/content/docs/ko/reference/cli.md b/docs-site/src/content/docs/ko/reference/cli.md index 2acd60981..abd182cff 100644 --- a/docs-site/src/content/docs/ko/reference/cli.md +++ b/docs-site/src/content/docs/ko/reference/cli.md @@ -368,6 +368,56 @@ security find-generic-password -w openrouter | ocx account add-key openrouter -- `--json`은 `{ ok: true, id: string | null, label?: string }`을 반환하며 key를 포함하지 않습니다. +### `ocx export --client ` + +실행 중인 프록시에 연결된 클라이언트 설정을 출력합니다. opencode와 Pi는 환경 변수가 아니라 각자의 +JSON 설정 파일에서 프로바이더를 읽으므로, 이 명령은 `opencodex` 프로바이더 블록(base URL, 모델 +목록, 클라이언트가 해석하는 환경 변수 참조)을 직렬화해 줍니다. 사용자가 직접 자신의 파일에 병합 +합니다. + +프록시가 실행 중이어야 합니다. 명령이 실행 중인 포트를 찾아 `/api/models`를 읽고, 지금 Codex가 +볼 수 있는 모델만 내보냅니다. + +| 플래그 | 동작 | +| --- | --- | +| `--client ` | 필수. 클라이언트 방언을 고릅니다. opencode는 key 기반 `provider` 객체, Pi는 `providers` 배열입니다. | +| `--json` | stdout에 설정 JSON만 출력하므로 리다이렉트해도 바이트가 정확합니다. `--out` 기록 알림을 포함한 모든 진단 메시지는 stderr로 갑니다. | +| `--out ` | 설정을 ``에 씁니다. 이미 있는 파일은 덮어쓰지 않고 거부합니다. | +| `--force` | `--out`이 기존 파일을 덮어쓰도록 허용합니다. | + +```bash +ocx export --client opencode # 설정과 함께 대상 경로, 병합 경고, 개수 출력 +ocx export --client pi --json > pi-models.json # 파이프나 diff에 쓸 바이트 정확한 JSON +ocx export --client opencode --out ~/opencodex-opencode.json +``` + +`--json` 없이 실행하면 JSON이 먼저 나오고, 이어서 표준 대상 경로, 병합 경고, 환경 변수 export +줄, 모델 개수와 컨텍스트 한도가 없는 행 수(해당 모델은 클라이언트 기본값을 씁니다)가 출력됩니다. + +| 클라이언트 | 표준 대상 경로 | 다운로드 파일명 | 환경 변수 | +| --- | --- | --- | --- | +| `opencode` | `~/.config/opencode/opencode.json` (`XDG_CONFIG_HOME`이 설정되면 그쪽이 우선) | `opencode.json` | `OPENCODEX_OPENCODE_API_KEY` | +| `pi` | `~/.pi/agent/models.json` | `pi-models.json` | `OPENCODEX_API_KEY` | + +두 환경 변수 이름은 서로 다르며, 각 클라이언트는 자기 것만 해석합니다. opencode는 +`{env:OPENCODEX_OPENCODE_API_KEY}`를, Pi는 `$OPENCODEX_API_KEY`를 읽습니다. + +:::caution[교체가 아니라 병합] +`ocx export`는 실제 클라이언트 설정 파일을 절대 쓰지 않습니다. 대상 경로는 직접 병합하라고 +출력하는 것이며, `--out`도 `--force` 없이는 기존 파일을 덮어쓰지 않습니다. 설정 파일을 통째로 +교체하면 그 안에 있던 다른 프로바이더, 에이전트, MCP 항목이 사라지기 때문입니다. +::: + +key는 절대 직렬화되지 않습니다. 설정에는 클라이언트의 환경 변수 참조만 들어가고 secret은 환경에 +남습니다. 루프백 프록시(기본값 `127.0.0.1`)는 admission key 자체가 필요 없으며, 참조는 그냥 쓰이지 +않습니다. 프록시가 루프백 밖으로 바인딩될 때만 변수를 설정하세요. admission key 발급 방법은 +[원격 접근](/ko/reference/configuration/#원격-접근)을 참고하세요. 상위 프로바이더의 key는 완전히 +별개이며 [프로바이더](/ko/guides/providers/)에서 설정합니다. Pi 가이드는 영어로만 제공됩니다: +[Pi](/guides/pi/). + +같은 페이로드를 `GET /api/client-config`가 제공하고 대시보드 API 탭이 렌더링하므로, CLI와 API, +GUI가 서로 다른 바이트를 보여줄 수 없습니다. + ## 인증 ### `ocx login ` diff --git a/docs-site/src/content/docs/reference/cli.md b/docs-site/src/content/docs/reference/cli.md index 4e90900fc..afb49f16b 100644 --- a/docs-site/src/content/docs/reference/cli.md +++ b/docs-site/src/content/docs/reference/cli.md @@ -198,6 +198,7 @@ routes, validation, live configuration, and catalog refresh side effects as the | Agent policy | `ocx agent injection|effort|subagents|fallback|sidecar ...` | | Observability | `ocx observe logs|usage|storage|memory|debug ...` | | API admission | `ocx access key|endpoints|models|test ...` | +| Client configs | `ocx export --client ...` | | Claude Code | `ocx claude config status|set ...` | | Grok Build | `ocx grok status|exclude|include|set|clear|apply ...` | | Runtime control | `ocx system status|settings|startup|diagnostics|sync|update ...` | @@ -215,6 +216,7 @@ ocx route combo set reliable --targets ark/model-a:2,openai/gpt-5.5 ocx agent subagents set ark/model-a,openai/gpt-5.5 ocx observe usage --range 30d --json ocx access key create deployment +ocx export --client opencode ocx system settings --stream-mode eager-relay ``` @@ -425,6 +427,57 @@ security find-generic-password -w openrouter | ocx account add-key openrouter -- `--json` returns `{ ok: true, id: string | null, label?: string }` and never includes the key. +### `ocx export --client ` + +Print a client config wired to the running proxy. opencode and [Pi](/guides/pi/) read providers +from their own JSON config rather than environment variables, so this command serializes the +`opencodex` provider block — base URL, model list, and the client's env reference — for you to +merge into that file. + +The proxy must be running; the command resolves its live port, reads `/api/models`, and emits only +models Codex can currently see. + +| Flag | Action | +| --- | --- | +| `--client ` | Required. Selects the client dialect: opencode's keyed `provider` object or Pi's `providers` array. | +| `--json` | Print only the config JSON on stdout, so a redirect captures byte-exact output. Every diagnostic, including the `--out` write note, goes to stderr. | +| `--out ` | Write the config to ``. Refuses to replace an existing file. | +| `--force` | Allow `--out` to replace an existing file. | + +```bash +ocx export --client opencode # config plus destination, merge warning, and counts +ocx export --client pi --json > pi-models.json # byte-exact JSON for a pipe or a diff +ocx export --client opencode --out ~/opencodex-opencode.json +``` + +Without `--json` the JSON leads, then the canonical destination path, the merge warning, the env +export line, and a model count with how many rows omit context limits (the client applies its own +defaults for those). + +| Client | Canonical destination | Download filename | Env var | +| --- | --- | --- | --- | +| `opencode` | `~/.config/opencode/opencode.json` (`XDG_CONFIG_HOME` wins when set) | `opencode.json` | `OPENCODEX_OPENCODE_API_KEY` | +| `pi` | `~/.pi/agent/models.json` | `pi-models.json` | `OPENCODEX_API_KEY` | + +The two env var names are different, and each client only interpolates its own. opencode reads +`{env:OPENCODEX_OPENCODE_API_KEY}`; Pi reads `$OPENCODEX_API_KEY`. + +:::caution[Merge, never replace] +`ocx export` never writes your real client config. The destination is printed for you to merge by +hand, and `--out` refuses to overwrite an existing file without `--force`, because replacing a +config destroys the other providers, agents, and MCP entries already in it. +::: + +No key is ever serialized. The config carries only the client's env reference, so the secret stays +in your environment. A loopback proxy (`127.0.0.1`, the default) requires no admission key at all — +the reference is simply unused. Set the variable only when the proxy binds beyond loopback; see +[Remote access](/reference/configuration/#remote-access) for how admission keys are issued. Keys for +the upstream providers themselves are a separate thing entirely, configured per +[Providers](/guides/providers/). + +The same payload is served by `GET /api/client-config` and rendered on the dashboard's API tab, so +the CLI, the API, and the GUI can never show different bytes. + ## Authentication ### `ocx login ` diff --git a/docs-site/src/content/docs/ru/reference/cli.md b/docs-site/src/content/docs/ru/reference/cli.md index 80493b7d5..7827414c6 100644 --- a/docs-site/src/content/docs/ru/reference/cli.md +++ b/docs-site/src/content/docs/ru/reference/cli.md @@ -361,6 +361,59 @@ security find-generic-password -w openrouter | ocx account add-key openrouter -- `--json` возвращает `{ ok: true, id: string | null, label?: string }` и никогда не включает ключ. +### `ocx export --client ` + +Печатает конфигурацию клиента, подключённую к запущенному прокси. opencode и Pi читают провайдеров +из собственных JSON-файлов, а не из переменных окружения, поэтому команда сериализует блок +провайдера `opencodex` — базовый URL, список моделей и ссылку на переменную окружения, которую +понимает клиент, — а вы вручную вливаете его в свой файл. + +Прокси должен быть запущен: команда определяет его живой порт, читает `/api/models` и выводит +только те модели, которые Codex видит прямо сейчас. + +| Флаг | Действие | +| --- | --- | +| `--client ` | Обязательный. Выбирает диалект клиента: объект `provider` с ключами у opencode или массив `providers` у Pi. | +| `--json` | Печатает в stdout только JSON конфигурации, поэтому перенаправление даёт побайтово точный файл. Вся диагностика, включая уведомление о записи `--out`, идёт в stderr. | +| `--out ` | Записывает конфигурацию в ``. Отказывается заменять существующий файл. | +| `--force` | Разрешает `--out` заменить существующий файл. | + +```bash +ocx export --client opencode # конфигурация плюс путь назначения, предупреждение и счётчики +ocx export --client pi --json > pi-models.json # побайтово точный JSON для конвейера или diff +ocx export --client opencode --out ~/opencodex-opencode.json +``` + +Без `--json` сначала идёт JSON, затем канонический путь назначения, предупреждение о слиянии, +строка export для переменной окружения и число моделей вместе с числом строк без лимитов контекста +(для них клиент применяет собственные значения по умолчанию). + +| Клиент | Канонический путь | Имя файла для скачивания | Переменная окружения | +| --- | --- | --- | --- | +| `opencode` | `~/.config/opencode/opencode.json` (при заданном `XDG_CONFIG_HOME` — там) | `opencode.json` | `OPENCODEX_OPENCODE_API_KEY` | +| `pi` | `~/.pi/agent/models.json` | `pi-models.json` | `OPENCODEX_API_KEY` | + +Имена переменных различаются, и каждый клиент подставляет только своё. opencode читает +`{env:OPENCODEX_OPENCODE_API_KEY}`, Pi — `$OPENCODEX_API_KEY`. + +:::caution[Слияние, а не замена] +`ocx export` никогда не пишет ваш настоящий файл конфигурации клиента. Путь назначения печатается +для ручного слияния, а `--out` без `--force` отказывается перезаписать существующий файл: замена +конфигурации целиком уничтожает уже настроенных в ней провайдеров, агентов и записи MCP. +::: + +Ключ никогда не сериализуется: конфигурация несёт только ссылку на переменную окружения, а секрет +остаётся в окружении. Прокси на loopback (`127.0.0.1` по умолчанию) вообще не требует ключа допуска +— ссылка просто не используется. Задавайте переменную только тогда, когда прокси привязан за +пределами loopback; о выдаче ключей допуска см. +[Удалённый доступ](/ru/reference/configuration/#удалённый-доступ). Ключи самих вышестоящих +провайдеров — совершенно отдельная вещь, они настраиваются согласно +[Провайдерам](/ru/guides/providers/). Руководство по Pi доступно только на английском: +[Pi](/guides/pi/). + +Ту же полезную нагрузку отдаёт `GET /api/client-config`, и её же рисует вкладка API в дашборде, так +что CLI, API и GUI не могут показать разные байты. + ## Аутентификация ### `ocx login ` diff --git a/docs-site/src/content/docs/zh-cn/reference/cli.md b/docs-site/src/content/docs/zh-cn/reference/cli.md index 00f58c054..b6a1e4a5c 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli.md @@ -314,6 +314,51 @@ security find-generic-password -w openrouter | ocx account add-key openrouter -- `--json` 返回 `{ ok: true, id: string | null, label?: string }`,且绝不包含 key。 +### `ocx export --client ` + +打印一份连接到正在运行的代理的客户端配置。opencode 和 Pi 从各自的 JSON 配置文件而不是环境变量读取 +provider,因此该命令会序列化 `opencodex` provider 块(base URL、模型列表以及客户端解析的环境变量 +引用),由你自己合并进配置文件。 + +代理必须处于运行状态。命令会解析其运行端口、读取 `/api/models`,只导出 Codex 当前可见的模型。 + +| 参数 | 作用 | +| --- | --- | +| `--client ` | 必填。选择客户端方言:opencode 的键控 `provider` 对象或 Pi 的 `providers` 数组。 | +| `--json` | 仅在 stdout 打印配置 JSON,因此重定向得到的字节完全一致。包括 `--out` 写入提示在内的所有诊断信息都走 stderr。 | +| `--out ` | 把配置写入 ``,拒绝替换已存在的文件。 | +| `--force` | 允许 `--out` 替换已存在的文件。 | + +```bash +ocx export --client opencode # 配置外加目标路径、合并警告和计数 +ocx export --client pi --json > pi-models.json # 供管道或 diff 使用的精确 JSON +ocx export --client opencode --out ~/opencodex-opencode.json +``` + +不带 `--json` 时先输出 JSON,然后是标准目标路径、合并警告、环境变量 export 行,以及模型数量和其中 +缺少上下文上限的行数(这些模型会使用客户端自己的默认值)。 + +| 客户端 | 标准目标路径 | 下载文件名 | 环境变量 | +| --- | --- | --- | --- | +| `opencode` | `~/.config/opencode/opencode.json`(设置了 `XDG_CONFIG_HOME` 时以它为准) | `opencode.json` | `OPENCODEX_OPENCODE_API_KEY` | +| `pi` | `~/.pi/agent/models.json` | `pi-models.json` | `OPENCODEX_API_KEY` | + +两个环境变量名并不相同,且每个客户端只解析自己的那一个。opencode 读取 +`{env:OPENCODEX_OPENCODE_API_KEY}`,Pi 读取 `$OPENCODEX_API_KEY`。 + +:::caution[合并,而不是替换] +`ocx export` 绝不会写入你真正的客户端配置文件。目标路径只是打印出来供你手动合并,`--out` 在没有 +`--force` 时也不会覆盖已有文件——因为整体替换配置会销毁其中已有的其他 provider、agent 和 MCP 条目。 +::: + +配置中绝不会序列化任何 key,只包含客户端的环境变量引用,密钥始终留在环境里。回环代理(默认的 +`127.0.0.1`)完全不需要准入密钥,该引用不会被用到。只有当代理绑定到回环之外时才需要设置这个变量; +准入密钥的签发方式见[远程访问](/zh-cn/reference/configuration/#远程访问)。上游 provider 自己的密钥 +是完全不同的东西,按[提供商](/zh-cn/guides/providers/)配置。Pi 指南仅提供英文版:[Pi](/guides/pi/)。 + +同一份负载由 `GET /api/client-config` 提供并由控制台的 API 标签页渲染,因此 CLI、API 和 GUI 不可能 +显示不同的字节。 + ## 认证 ### `ocx login ` From f4f77744ea2076f19c7ee783816e02ea0d9f02fd Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 1 Aug 2026 23:01:20 +0900 Subject: [PATCH 6/7] fix(gui): gate the client-config fetch behind an explicit cancel flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit React Doctor flagged the setter after `await` in the panel's effect. The write was already safe — every result carries its request key and a superseded run's key no longer matches — but the guard was an `AbortController.signal.aborted` check, which states "the request was cancelled", not "this run may no longer write". An explicit `cancelled` flag set in the cleanup makes the invariant local to the setter, so a reader (and the linter) can see why a stale resolve cannot land. The abort stays: it stops the in-flight request, the flag stops the write. --- .../apikeys-workspace/ClientConfigPanel.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/gui/src/components/apikeys-workspace/ClientConfigPanel.tsx b/gui/src/components/apikeys-workspace/ClientConfigPanel.tsx index 11072c2d8..c31a507ce 100644 --- a/gui/src/components/apikeys-workspace/ClientConfigPanel.tsx +++ b/gui/src/components/apikeys-workspace/ClientConfigPanel.tsx @@ -62,6 +62,11 @@ export default function ClientConfigPanel({ useEffect(() => { const controller = new AbortController(); + // `cancelled` is what actually gates the setters. The abort signal alone left the + // guard implicit — a reader (and react-doctor's no-set-state-after-await-in-effect) + // cannot tell from the setter call that a superseded run can no longer write. Both + // stay: abort stops the in-flight request, this flag stops the write. + let cancelled = false; void (async () => { try { const res = await fetch(`${apiBase}/api/client-config?client=${encodeURIComponent(client)}`, { @@ -69,15 +74,18 @@ export default function ClientConfigPanel({ }); if (!res.ok) throw new Error(await apiErrorMessage(res, t("api.clientConfig.loadFailed"))); const envelope = await res.json() as ClientConfigEnvelope; - if (controller.signal.aborted) return; + if (cancelled) return; setResult({ key: requestKey, data: envelope, error: null }); } catch (cause) { - if (controller.signal.aborted) return; + if (cancelled) return; const message = cause instanceof Error && cause.message ? cause.message : t("api.clientConfig.loadFailed"); setResult({ key: requestKey, data: null, error: message }); } })(); - return () => controller.abort(); + return () => { + cancelled = true; + controller.abort(); + }; }, [apiBase, client, requestKey, t]); const settled = result !== null && result.key === requestKey ? result : null; From 4521f1d9e430933bf9a79e5fd7f82c28e111b4c4 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 1 Aug 2026 23:09:59 +0900 Subject: [PATCH 7/7] test(cli): map /api/client-config to ocx export in the parity sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The headless-parity sweep asserts every GUI management endpoint has a documented CLI resource. Phase 030 added GET /api/client-config without registering it, so the sweep went red — correctly: the route and the CLI command shipped in the same unit and `ocx export` is exactly its mirror. --- tests/cli-headless-parity.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/cli-headless-parity.test.ts b/tests/cli-headless-parity.test.ts index fbb87a800..746efce3a 100644 --- a/tests/cli-headless-parity.test.ts +++ b/tests/cli-headless-parity.test.ts @@ -71,6 +71,7 @@ describe("headless GUI parity CLI", () => { ["/api/custom-models", "ocx models"], ["/api/model", "ocx models"], ["/api/combos", "ocx combo"], + ["/api/client-config", "ocx export"], ["/api/debug", "ocx debug/observe"], ["/api/diagnostics", "ocx system"], ["/api/effort", "ocx agent"],