From 372139bffa3ab06fb9b7f29fd1162aeae859a6d2 Mon Sep 17 00:00:00 2001 From: codingbo Date: Wed, 14 Oct 2026 02:14:44 +0900 Subject: [PATCH 1/5] feat(clients): add Qoder client integration and config export Rebased onto dev as a single commit for #5950. Kept every client that dev added (Command Code, Kilo, Factory Droid) and placed Qoder beside them in each list, raising the client counts to nineteen. Dev's new CLI integration-plan decoder now lists qoder and accepts its modelConfigs.customModels.* managed path, matching the GUI decoder. --- .../src/content/docs/guides/integrations.md | 19 ++- gui/src/app-routing.ts | 1 + .../client-config-clients.ts | 4 +- gui/src/components/integration-marks.ts | 1 + gui/src/i18n/de.ts | 3 + gui/src/i18n/en.ts | 3 + gui/src/i18n/fr.ts | 3 + gui/src/i18n/ja.ts | 3 + gui/src/i18n/ko.ts | 3 + gui/src/i18n/ru.ts | 3 + gui/src/i18n/tr.ts | 3 + gui/src/i18n/vi.ts | 3 + gui/src/i18n/zh-TW.ts | 3 + gui/src/i18n/zh.ts | 3 + .../integrations/FileIntegrationPage.tsx | 2 + gui/src/pages/integrations/integration-api.ts | 2 + .../pages/integrations/integration-tabs.ts | 2 + .../pages/integrations/overview-clients.ts | 1 + gui/tests/client-config-panel.test.tsx | 4 +- gui/tests/integrations-api.test.ts | 2 +- scripts/test-layout/layout.json | 2 +- src/cli/help.ts | 2 +- src/cli/integration-plan-dto.ts | 2 + src/cli/registry.ts | 4 +- src/clients/config-export.ts | 14 ++ src/clients/config-export/contracts.ts | 1 + src/clients/config-export/qoder.ts | 73 ++++++++++ src/integrations/mutation-plan.ts | 1 + src/integrations/registry.ts | 6 + structure/clients/integrations.md | 14 ++ structure/dashboard-and-usage.md | 2 +- tests/clients/integrations-state.test.ts | 4 +- tests/clients/qoder-client.test.ts | 128 ++++++++++++++++++ .../client-config-export-new-clients.test.ts | 6 +- tests/config/client-config-export.test.ts | 4 +- tests/fixtures/test-layout-expected.json | 2 +- tests/gui/integrations-invariants.test.ts | 3 +- 37 files changed, 319 insertions(+), 17 deletions(-) create mode 100644 src/clients/config-export/qoder.ts create mode 100644 tests/clients/qoder-client.test.ts diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index 0badb584218..96bc6de9841 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -4,7 +4,7 @@ description: Connect opencodex to OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code --- The **Connect** page writes opencodex's provider block into a client's own config -file, and removes it again. Eighteen clients work this way, each with a switch: +file, and removes it again. Nineteen clients work this way, each with a switch: | Client | Config file | Format | When the change takes effect | Credential | |---|---|---|---|---| @@ -22,6 +22,7 @@ file, and removes it again. Eighteen clients work this way, each with a switch: | Aside | `~/.aside/u//models.json` | JSON | after fully quitting and reopening Aside | loopback placeholder | | Raycast | `~/.config/raycast/ai/providers.yaml` | YAML | immediately on save — Raycast watches the file | none — loopback only | | omo (Pi / senpi) | `~/.omo/agent/models.json` | JSON | new sessions | loopback placeholder | +| Qoder | `~/.qoder/settings.json` | JSON | new sessions | loopback placeholder | | Cline CLI | `~/.cline/data/settings/providers.json` and sibling `models.json` | JSON pair | after stopping and restarting Cline | loopback placeholder | | Kilo | first existing `kilo.jsonc`, `kilo.json`, `opencode.jsonc`, `opencode.json`, or `config.json` under `~/.config/kilo` | JSONC | new sessions | `OPENCODEX_KILO_API_KEY` | | Command Code | `~/.commandcode/providers.json` (on Windows with the default home: use `HOME ?? USERPROFILE` only if nonblank and Windows-absolute, otherwise `homedir()`; a separately supplied home is preserved; append `.commandcode\providers.json`) | JSON | next launch of Command Code | none — keyless loopback (`apiKey: false`) | @@ -33,6 +34,22 @@ file, and removes it again. Eighteen clients work this way, each with a switch: [omo (Codex / LazyCodex) role models](#omo-codex--lazycodex-role-models). OpenCode-based omo (oh-my-opencode) keeps its own config under OpenCode; opencodex does not read or write it. +Qoder IDE / qodercli supports export and managed configuration through the Dashboard and CLI: + +```bash +ocx export --client qoder --json +ocx integration client enable --client qoder +ocx integration client disable --client qoder +``` + +Qoder uses `~/.qoder/settings.json`. Enable merges `providers.opencodex` and +OpenCodex entries in `modelConfigs.customModels`, preserving other providers, custom models, +and settings. Disable removes the recorded OpenCodex entries; restore uses the existing +snapshot workflow. The provider's default model is the first model in the sorted export. +Custom models use their catalog context window, falling back to 128000 input tokens. +The generated API key is a non-secret placeholder: Qoder integration requires loopback +access and refuses an authenticated non-loopback bind. + Generated catalogs include only enabled models from each provider selection. This applies to both downloads and managed integrations, including Pi and Aside. The management model list still shows the full roster so you can enable additional models. diff --git a/gui/src/app-routing.ts b/gui/src/app-routing.ts index 8415f7bd6ba..195504dd223 100644 --- a/gui/src/app-routing.ts +++ b/gui/src/app-routing.ts @@ -147,6 +147,7 @@ export const INTEGRATION_TAB_HASHES = [ "integrations/cline", "integrations/kilo", "integrations/droid", + "integrations/qoder", ] as const; /** diff --git a/gui/src/components/apikeys-workspace/client-config-clients.ts b/gui/src/components/apikeys-workspace/client-config-clients.ts index 8689f3b0a46..11b0d35b26e 100644 --- a/gui/src/components/apikeys-workspace/client-config-clients.ts +++ b/gui/src/components/apikeys-workspace/client-config-clients.ts @@ -8,10 +8,11 @@ * with EXPORT_CLIENT_IDS by hand; adding a client server-side renders no row * until this tuple changes. */ -export const CLIENTS = ["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "commandcode", "prime", "aside", "raycast", "omo", "cline", "kilo", "droid"] as const; +export const CLIENTS = ["qoder", "opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "commandcode", "prime", "aside", "raycast", "omo", "cline", "kilo", "droid"] as const; export type ExportClientId = (typeof CLIENTS)[number]; export const CLIENT_LABEL_KEYS = { + qoder: "api.clientConfig.clientQoder", opencode: "api.clientConfig.clientOpencode", pi: "api.clientConfig.clientPi", omp: "api.clientConfig.clientOmp", @@ -64,6 +65,7 @@ export const CLIENT_LABEL_KEYS = { * names them. */ export const CLIENT_MARKS: Partial> = { + qoder: "/provider-icons/qoder.svg", opencode: "/provider-icons/opencode.svg", pi: "/provider-icons/pi.svg", omp: "/provider-icons/oh-my-pi.svg", diff --git a/gui/src/components/integration-marks.ts b/gui/src/components/integration-marks.ts index a7db3023ba9..40e7e277f5e 100644 --- a/gui/src/components/integration-marks.ts +++ b/gui/src/components/integration-marks.ts @@ -63,6 +63,7 @@ export const INTEGRATION_MARKS: Record = { commandcode: CLIENT_MARKS.commandcode ?? null, kilo: CLIENT_MARKS.kilo ?? null, droid: CLIENT_MARKS.droid ?? null, + qoder: CLIENT_MARKS.qoder ?? null, }; /** diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index b1baf027a32..9cec5a6564f 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1567,6 +1567,9 @@ export const de: Record = { "integrations.tab.aside": "Aside", "integrations.tab.raycast": "Raycast", "integrations.tab.omo": "omo", + "integrations.tab.qoder": "Qoder", + "api.clientConfig.clientQoder": "Qoder", + "integrations.semantics.qoder": "Verwaltet OpenCodex-Anbieter und eigene Modelle in ~/.qoder/settings.json. Erfordert Loopback-Zugriff. Andere Einstellungen bleiben erhalten; Rückgängig stellt den Snapshot wieder her.", "integrations.tab.cline": "Cline CLI", "integrations.tab.kilo": "Kilo", "integrations.tab.droid": "Factory Droid", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index c13ed81ae9e..7e2ba2804e0 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2135,6 +2135,9 @@ export const en = { "integrations.tab.aside": "Aside", "integrations.tab.raycast": "Raycast", "integrations.tab.omo": "omo", + "integrations.tab.qoder": "Qoder", + "api.clientConfig.clientQoder": "Qoder", + "integrations.semantics.qoder": "Manages OpenCodex providers and custom models in ~/.qoder/settings.json. Requires loopback access. Other settings are preserved; Undo restores the snapshot.", "integrations.tab.cline": "Cline CLI", "integrations.tab.kilo": "Kilo", "integrations.tab.droid": "Factory Droid", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 3283bd6a766..2da87f9dc43 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2107,6 +2107,9 @@ export const fr: Record = { "integrations.tab.aside": "Aside", "integrations.tab.raycast": "Raycast", "integrations.tab.omo": "omo", + "integrations.tab.qoder": "Qoder", + "api.clientConfig.clientQoder": "Qoder", + "integrations.semantics.qoder": "Gère les fournisseurs OpenCodex et les modèles personnalisés dans ~/.qoder/settings.json. Nécessite un accès local en boucle. Les autres paramètres sont conservés ; Annuler restaure la sauvegarde.", "integrations.tab.cline": "Cline CLI", "integrations.tab.kilo": "Kilo", "integrations.tab.droid": "Factory Droid", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 020e9be2c54..b8af80188eb 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2002,6 +2002,9 @@ export const ja: Record = { "integrations.tab.aside": "Aside", "integrations.tab.raycast": "Raycast", "integrations.tab.omo": "omo", + "integrations.tab.qoder": "Qoder", + "api.clientConfig.clientQoder": "Qoder", + "integrations.semantics.qoder": "~/.qoder/settings.json の OpenCodex プロバイダーとカスタムモデルを管理します。ループバック接続が必要です。他の設定は保持され、元に戻すとスナップショットが復元されます。", "integrations.tab.cline": "Cline CLI", "integrations.tab.kilo": "Kilo", "integrations.tab.droid": "Factory Droid", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 10e79645bea..d82537229cf 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1611,6 +1611,9 @@ export const ko: Record = { "integrations.tab.aside": "Aside", "integrations.tab.raycast": "Raycast", "integrations.tab.omo": "omo", + "integrations.tab.qoder": "Qoder", + "api.clientConfig.clientQoder": "Qoder", + "integrations.semantics.qoder": "~/.qoder/settings.json의 OpenCodex 공급자와 사용자 지정 모델을 관리합니다. 루프백 접근이 필요합니다. 다른 설정은 유지되며 실행 취소 시 스냅샷을 복원합니다.", "integrations.tab.cline": "Cline CLI", "integrations.tab.kilo": "Kilo", "integrations.tab.droid": "Factory Droid", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 20796d6213d..5baa1942d14 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2093,6 +2093,9 @@ export const ru: Record = { "integrations.tab.aside": "Aside", "integrations.tab.raycast": "Raycast", "integrations.tab.omo": "omo", + "integrations.tab.qoder": "Qoder", + "api.clientConfig.clientQoder": "Qoder", + "integrations.semantics.qoder": "Управляет провайдерами OpenCodex и пользовательскими моделями в ~/.qoder/settings.json. Требуется доступ через loopback. Остальные настройки сохраняются; отмена восстанавливает снимок.", "integrations.tab.cline": "Cline CLI", "integrations.tab.kilo": "Kilo", "integrations.tab.droid": "Factory Droid", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 50ecb6ef480..a5242dfd27f 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2120,6 +2120,9 @@ export const tr: Record = { "integrations.tab.aside": "Aside", "integrations.tab.raycast": "Raycast", "integrations.tab.omo": "omo", + "integrations.tab.qoder": "Qoder", + "api.clientConfig.clientQoder": "Qoder", + "integrations.semantics.qoder": "~/.qoder/settings.json içindeki OpenCodex sağlayıcılarını ve özel modelleri yönetir. Loopback erişimi gerektirir. Diğer ayarlar korunur; Geri al, anlık görüntüyü geri yükler.", "integrations.tab.cline": "Cline CLI", "integrations.tab.kilo": "Kilo", "integrations.tab.droid": "Factory Droid", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 206f6292a7a..0bdcea43641 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -2083,6 +2083,9 @@ export const vi: Record = { "integrations.tab.aside": "Aside", "integrations.tab.raycast": "Raycast", "integrations.tab.omo": "omo", + "integrations.tab.qoder": "Qoder", + "api.clientConfig.clientQoder": "Qoder", + "integrations.semantics.qoder": "Quản lý nhà cung cấp OpenCodex và mô hình tùy chỉnh trong ~/.qoder/settings.json. Yêu cầu truy cập loopback. Giữ nguyên các cài đặt khác; Hoàn tác khôi phục bản chụp.", "integrations.tab.cline": "Cline CLI", "integrations.tab.kilo": "Kilo", "integrations.tab.droid": "Factory Droid", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 01cdc1b5e37..ebd784e3c9c 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -3019,6 +3019,9 @@ export const zhTW: Record = { "integrations.tab.aside": "Aside", "integrations.tab.raycast": "Raycast", "integrations.tab.omo": "omo", + "integrations.tab.qoder": "Qoder", + "api.clientConfig.clientQoder": "Qoder", + "integrations.semantics.qoder": "管理 ~/.qoder/settings.json 中的 OpenCodex 供應商和自訂模型。需要回環存取。保留其他設定;復原會還原快照。", "integrations.tab.cline": "Cline CLI", "integrations.tab.kilo": "Kilo", "integrations.tab.droid": "Factory Droid", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 6e12ed34d93..ee57413daf0 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1584,6 +1584,9 @@ export const zh: Record = { "integrations.tab.aside": "Aside", "integrations.tab.raycast": "Raycast", "integrations.tab.omo": "omo", + "integrations.tab.qoder": "Qoder", + "api.clientConfig.clientQoder": "Qoder", + "integrations.semantics.qoder": "管理 ~/.qoder/settings.json 中的 OpenCodex 提供商和自定义模型。需要回环访问。保留其他设置;撤销会恢复快照。", "integrations.tab.cline": "Cline CLI", "integrations.tab.kilo": "Kilo", "integrations.tab.droid": "Factory Droid", diff --git a/gui/src/pages/integrations/FileIntegrationPage.tsx b/gui/src/pages/integrations/FileIntegrationPage.tsx index aa07718901a..85d6d101c6f 100644 --- a/gui/src/pages/integrations/FileIntegrationPage.tsx +++ b/gui/src/pages/integrations/FileIntegrationPage.tsx @@ -88,6 +88,7 @@ const SEMANTICS_KEY: Record = { cline: "integrations.semantics.cline", kilo: "integrations.semantics.kilo", droid: "integrations.semantics.droid", + qoder: "integrations.semantics.qoder", }; const TAB_LABEL_KEY: Record = { @@ -109,6 +110,7 @@ const TAB_LABEL_KEY: Record = { cline: "integrations.tab.cline", kilo: "integrations.tab.kilo", droid: "integrations.tab.droid", + qoder: "integrations.tab.qoder", }; type FileIntegrationPageProps = { diff --git a/gui/src/pages/integrations/integration-api.ts b/gui/src/pages/integrations/integration-api.ts index 0d78dc62233..b922ac80333 100644 --- a/gui/src/pages/integrations/integration-api.ts +++ b/gui/src/pages/integrations/integration-api.ts @@ -2,6 +2,7 @@ import { readJsonIfOk } from "../../fetch-json"; import { parseAsideProfileStatus, parseAsideProfileOutcomes, type AsideProfileOutcome } from "./aside-profile-contract"; export const FILE_INTEGRATION_CLIENTS = [ + "qoder", "opencode", "pi", "omp", @@ -276,6 +277,7 @@ const PLAN_SCHEMA_PATHS = new Set([ "providers.opencodex", "models.providers.opencodex", "models.*", + "modelConfigs.customModels.*", "llm-pi-ai.providers.opencodex", "custom_provider.opencodex", "providers.[id=opencodex]", diff --git a/gui/src/pages/integrations/integration-tabs.ts b/gui/src/pages/integrations/integration-tabs.ts index 8fbad751f89..c34b214b126 100644 --- a/gui/src/pages/integrations/integration-tabs.ts +++ b/gui/src/pages/integrations/integration-tabs.ts @@ -53,6 +53,7 @@ export const TABS: readonly TabDefinition[] = [ { id: "aside", hash: "integrations/aside", labelKey: "integrations.tab.aside" }, { id: "raycast", hash: "integrations/raycast", labelKey: "integrations.tab.raycast" }, { id: "omo", hash: "integrations/omo", labelKey: "integrations.tab.omo" }, + { id: "qoder", hash: "integrations/qoder", labelKey: "integrations.tab.qoder" }, { id: "cline", hash: "integrations/cline", labelKey: "integrations.tab.cline" }, { id: "kilo", hash: "integrations/kilo", labelKey: "integrations.tab.kilo" }, { id: "droid", hash: "integrations/droid", labelKey: "integrations.tab.droid" }, @@ -77,4 +78,5 @@ export const FILE_CLIENTS = new Set([ "cline", "kilo", "droid", + "qoder", ]); diff --git a/gui/src/pages/integrations/overview-clients.ts b/gui/src/pages/integrations/overview-clients.ts index 105a967a30e..f92906001b5 100644 --- a/gui/src/pages/integrations/overview-clients.ts +++ b/gui/src/pages/integrations/overview-clients.ts @@ -159,6 +159,7 @@ const FILE_LABEL_KEY: Record = { cline: "integrations.tab.cline", kilo: "integrations.tab.kilo", droid: "integrations.tab.droid", + qoder: "integrations.tab.qoder", }; /** A file client's block is in the file for both `current` and `stale`. */ diff --git a/gui/tests/client-config-panel.test.tsx b/gui/tests/client-config-panel.test.tsx index 5d144e200d5..d3e66a8e7d9 100644 --- a/gui/tests/client-config-panel.test.tsx +++ b/gui/tests/client-config-panel.test.tsx @@ -171,7 +171,7 @@ function rowButton(container: HTMLElement, name: string, label: string): HTMLBut } test("the API download surface includes DSH, MiniMax Code, Aside, Raycast and omo as clients", () => { - expect(CLIENTS).toEqual(["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "commandcode", "prime", "aside", "raycast", "omo", "cline", "kilo", "droid"]); + expect(CLIENTS).toEqual(["qoder", "opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "commandcode", "prime", "aside", "raycast", "omo", "cline", "kilo", "droid"]); expect(CLIENT_LABEL_KEYS.dsh).toBe("api.clientConfig.clientDsh"); expect(CLIENT_LABEL_KEYS.mcode).toBe("api.clientConfig.clientMcode"); expect(CLIENT_LABEL_KEYS.zcode).toBe("api.clientConfig.clientZcode"); @@ -251,7 +251,7 @@ test("clients render as rows, not a switch", async () => { expect(container.querySelector(".awi-clientconfig-segmented")).toBeNull(); const names = [...container.querySelectorAll(".awi-clientconfig-name")].map(el => el.textContent); // Row order follows the registry, so a new client appears without a code change here. - expect(names?.slice(0, 2)).toEqual(["OpenCode", "Pi"]); + expect(names?.slice(0, 2)).toEqual(["Qoder", "OpenCode"]); expect(names).toHaveLength(CLIENTS.length); // Each row states where its file goes; a Download with no destination is the // ambiguity the announcement text works to prevent. diff --git a/gui/tests/integrations-api.test.ts b/gui/tests/integrations-api.test.ts index 29d2c29bae1..ec0c386eadf 100644 --- a/gui/tests/integrations-api.test.ts +++ b/gui/tests/integrations-api.test.ts @@ -21,7 +21,7 @@ const originalFetch = globalThis.fetch; test("all registered export clients include Cline in file integrations", () => { expect(FILE_INTEGRATION_CLIENTS).toEqual([ - "opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "commandcode", "prime", "aside", "raycast", "omo", "cline", "kilo", "droid", + "qoder", "opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "commandcode", "prime", "aside", "raycast", "omo", "cline", "kilo", "droid", ]); }); diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 8b82929714c..5b3c6161a4a 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -98,7 +98,7 @@ "cli-kiro-auto-selection.test.ts": "cli", "codebuddy-live-models.test.ts": "providers", "kiro-auto-selection.test.ts": "providers/kiro", "kiro-quota-metrics.test.ts": "providers/kiro", "management-provider-request-pacing.test.ts": "server", "cli-restart-handoff.test.ts": "cli", "restart-replacement.test.ts": "server", "deepseek-artifact-tool-schema.test.ts": "providers", - "client-config-export-output-limit.test.ts": "config", "responses-skills-snapshot.test.ts": "responses", "responses-code-mode-mcp-direct.test.ts": "responses", "local-read-response-proof.test.ts": "server", + "client-config-export-output-limit.test.ts": "config", "qoder-client.test.ts": "clients", "responses-skills-snapshot.test.ts": "responses", "responses-code-mode-mcp-direct.test.ts": "responses", "local-read-response-proof.test.ts": "server", "claude-desktop-system-proxy.test.ts": "claude-integration", "codex-config-drift-heal.test.ts": "codex-integration", "openai-chat-serialized-tool-call-scaling.test.ts": "adapters/openai", "openai-chat-tool-call-id-remint.test.ts": "adapters/openai", diff --git a/src/cli/help.ts b/src/cli/help.ts index 7f1c6d5d992..55e33181e98 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -92,7 +92,7 @@ Usage: ocx api-key Alias of ocx access key ocx access External API keys and endpoint information ocx api Protocol paths: vocabulary, request-path preview, and policy - ocx export --client Print a client config wired to the running proxy (18 clients) + ocx export --client Print a client config wired to the running proxy (19 clients) ocx integration client Enable, disable, inspect or roll back a client integration ocx grok Grok Build model selection and apply ocx system Runtime settings, startup, sync, OpenCodex updates, and Codex CLI inspection diff --git a/src/cli/integration-plan-dto.ts b/src/cli/integration-plan-dto.ts index d43196971aa..09135d2a4cc 100644 --- a/src/cli/integration-plan-dto.ts +++ b/src/cli/integration-plan-dto.ts @@ -26,6 +26,7 @@ export const FILE_INTEGRATION_CLIENTS = [ "commandcode", "kilo", "droid", + "qoder", ] as const; export const INTEGRATION_REFUSAL_REASONS: ReadonlySet = new Set([ @@ -65,6 +66,7 @@ const PLAN_SCHEMA_PATHS = new Set([ "settings.providers.opencodex", "catalog.providers.opencodex", "customModels.*", + "modelConfigs.customModels.*", // ZCode reads its providers from a second file; a plan for it publishes that // file's templates, and a path missing here is rejected as an invalid preview. "config.providerConfigRules.providerRules.[providerId=opencodex]", diff --git a/src/cli/registry.ts b/src/cli/registry.ts index cd1e49c656c..43031f4d84e 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -486,8 +486,8 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ }, { name: "export", - usage: "ocx export --client [--json] [--out ] [--force]", - summary: "Print a client config (OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, gjc, DeepSeek Harness, MiniMax Code, ZCode, Command Code, Prime Agent, Aside, Raycast, omo, Cline, Kilo, Factory Droid) wired to the running proxy.", + usage: "ocx export --client [--json] [--out ] [--force]", + summary: "Print a client config (OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, gjc, DeepSeek Harness, MiniMax Code, ZCode, Command Code, Prime Agent, Aside, Raycast, omo, Cline, Kilo, Factory Droid, Qoder) wired to the running proxy.", details: [ "--json prints the generated document as JSON on stdout; use --out for the client's native format.", "--out writes the native config there and refuses to replace an existing file without --force.", diff --git a/src/clients/config-export.ts b/src/clients/config-export.ts index d1c39ac0273..3df950a3d82 100644 --- a/src/clients/config-export.ts +++ b/src/clients/config-export.ts @@ -82,6 +82,8 @@ import { export type { CommandCodeGeneratedConfig, CommandCodeModelEntry, CommandCodeProviderBlock }; export { buildCommandCodeClientConfig, summarizeCommandCode, buildCommandCodeContribution, commandCodeProviderRoot }; +import { buildQoderClientConfig, summarizeQoder, buildQoderContribution, qoderConfigPath } from "./config-export/qoder"; +export { buildQoderClientConfig, qoderConfigPath, type QoderGeneratedConfig } from "./config-export/qoder"; @@ -1428,6 +1430,18 @@ export function clineSettingsDir(env: OpencodeLaunchEnv = process.env, home: str } export const EXPORT_CLIENTS: Record = { + qoder: { + id: "qoder", + filename: "qoder-settings.json", + destination: env => qoderConfigPath(env), + apiKeyEnv: "", + exportHint: "Qoder reads a non-secret placeholder from settings.json; loopback needs no key.", + build: buildQoderClientConfig, + format: "json", + summarize: summarizeQoder, + buildContribution: buildQoderContribution, + loopbackOnly: true, + }, opencode: { id: "opencode", filename: "opencode.json", diff --git a/src/clients/config-export/contracts.ts b/src/clients/config-export/contracts.ts index 082d6be1451..75b05ed348e 100644 --- a/src/clients/config-export/contracts.ts +++ b/src/clients/config-export/contracts.ts @@ -202,6 +202,7 @@ export interface ExportContext { export type DroidReasoningDefaults = Record; export type ExportClientId = + | "qoder" | "opencode" | "pi" | "omp" diff --git a/src/clients/config-export/qoder.ts b/src/clients/config-export/qoder.ts new file mode 100644 index 00000000000..867c791e7e1 --- /dev/null +++ b/src/clients/config-export/qoder.ts @@ -0,0 +1,73 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; +import { isLoopbackHostname, shouldInjectApiAuthHeader } from "../../codex/loopback-target"; +import { formatSelectorConjunction } from "../../integrations/merge"; +import type { ExportContext, ManagedContribution, OpencodeLaunchEnv } from "./contracts"; +import { LOOPBACK_API_KEY_PLACEHOLDER, OPENCODE_PROVIDER_ID } from "./constants"; +import { authoritativeContextWindow, normalizeExportModels } from "./model-metadata"; + +export function qoderConfigPath(_env: OpencodeLaunchEnv = process.env, home: string = homedir()): string { + return join(home, ".qoder", "settings.json"); +} + +/** Qoder IDE and qodercli share this provider and custom-model catalog. */ +export function buildQoderClientConfig(ctx: ExportContext) { + if (shouldInjectApiAuthHeader(ctx.config) || !isLoopbackHostname(new URL(ctx.baseUrl).hostname)) { + throw new Error("Qoder's placeholder API key requires a loopback-only listener."); + } + return qoderDocument(ctx); +} + +function qoderDocument(ctx: ExportContext) { + const models = normalizeExportModels(ctx.models); + return { + providers: { + [OPENCODE_PROVIDER_ID]: { + type: "openai-compatible", + protocol: "openai", + displayName: "opencodex", + baseUrl: ctx.baseUrl, + apiKey: LOOPBACK_API_KEY_PLACEHOLDER, + ...(models[0] ? { defaultModel: models[0].namespaced } : {}), + models: models.map(model => ({ model: model.namespaced })), + }, + }, + modelConfigs: { + customModels: models.map(model => ({ + provider: OPENCODE_PROVIDER_ID, + apiKey: LOOPBACK_API_KEY_PLACEHOLDER, + model: model.namespaced, + baseURL: ctx.baseUrl, + displayName: model.namespaced, + maxInputTokens: authoritativeContextWindow(model.contextWindow) ?? 128000, + })), + }, + }; +} + +export type QoderGeneratedConfig = ReturnType; + +export function summarizeQoder(document: unknown): { modelCount: number; modelsWithoutLimits: number } { + const models = (document as QoderGeneratedConfig | undefined)?.modelConfigs?.customModels ?? []; + return { modelCount: models.length, modelsWithoutLimits: 0 }; +} + +export function buildQoderContribution(ctx: ExportContext): ManagedContribution { + // The writer gates admission before applying. Classification and disable must + // still work after the operator changes the proxy to a non-loopback bind. + const doc = qoderDocument(ctx); + return { + clientId: "qoder", + fragments: [ + { path: ["providers", OPENCODE_PROVIDER_ID], value: doc.providers[OPENCODE_PROVIDER_ID] }, + ...doc.modelConfigs.customModels.map(model => { + const selector = formatSelectorConjunction([ + { field: "provider", value: OPENCODE_PROVIDER_ID }, + { field: "model", value: model.model }, + ]); + if (selector === null) throw new Error("Qoder model ID cannot be represented by a managed array selector."); + return { path: ["modelConfigs", "customModels", selector], value: model }; + }), + ], + }; +} diff --git a/src/integrations/mutation-plan.ts b/src/integrations/mutation-plan.ts index 515247fb19b..5dc16f47526 100644 --- a/src/integrations/mutation-plan.ts +++ b/src/integrations/mutation-plan.ts @@ -131,6 +131,7 @@ export const DYNAMIC_SEGMENT = "*"; * paths silently have no declaration. */ const CLIENT_MANAGED_PATHS = { + qoder: [["providers", OPENCODE_PROVIDER_ID], ["modelConfigs", "customModels", DYNAMIC_SEGMENT]], opencode: [["provider", OPENCODE_PROVIDER_ID], ["providers", OPENCODE_PROVIDER_ID]], pi: [["providers", OPENCODE_PROVIDER_ID]], omp: [["providers", OPENCODE_PROVIDER_ID]], diff --git a/src/integrations/registry.ts b/src/integrations/registry.ts index af078342d8a..1d24ea005d7 100644 --- a/src/integrations/registry.ts +++ b/src/integrations/registry.ts @@ -17,6 +17,7 @@ import { dirname, join } from "node:path"; import { ClientPathError, buildDroidContribution, + qoderConfigPath, clineConfigPath, clineSettingsDir, droidConfigPath, @@ -280,6 +281,11 @@ function xdgConfigHome(env: NodeJS.ProcessEnv, home: string): string { } export const INTEGRATION_CLIENTS: Record = { + qoder: { + id: "qoder", + configPath: qoderConfigPath, + detectDir: (_env = process.env, home = homedir()) => join(home, ".qoder"), + }, opencode: { id: "opencode", // These take `home` explicitly. The export registry's `destination` reads diff --git a/structure/clients/integrations.md b/structure/clients/integrations.md index f39642644d6..b512456d26c 100644 --- a/structure/clients/integrations.md +++ b/structure/clients/integrations.md @@ -93,6 +93,20 @@ The request preference is interpreted after initial Chat route selection and bef concrete dispatch under the [inbound effort contract](../data-planes/inbound-compat.md#droid-request-defaults). It never authenticates a client or changes admission policy. +## Qoder settings ownership + +Qoder IDE / qodercli uses JSON at `~/.qoder/settings.json`. The Qoder exporter in +`src/clients/config-export.ts` writes `providers.opencodex` and one +`modelConfigs.customModels` entry per model. Array ownership selects both `provider` and +`model`, preserving same-named models belonging to other providers. Apply, refresh and disable +use the existing recorded-fragment writer; restore uses its snapshots. Model IDs that cannot +be represented by the shared selector grammar refuse managed contribution construction. +The default model is the first normalized model; an empty catalog omits the default. +Input limits use authoritative context metadata or Qoder's requested 128000 fallback. +Exports reject non-loopback placeholder use; the writer's existing admission gate protects +apply while permitting cleanup after a bind change. CLI help, Dashboard export rows, integration +tabs and preview-path validation register the same Qoder client. + ## Cursor installed capability reads `src/integrations/cursor-effort-table.ts` reads the installed agent bundle through one regular-file diff --git a/structure/dashboard-and-usage.md b/structure/dashboard-and-usage.md index 93aed0a3c40..c56a02de744 100644 --- a/structure/dashboard-and-usage.md +++ b/structure/dashboard-and-usage.md @@ -21,7 +21,7 @@ Codex account panels expose no Spark quota toggle or setting and retain quota re Dashboard localization uses the English `gui/src/i18n/en.ts` catalog as the complete key and placeholder contract. Every registered locale, including Vietnamese and Brazilian Portuguese (`pt`, HTML `pt-BR`), supplies the same keys; locale-specific Compatibility Lab, log-guard, routing, vision, status-code, and quota-formatting -maps remain total rather than silently falling back to English. +maps remain total rather than silently falling back to English. The Dashboard registers Qoder for export and managed integration under the [Qoder settings ownership contract](clients/integrations.md#qoder-settings-ownership). The Models catalog names three distinct delivery states. A successful management mutation confirms only that the catalog is saved on the hub. `gui/src/api-targets.ts` carries the local machine's diff --git a/tests/clients/integrations-state.test.ts b/tests/clients/integrations-state.test.ts index df06308f431..909d5da0d29 100644 --- a/tests/clients/integrations-state.test.ts +++ b/tests/clients/integrations-state.test.ts @@ -794,9 +794,9 @@ describe("installation detection is independent of config state", () => { * from. Rationale and the per-client table: 020 §1 amendment. */ describe("the loopback-only set is one fact, read through one seam", () => { - test("omp, pi, kimi, gajae, dsh, mcode, zcode, commandcode, prime, aside, raycast, omo and cline are loopback-only and nobody else is", () => { + test("identifies exactly the registered loopback-only clients", () => { const loopbackOnly = INTEGRATION_CLIENT_IDS.filter(id => isLoopbackOnly(id)); - expect(loopbackOnly).toEqual(["pi", "omp", "kimi", "gajae", "dsh", "mcode", "zcode", "commandcode", "prime", "aside", "raycast", "omo", "cline", "droid"]); + expect(loopbackOnly).toEqual(["qoder", "pi", "omp", "kimi", "gajae", "dsh", "mcode", "zcode", "commandcode", "prime", "aside", "raycast", "omo", "cline", "droid"]); }); test("the registry restates nothing — it reads the export spec", () => { diff --git a/tests/clients/qoder-client.test.ts b/tests/clients/qoder-client.test.ts new file mode 100644 index 00000000000..5ee8e6d213a --- /dev/null +++ b/tests/clients/qoder-client.test.ts @@ -0,0 +1,128 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { buildClientConfigText, buildClientContribution, buildQoderClientConfig, EXPORT_CLIENTS, qoderConfigPath, type ExportContext } from "../../src/clients/config-export"; +import { INTEGRATION_CLIENTS } from "../../src/integrations/registry"; +import { createIntegrationStateStore } from "../../src/integrations/store"; +import { applyIntegration, disableIntegration, refreshIntegration, restoreIntegration, type IntegrationWriteInput } from "../../src/integrations/writer"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { handleExportCommand } from "../../src/cli/export-command"; + +const config = { + port: 10100, hostname: "127.0.0.1", defaultProvider: "mock", + providers: { mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1/v1" } }, +} as OcxConfig; +const context: ExportContext = { + baseUrl: "http://127.0.0.1:10100/v1", config, + models: [ + { namespaced: "mock/b", provider: "mock", id: "b" }, + { namespaced: "mock/a", provider: "mock", id: "a", contextWindow: 200000 }, + ], +}; +const roots: string[] = []; +afterEach(() => { for (const root of roots.splice(0)) removeTreeWithRetry(root); }); + +function fixture() { + const home = mkdtempSync(join(tmpdir(), "ocx-qoder-test-")); + roots.push(home); + const path = qoderConfigPath({}, home); + mkdirSync(dirname(path), { recursive: true }); + const original = { + theme: "dark", providers: { other: { type: "custom" } }, + modelConfigs: { selected: "other/model", customModels: [{ provider: "other", model: "mock/a", apiKey: "user-placeholder" }] }, + }; + const before = JSON.stringify(original, null, 4) + "\n"; + writeFileSync(path, before); + const input: IntegrationWriteInput = { + clientId: "qoder", config, port: 10100, models: context.models, + home, env: {}, store: createIntegrationStateStore(join(home, "store")), + }; + return { path, original, before, input, read: () => JSON.parse(readFileSync(path, "utf8")) }; +} + +describe("Qoder client integration", () => { + test("the export CLI writes Qoder JSON and refuses a remote placeholder without creating a file", async () => { + const f = fixture(); + const server = Bun.serve({ port: 0, fetch: () => Response.json(context.models) }); + const log = console.log; + const error = console.error; + console.log = () => {}; + console.error = () => {}; + try { + const output = join(f.input.home!, "export.json"); + const deps = { baseUrl: `http://127.0.0.1:${server.port}`, configImpl: () => config }; + expect(await handleExportCommand(["--client", "qoder", "--out", output], deps)).toBe(0); + expect(JSON.parse(readFileSync(output, "utf8")).providers.opencodex.baseUrl).toBe(`${deps.baseUrl}/v1`); + const before = readFileSync(output, "utf8"); + expect(await handleExportCommand(["--client", "qoder", "--out", output, "--force"], { + ...deps, configImpl: () => ({ ...config, hostname: "0.0.0.0" }), + })).not.toBe(0); + expect(readFileSync(output, "utf8")).toBe(before); + } finally { + console.log = log; + console.error = error; + server.stop(true); + } + }); + + test("exports both JSON catalogs with the requested field casing and normalized model order", () => { + const doc = buildQoderClientConfig(context); + expect(doc.providers.opencodex).toEqual({ + type: "openai-compatible", protocol: "openai", displayName: "opencodex", + baseUrl: context.baseUrl, apiKey: "opencodex-loopback", defaultModel: "mock/a", + models: [{ model: "mock/a" }, { model: "mock/b" }], + }); + expect(doc.modelConfigs.customModels).toEqual([ + { provider: "opencodex", apiKey: "opencodex-loopback", model: "mock/a", baseURL: context.baseUrl, displayName: "mock/a", maxInputTokens: 200000 }, + { provider: "opencodex", apiKey: "opencodex-loopback", model: "mock/b", baseURL: context.baseUrl, displayName: "mock/b", maxInputTokens: 128000 }, + ]); + const exported = buildClientConfigText("qoder", context); + expect(exported.format).toBe("json"); + expect(exported.mediaType).toContain("application/json"); + expect(JSON.parse(exported.text)).toEqual(doc); + expect(EXPORT_CLIENTS.qoder.summarize(doc)).toEqual({ modelCount: 2, modelsWithoutLimits: 0 }); + expect(INTEGRATION_CLIENTS.qoder.configPath({}, "/home/test")).toBe(join("/home/test", ".qoder", "settings.json")); + expect(buildQoderClientConfig({ ...context, models: [] }).providers.opencodex).not.toHaveProperty("defaultModel"); + }); + + test("refuses placeholders on remote or wildcard binds, including a loopback dial address", () => { + for (const hostname of ["0.0.0.0", "::", "192.0.2.1"]) { + expect(() => buildClientConfigText("qoder", { ...context, config: { ...config, hostname } })).toThrow("loopback"); + } + expect(() => buildQoderClientConfig({ ...context, baseUrl: "https://example.com/v1" })).toThrow("loopback"); + expect(() => buildQoderClientConfig({ ...context, config: undefined, baseUrl: "http://[::1]:10100/v1" })).not.toThrow(); + }); + + test("apply and refresh preserve foreign providers and same-named custom models; disable removes owned entries", () => { + const f = fixture(); + expect(applyIntegration(f.input).ok).toBe(true); + const applied = f.read(); + applied.modelConfigs.customModels.reverse(); + applied.theme = "light"; + writeFileSync(f.path, JSON.stringify(applied)); + const updated = { ...f.input, models: [context.models[0]!] }; + expect(refreshIntegration(updated).ok).toBe(true); + expect(f.read().modelConfigs.customModels).toHaveLength(2); + expect(f.read().modelConfigs.customModels.find((m: { provider: string }) => m.provider === "other")).toEqual(f.original.modelConfigs.customModels[0]); + expect(disableIntegration({ ...updated, config: { ...config, hostname: "0.0.0.0" } }).ok).toBe(true); + expect(f.read()).toEqual({ ...f.original, theme: "light" }); + }); + + test("restore recovers exact prior bytes, and non-loopback apply writes nothing", () => { + const f = fixture(); + const refused = applyIntegration({ ...f.input, config: { ...config, hostname: "0.0.0.0" } }); + expect(refused.ok).toBe(false); + if (!refused.ok) expect(refused.reason).toBe("non_loopback"); + expect(readFileSync(f.path, "utf8")).toBe(f.before); + expect(applyIntegration(f.input).ok).toBe(true); + const opId = f.input.store!.listOperations("qoder")[0]!.opId; + expect(restoreIntegration({ ...f.input, opId }).ok).toBe(true); + expect(readFileSync(f.path, "utf8")).toBe(f.before); + }); + + test("refuses ambiguous model selectors instead of owning another provider's entry", () => { + expect(() => buildClientContribution("qoder", { ...context, models: [{ namespaced: "mock/a,b", provider: "mock", id: "a,b" }] })).toThrow("selector"); + }); +}); diff --git a/tests/config/client-config-export-new-clients.test.ts b/tests/config/client-config-export-new-clients.test.ts index 726dd2a226e..1175e3db70b 100644 --- a/tests/config/client-config-export-new-clients.test.ts +++ b/tests/config/client-config-export-new-clients.test.ts @@ -64,7 +64,7 @@ describe("no secret reaches a client config", () => { // credential wiring is deliberately deferred from those initial generated // integrations -- omo reuses Pi's builder, which emits no headers at all. const loopbackOnly = EXPORT_CLIENT_IDS.filter(id => EXPORT_CLIENTS[id].loopbackOnly); - expect(loopbackOnly).toEqual(["pi", "omp", "kimi", "gajae", "dsh", "mcode", "zcode", "commandcode", "prime", "aside", "raycast", "omo", "cline", "droid"]); + expect(loopbackOnly).toEqual(["qoder", "pi", "omp", "kimi", "gajae", "dsh", "mcode", "zcode", "commandcode", "prime", "aside", "raycast", "omo", "cline", "droid"]); }); test("every client that is not loopback-only carries the header on a remote bind", () => { @@ -85,6 +85,10 @@ describe("no secret reaches a client config", () => { test("even a non-loopback bind never serializes the key", () => { for (const id of EXPORT_CLIENT_IDS) { + if (id === "qoder") { + expect(() => buildClientConfigText(id, ctx(REMOTE))).toThrow("loopback"); + continue; + } const { text } = buildClientConfigText(id, ctx(REMOTE)); expect(text).not.toContain(SECRET); } diff --git a/tests/config/client-config-export.test.ts b/tests/config/client-config-export.test.ts index 0dd01842ff5..cc76905bd8c 100644 --- a/tests/config/client-config-export.test.ts +++ b/tests/config/client-config-export.test.ts @@ -850,8 +850,8 @@ describe("hub-resolved Fast exports", () => { }); describe("EXPORT_CLIENTS registry", () => { - test("covers exactly the eighteen file-toggle clients", () => { - expect(EXPORT_CLIENT_IDS).toEqual(["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "commandcode", "prime", "aside", "raycast", "omo", "cline", "kilo", "droid"]); + test("covers exactly the nineteen file-toggle clients", () => { + expect(EXPORT_CLIENT_IDS).toEqual(["qoder", "opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "commandcode", "prime", "aside", "raycast", "omo", "cline", "kilo", "droid"]); for (const id of EXPORT_CLIENT_IDS) expect(isExportClientId(id)).toBe(true); // The exception clients keep their own surfaces and are not export clients. expect(isExportClientId("claude-desktop")).toBe(false); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 2a6a993ab92..d84e906afc8 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -132,7 +132,7 @@ "kiro-auto-selection.test.ts": "providers/kiro", "kiro-quota-metrics.test.ts": "providers/kiro", "management-provider-request-pacing.test.ts": "server", "desktop-supervised-restart.test.ts": "clients", "cli-restart-handoff.test.ts": "cli", "restart-replacement.test.ts": "server", - "deepseek-artifact-tool-schema.test.ts": "providers", "client-config-export-output-limit.test.ts": "config", + "deepseek-artifact-tool-schema.test.ts": "providers", "client-config-export-output-limit.test.ts": "config", "qoder-client.test.ts": "clients", "codex-account-clear-paused.test.ts": "codex-integration", "responses-skills-snapshot.test.ts": "responses", "openai-chat-serialized-tool-call-scaling.test.ts": "adapters/openai", "openai-chat-tool-call-id-remint.test.ts": "adapters/openai", "coding-agent-json-lines-scaling.test.ts": "providers", diff --git a/tests/gui/integrations-invariants.test.ts b/tests/gui/integrations-invariants.test.ts index 57cbedfca4d..a2680b61d6d 100644 --- a/tests/gui/integrations-invariants.test.ts +++ b/tests/gui/integrations-invariants.test.ts @@ -99,7 +99,7 @@ describe("the client registries cannot drift apart", () => { const guiRouting = await import("../../gui/src/app-routing"); const expected = [...EXPORT_CLIENT_IDS].sort(); - expect(expected).toHaveLength(18); + expect(expected).toHaveLength(19); expect([...INTEGRATION_CLIENT_IDS].sort()).toEqual(expected); expect([...gui.CLIENTS].sort()).toEqual(expected); @@ -252,6 +252,7 @@ describe("the journal is metadata, never a copy of the file", () => { describe("every client survives a full lifecycle", () => { /** A pre-existing user document in each client's own format. */ const SEED: Record = { + qoder: '{"providers":{"mine":{"api":"http://keep-me"}},"theme":"dark"}\n', cline: '{"version":1,"modes":{},"providers":{"mine":{"settings":{"provider":"mine"},"updatedAt":"2026-01-01T00:00:00.000Z","tokenSource":"manual"}}}\n', commandcode: '{\n "provider": {\n "mine": { "name": "Keep Me", "api": "openai-completions", "baseURL": "http://keep-me/v1", "apiKey": "test-key", "models": {} }\n }\n}\n', droid: '{"theme":"dark","customModels":[{"model":"local","displayName":"Local","baseUrl":"http://127.0.0.1:11434/v1","provider":"generic-chat-completion-api"}]}\n', From 2ce2c9ab4cd4525dbc16e0325c74e27b85c72d67 Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 14 Oct 2026 02:22:42 +0900 Subject: [PATCH 2/5] docs(structure): move Qoder ownership notes to clients/qoder.md clients/integrations.md was already at its 600-line budget on dev, so the Qoder section now lives in its own registered doc and is linked from the existing introduction. --- structure/INDEX.md | 5 +++-- structure/clients/integrations.md | 16 +--------------- structure/clients/qoder.md | 18 ++++++++++++++++++ structure/dashboard-and-usage.md | 2 +- structure/manifest.json | 10 ++++++++++ 5 files changed, 33 insertions(+), 18 deletions(-) create mode 100644 structure/clients/qoder.md diff --git a/structure/INDEX.md b/structure/INDEX.md index 9b70e8c20b6..917ca2e18ac 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -92,6 +92,7 @@ The dashboard, the management API, and third-party client config ownership. | [`clients/chatgpt-desktop.md`](clients/chatgpt-desktop.md) | Experimental macOS app-server stdout shim, opt-in launch, restore and failure boundaries. | | [`clients/claude-desktop.md`](clients/claude-desktop.md) | Claude Desktop profile ownership and config-library resolution. | | [`clients/claude-desktop-config-reads.md`](clients/claude-desktop-config-reads.md) | Verified regular-file reads of Claude Desktop config-library metadata, profiles and snapshots. | +| [`clients/qoder.md`](clients/qoder.md) | Qoder settings.json provider and custom-model ownership, export and loopback placeholder rules. | | [`companion.md`](companion.md) | Shared timeline filtering, usage/quotas, native and web tray title, and WidgetKit display contracts. | | [`codex-account-controls.md`](codex-account-controls.md) | Account selection order, custom usage thresholds, and stable account-card editing. | @@ -149,7 +150,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/cli.ts` | [`runtime.md`](runtime.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | | `src/cli/` | [`runtime.md`](runtime.md)
[`config.md`](config.md)
[`local-messaging.md`](local-messaging.md)
[`cli-management.md`](cli-management.md)
[`clients/integrations.md`](clients/integrations.md)
[`clients/codex-desktop.md`](clients/codex-desktop.md)
[`clients/chatgpt-desktop.md`](clients/chatgpt-desktop.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | | `src/client/` | [`runtime.md`](runtime.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md) | -| `src/clients/` | [`clients/integrations.md`](clients/integrations.md) | +| `src/clients/` | [`clients/integrations.md`](clients/integrations.md)
[`clients/qoder.md`](clients/qoder.md) | | `src/codex/` | [`runtime.md`](runtime.md)
[`config.md`](config.md)
[`codex-home.md`](codex-home.md)
[`codex-prompts.md`](codex-prompts.md)
[`catalog.md`](catalog.md)
[`subagents.md`](subagents.md)
[`transports/responses-failover.md`](transports/responses-failover.md)
[`providers/openai-tiers.md`](providers/openai-tiers.md)
[`providers/openai-accounts.md`](providers/openai-accounts.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`dashboard-and-usage.md`](dashboard-and-usage.md)
[`clients/codex-desktop.md`](clients/codex-desktop.md)
[`clients/chatgpt-desktop.md`](clients/chatgpt-desktop.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | | `src/codex/catalog/effort.ts` | [`catalog-vendor-context.md`](catalog-vendor-context.md)
[`catalog-effort-clamp.md`](catalog-effort-clamp.md) | | `src/codex/catalog/model-hints.ts` | [`catalog-vendor-context.md`](catalog-vendor-context.md) | @@ -168,7 +169,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/grok/` | [`runtime.md`](runtime.md) | | `src/images/` | [`runtime.md`](runtime.md)
[`transports/inventory.md`](transports/inventory.md) | | `src/index.ts` | [`runtime.md`](runtime.md) | -| `src/integrations/` | [`clients/integrations.md`](clients/integrations.md) | +| `src/integrations/` | [`clients/integrations.md`](clients/integrations.md)
[`clients/qoder.md`](clients/qoder.md) | | `src/lab/` | [`runtime.md`](runtime.md)
[`adapters/compatibility-lab.md`](adapters/compatibility-lab.md) | | `src/lib/` | [`overview.md`](overview.md)
[`runtime.md`](runtime.md)
[`transports/byte-accounting.md`](transports/byte-accounting.md)
[`transports/responses-wire-shapes.md`](transports/responses-wire-shapes.md)
[`transports/responses-failover.md`](transports/responses-failover.md)
[`transports/responses-spend.md`](transports/responses-spend.md)
[`transports/inventory.md`](transports/inventory.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`dashboard-and-usage.md`](dashboard-and-usage.md)
[`clients/integrations.md`](clients/integrations.md)
[`ops/service-and-sidecars.md`](ops/service-and-sidecars.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | | `src/lib/advisor-activation.ts` | [`advisor.md`](advisor.md) | diff --git a/structure/clients/integrations.md b/structure/clients/integrations.md index b512456d26c..9688b1206c4 100644 --- a/structure/clients/integrations.md +++ b/structure/clients/integrations.md @@ -5,7 +5,7 @@ Shared parsing and streaming follow the [request-copy](../transports/byte-accoun The client-integration subsystem writes one generated OpenCodex provider contribution into a third-party client's existing config without taking ownership of the rest of that file. Its core promise is reversibility: apply snapshots first, writes atomically, records exactly what it owns, -and refuses refresh, disable, or restore when the current file cannot be classified safely. +and refuses refresh, disable, or restore when the current file cannot be classified safely. Qoder specifics live in [`clients/qoder.md`](qoder.md). Managed client targets are inspected without following a final symbolic link, and their atomic replacement addresses the named directory entry rather than resolving that link again at commit. Uninstall runs the same coordinated disable path for every strict ownership record before removing @@ -93,20 +93,6 @@ The request preference is interpreted after initial Chat route selection and bef concrete dispatch under the [inbound effort contract](../data-planes/inbound-compat.md#droid-request-defaults). It never authenticates a client or changes admission policy. -## Qoder settings ownership - -Qoder IDE / qodercli uses JSON at `~/.qoder/settings.json`. The Qoder exporter in -`src/clients/config-export.ts` writes `providers.opencodex` and one -`modelConfigs.customModels` entry per model. Array ownership selects both `provider` and -`model`, preserving same-named models belonging to other providers. Apply, refresh and disable -use the existing recorded-fragment writer; restore uses its snapshots. Model IDs that cannot -be represented by the shared selector grammar refuse managed contribution construction. -The default model is the first normalized model; an empty catalog omits the default. -Input limits use authoritative context metadata or Qoder's requested 128000 fallback. -Exports reject non-loopback placeholder use; the writer's existing admission gate protects -apply while permitting cleanup after a bind change. CLI help, Dashboard export rows, integration -tabs and preview-path validation register the same Qoder client. - ## Cursor installed capability reads `src/integrations/cursor-effort-table.ts` reads the installed agent bundle through one regular-file diff --git a/structure/clients/qoder.md b/structure/clients/qoder.md new file mode 100644 index 00000000000..bc0ea69769e --- /dev/null +++ b/structure/clients/qoder.md @@ -0,0 +1,18 @@ +# Qoder Client Integration + +Qoder follows the shared [client-integration ownership contract](integrations.md); this page records only what is specific to it. + +## Qoder settings ownership + +Qoder IDE / qodercli uses JSON at `~/.qoder/settings.json`. The Qoder exporter in +`src/clients/config-export.ts` writes `providers.opencodex` and one +`modelConfigs.customModels` entry per model. Array ownership selects both `provider` and +`model`, preserving same-named models belonging to other providers. Apply, refresh and disable +use the existing recorded-fragment writer; restore uses its snapshots. Model IDs that cannot +be represented by the shared selector grammar refuse managed contribution construction. +The default model is the first normalized model; an empty catalog omits the default. +Input limits use authoritative context metadata or Qoder's requested 128000 fallback. +Exports reject non-loopback placeholder use; the writer's existing admission gate protects +apply while permitting cleanup after a bind change. CLI help, Dashboard export rows, integration +tabs and preview-path validation register the same Qoder client; the managed path templates live in +`src/integrations/mutation-plan.ts` and the builder in `src/clients/config-export/qoder.ts`. diff --git a/structure/dashboard-and-usage.md b/structure/dashboard-and-usage.md index c56a02de744..32f2ddae34a 100644 --- a/structure/dashboard-and-usage.md +++ b/structure/dashboard-and-usage.md @@ -21,7 +21,7 @@ Codex account panels expose no Spark quota toggle or setting and retain quota re Dashboard localization uses the English `gui/src/i18n/en.ts` catalog as the complete key and placeholder contract. Every registered locale, including Vietnamese and Brazilian Portuguese (`pt`, HTML `pt-BR`), supplies the same keys; locale-specific Compatibility Lab, log-guard, routing, vision, status-code, and quota-formatting -maps remain total rather than silently falling back to English. The Dashboard registers Qoder for export and managed integration under the [Qoder settings ownership contract](clients/integrations.md#qoder-settings-ownership). +maps remain total rather than silently falling back to English. The Dashboard registers Qoder for export and managed integration under the [Qoder settings ownership contract](clients/qoder.md#qoder-settings-ownership). The Models catalog names three distinct delivery states. A successful management mutation confirms only that the catalog is saved on the hub. `gui/src/api-targets.ts` carries the local machine's diff --git a/structure/manifest.json b/structure/manifest.json index e3b6b73e149..d6e11baaec1 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -578,6 +578,16 @@ "src/claude/" ] }, + { + "path": "clients/qoder.md", + "tier": 5, + "title": "Qoder Client Integration", + "scope": "Qoder settings.json provider and custom-model ownership, export and loopback placeholder rules.", + "documents": [ + "src/clients/", + "src/integrations/" + ] + }, { "path": "desktop-shell.md", "tier": 6, From e67edecdaf54c3b504e03969acd10e59ed0adb4b Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 14 Oct 2026 02:26:44 +0900 Subject: [PATCH 3/5] fix(gui): complete Qoder strings for the Portuguese locale Dev added a pt catalog after this branch was cut, so it lacked the three Qoder keys. Qoder joins the product-name allowlists for zh-TW and French alongside Kilo and Factory Droid. --- gui/src/i18n/pt.ts | 3 +++ gui/tests/fr-localization.test.ts | 3 +++ gui/tests/locale-parity.test.ts | 3 +++ 3 files changed, 9 insertions(+) diff --git a/gui/src/i18n/pt.ts b/gui/src/i18n/pt.ts index 0aa5fe5463f..89ef6471c86 100644 --- a/gui/src/i18n/pt.ts +++ b/gui/src/i18n/pt.ts @@ -2121,6 +2121,7 @@ export const pt: Record = { "integrations.tab.cline": "Cline CLI", "integrations.tab.commandcode": "Command Code", "integrations.tab.kilo": "Kilo", + "integrations.tab.qoder": "Qoder", "integrations.tab.droid": "Factory Droid", "integrations.aside.profilesTitle": "Perfis do Aside", "integrations.aside.profilesHint": "Escolha quais perfis recebem os modelos selecionados. O perfil ativo do Aside permanece inalterado.", @@ -2425,6 +2426,7 @@ export const pt: Record = { "integrations.lazycodexRoles.auto.moveUp": "Suba de nível se: {text}", "integrations.lazycodexRoles.auto.moveDown": "Desça de nível se: {text}", "integrations.semantics.cline": "Gerencia o OpenCodex no providers.json e no models.json do Cline CLI. Pare o Cline antes de alterar ou sincronizar esses arquivos e reinicie depois. Desfazer restaura os dois originais. Seu provedor padrão permanece inalterado; selecione o OpenCodex no Cline.", + "integrations.semantics.qoder": "Gerencia os provedores e modelos personalizados do OpenCodex em ~/.qoder/settings.json. Requer acesso por loopback. As demais configurações são preservadas; Desfazer restaura o snapshot.", "integrations.semantics.commandcode": "Gerencia apenas a entrada do provedor OpenCodex em ~/.commandcode/providers.json. Seus outros provedores e configurações permanecem inalterados.", "integrations.semantics.kilo": "Gerencia apenas provider.opencodex na configuração global do Kilo — o primeiro arquivo existente entre kilo.jsonc, kilo.json, opencode.jsonc, opencode.json ou config.json em ~/.config/kilo (XDG_CONFIG_HOME realoca esse diretório; kilo.jsonc é criado quando nenhum existe). As demais chaves permanecem inalteradas. Aplicar reescreve o arquivo inteiro, então comentários e vírgulas finais não são preservados. Selecione opencodex/ no Kilo.", "integrations.semantics.droid": "Adiciona modelos personalizados do OpenCodex ao settings.json do Factory Droid. Desativar remove apenas as linhas gerenciadas; desfazer restaura o arquivo salvo.", @@ -2975,6 +2977,7 @@ export const pt: Record = { "api.clientConfig.clientCline": "Cline CLI", "api.clientConfig.clientCommandCode": "Command Code", "api.clientConfig.clientKilo": "Kilo", + "api.clientConfig.clientQoder": "Qoder", "api.clientConfig.clientDroid": "Factory Droid", "api.clientConfig.clineBundle": "Este é um pacote de dois arquivos, não um arquivo de configurações do Cline. Mescle settings em providers.json e catalog no models.json vizinho. Use o interruptor de integração para uma gravação com backup.", "api.clientConfig.clineDownloaded": "{filename} baixado. Nada foi alterado ainda; settings e catalog pertencem a dois arquivos separados do Cline.", diff --git a/gui/tests/fr-localization.test.ts b/gui/tests/fr-localization.test.ts index 0d39f14bbd9..4bd2bf75e22 100644 --- a/gui/tests/fr-localization.test.ts +++ b/gui/tests/fr-localization.test.ts @@ -164,6 +164,9 @@ const INTENTIONAL_ENGLISH = new Set([ // Factory Droid is a product name, identical in every locale. "integrations.tab.droid", "api.clientConfig.clientDroid", + // Qoder is a product name, identical in every locale. + "integrations.tab.qoder", + "api.clientConfig.clientQoder", "models.reasoningEffort.minimal", "models.reasoningEffort.max", "models.reasoningEffort.ultra", diff --git a/gui/tests/locale-parity.test.ts b/gui/tests/locale-parity.test.ts index 5dd2690cbe2..ee77b959b1d 100644 --- a/gui/tests/locale-parity.test.ts +++ b/gui/tests/locale-parity.test.ts @@ -171,6 +171,9 @@ const ZH_TW_KEEP_ENGLISH: ReadonlySet = new Set([ "integrations.tab.kilo", "api.clientConfig.clientKilo", "api.clientConfig.clientDroid", + // Qoder is a product name, identical in every locale. + "integrations.tab.qoder", + "api.clientConfig.clientQoder", // Command Code is a product name, identical in every locale. "integrations.tab.commandcode", "api.clientConfig.clientCommandCode", From a452b812b6a286ef57002517b963add31926c630 Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 14 Oct 2026 02:45:18 +0900 Subject: [PATCH 4/5] fix(clients): honor QODER_CONFIG_DIR and keep Qoder in standard sync paths Addresses the remaining review findings on #5950: - qoderHomeDir resolves QODER_CONFIG_DIR (absolute or ~-relative; relative values are refused) and both the config path and integration detection use it. - Owned-catalog refresh in ocx sync, /api/sync and catalog refresh now includes qoder. - ocx export --client qoder asks the live /api/client-config route, like Raycast and Factory Droid, so the serving process's bind and unauthenticated loopback listener decide the exported URL. The split-port case is covered by the shared listener-policy test. --- .../src/content/docs/guides/integrations.md | 9 ++-- src/cli/dispatch.ts | 2 +- src/cli/export-command.ts | 2 +- src/clients/config-export.ts | 2 +- src/clients/config-export/qoder.ts | 26 ++++++++-- src/integrations/catalog-refresh.ts | 2 +- src/integrations/registry.ts | 3 +- src/server/management/config-routes.ts | 4 +- src/server/management/model-routes.ts | 4 +- structure/clients/qoder.md | 9 +++- tests/cli/cli-dispatch.test.ts | 2 +- tests/cli/cli-export-command.test.ts | 7 +-- tests/clients/qoder-client.test.ts | 52 ++++++++++++++++--- ...ync-client-integrations-unattended.test.ts | 2 +- .../clients/sync-client-integrations.test.ts | 4 +- 15 files changed, 100 insertions(+), 30 deletions(-) diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index 96bc6de9841..5bd17d8072e 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -22,7 +22,7 @@ file, and removes it again. Nineteen clients work this way, each with a switch: | Aside | `~/.aside/u//models.json` | JSON | after fully quitting and reopening Aside | loopback placeholder | | Raycast | `~/.config/raycast/ai/providers.yaml` | YAML | immediately on save — Raycast watches the file | none — loopback only | | omo (Pi / senpi) | `~/.omo/agent/models.json` | JSON | new sessions | loopback placeholder | -| Qoder | `~/.qoder/settings.json` | JSON | new sessions | loopback placeholder | +| Qoder | `settings.json` in `$QODER_CONFIG_DIR`, default `~/.qoder` | JSON | new sessions | loopback placeholder | | Cline CLI | `~/.cline/data/settings/providers.json` and sibling `models.json` | JSON pair | after stopping and restarting Cline | loopback placeholder | | Kilo | first existing `kilo.jsonc`, `kilo.json`, `opencode.jsonc`, `opencode.json`, or `config.json` under `~/.config/kilo` | JSONC | new sessions | `OPENCODEX_KILO_API_KEY` | | Command Code | `~/.commandcode/providers.json` (on Windows with the default home: use `HOME ?? USERPROFILE` only if nonblank and Windows-absolute, otherwise `homedir()`; a separately supplied home is preserved; append `.commandcode\providers.json`) | JSON | next launch of Command Code | none — keyless loopback (`apiKey: false`) | @@ -42,13 +42,16 @@ ocx integration client enable --client qoder ocx integration client disable --client qoder ``` -Qoder uses `~/.qoder/settings.json`. Enable merges `providers.opencodex` and +Qoder uses `settings.json` in `QODER_CONFIG_DIR` when that variable is set, and +`~/.qoder/settings.json` otherwise; a relative `QODER_CONFIG_DIR` is refused. Enable merges `providers.opencodex` and OpenCodex entries in `modelConfigs.customModels`, preserving other providers, custom models, and settings. Disable removes the recorded OpenCodex entries; restore uses the existing snapshot workflow. The provider's default model is the first model in the sorted export. Custom models use their catalog context window, falling back to 128000 input tokens. The generated API key is a non-secret placeholder: Qoder integration requires loopback -access and refuses an authenticated non-loopback bind. +access and refuses an authenticated non-loopback bind. When the dedicated unauthenticated +loopback listener is enabled, exports point Qoder at that listener. `ocx sync` and catalog +changes refresh an already enabled Qoder integration like the other managed clients. Generated catalogs include only enabled models from each provider selection. This applies to both downloads and managed integrations, including Pi and Aside. The management model list still shows diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 4bdcffcf2d7..59d9350e4be 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -484,7 +484,7 @@ const commandRunners: Record = { }, config, port: live.port, - }, ["mcode", "pi", "raycast", "omo", "cline", "commandcode", "droid", "opencode", "kilo"])); + }, ["mcode", "pi", "raycast", "omo", "cline", "commandcode", "droid", "opencode", "kilo", "qoder"])); } catch (error) { console.warn(`Client integrations were not refreshed: ${error instanceof Error ? error.message : String(error)}`); } diff --git a/src/cli/export-command.ts b/src/cli/export-command.ts index ca458280415..91f0f3e4930 100644 --- a/src/cli/export-command.ts +++ b/src/cli/export-command.ts @@ -169,7 +169,7 @@ export async function handleExportCommand(argv: string[], deps: ExportCommandDep const spec = EXPORT_CLIENTS[client]; const root = await runtimeBaseUrl(deps); let built: { document: unknown; text: string }; - if (client === "raycast" || client === "droid") { + if (client === "raycast" || client === "droid" || client === "qoder") { // The dial address alone cannot distinguish a wildcard authenticated bind // from loopback. Let the live server resolve its admission/listener policy; // saved config can differ from the process serving this request. diff --git a/src/clients/config-export.ts b/src/clients/config-export.ts index 3df950a3d82..701257e9f18 100644 --- a/src/clients/config-export.ts +++ b/src/clients/config-export.ts @@ -83,7 +83,7 @@ import { export type { CommandCodeGeneratedConfig, CommandCodeModelEntry, CommandCodeProviderBlock }; export { buildCommandCodeClientConfig, summarizeCommandCode, buildCommandCodeContribution, commandCodeProviderRoot }; import { buildQoderClientConfig, summarizeQoder, buildQoderContribution, qoderConfigPath } from "./config-export/qoder"; -export { buildQoderClientConfig, qoderConfigPath, type QoderGeneratedConfig } from "./config-export/qoder"; +export { buildQoderClientConfig, qoderConfigPath, qoderHomeDir, type QoderGeneratedConfig } from "./config-export/qoder"; diff --git a/src/clients/config-export/qoder.ts b/src/clients/config-export/qoder.ts index 867c791e7e1..694c264a18d 100644 --- a/src/clients/config-export/qoder.ts +++ b/src/clients/config-export/qoder.ts @@ -1,13 +1,33 @@ import { homedir } from "node:os"; -import { join } from "node:path"; +import { isAbsolute, join } from "node:path"; import { isLoopbackHostname, shouldInjectApiAuthHeader } from "../../codex/loopback-target"; import { formatSelectorConjunction } from "../../integrations/merge"; +import { ClientPathError } from "../config-export"; import type { ExportContext, ManagedContribution, OpencodeLaunchEnv } from "./contracts"; import { LOOPBACK_API_KEY_PLACEHOLDER, OPENCODE_PROVIDER_ID } from "./constants"; import { authoritativeContextWindow, normalizeExportModels } from "./model-metadata"; -export function qoderConfigPath(_env: OpencodeLaunchEnv = process.env, home: string = homedir()): string { - return join(home, ".qoder", "settings.json"); +/** + * Qoder's configuration directory: `QODER_CONFIG_DIR` when set, otherwise `~/.qoder` + * (https://docs.qoder.com/cli/settings-reference). Export and detection both resolve it here. + * A relative override is refused because opencodex and Qoder would anchor it to different cwds. + */ +export function qoderHomeDir(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string { + const override = env.QODER_CONFIG_DIR?.trim(); + if (!override) return join(home, ".qoder"); + if (override === "~") return home; + if (override.startsWith("~/") || override.startsWith("~\\")) return join(home, override.slice(2)); + if (!isAbsolute(override)) { + throw new ClientPathError( + `QODER_CONFIG_DIR must be an absolute path or start with ~; "${override}" depends on the working directory, ` + + "so opencodex and Qoder would disagree about which file it names.", + ); + } + return override; +} + +export function qoderConfigPath(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string { + return join(qoderHomeDir(env, home), "settings.json"); } /** Qoder IDE and qodercli share this provider and custom-model catalog. */ diff --git a/src/integrations/catalog-refresh.ts b/src/integrations/catalog-refresh.ts index 49caa9a7de6..35347c636c1 100644 --- a/src/integrations/catalog-refresh.ts +++ b/src/integrations/catalog-refresh.ts @@ -12,7 +12,7 @@ import { /** Refresh only previously connected clients; a refused file never blocks its peers. */ export async function refreshOwnedCatalogIntegrations( input: Omit, - clientIds: readonly IntegrationClientId[] = ["pi", "aside", "raycast", "omo", "commandcode", "droid", "opencode", "kilo"], + clientIds: readonly IntegrationClientId[] = ["pi", "aside", "raycast", "omo", "commandcode", "droid", "opencode", "kilo", "qoder"], options: { refreshOnly?: boolean; admit?: () => boolean } = {}, ): Promise { // Client files are shared with the live proxy a sibling instance runs beside; their entries diff --git a/src/integrations/registry.ts b/src/integrations/registry.ts index 1d24ea005d7..b988e8c7977 100644 --- a/src/integrations/registry.ts +++ b/src/integrations/registry.ts @@ -18,6 +18,7 @@ import { ClientPathError, buildDroidContribution, qoderConfigPath, + qoderHomeDir, clineConfigPath, clineSettingsDir, droidConfigPath, @@ -284,7 +285,7 @@ export const INTEGRATION_CLIENTS: Record join(home, ".qoder"), + detectDir: (env = process.env, home = homedir()) => qoderHomeDir(env, home), }, opencode: { id: "opencode", diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index 1ea79b09d4a..ce6de3b2d52 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -329,8 +329,8 @@ export async function syncEnabledClientIntegrations( config, port, }, unattended - ? ["mcode", "pi", "aside", "raycast", "omo", "commandcode", "droid", "opencode", "kilo"] - : ["mcode", "pi", "aside", "raycast", "omo", "cline", "commandcode", "droid", "opencode", "kilo"], + ? ["mcode", "pi", "aside", "raycast", "omo", "commandcode", "droid", "opencode", "kilo", "qoder"] + : ["mcode", "pi", "aside", "raycast", "omo", "cline", "commandcode", "droid", "opencode", "kilo", "qoder"], ...unattended ? [{ refreshOnly: true, admit }] as const : [])); return out; diff --git a/src/server/management/model-routes.ts b/src/server/management/model-routes.ts index 858c2806108..b8928c39bd7 100644 --- a/src/server/management/model-routes.ts +++ b/src/server/management/model-routes.ts @@ -556,9 +556,9 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise { expect(code).toBe(0); expect(order).toEqual(["catalog", "refresh"]); expect(refresh).toHaveBeenCalledTimes(1); - expect(refresh.mock.calls[0]![1]).toEqual(["mcode", "pi", "raycast", "omo", "cline", "commandcode", "droid", "opencode", "kilo"]); + expect(refresh.mock.calls[0]![1]).toEqual(["mcode", "pi", "raycast", "omo", "cline", "commandcode", "droid", "opencode", "kilo", "qoder"]); } finally { sync.mockRestore(); refresh.mockRestore(); aside.mockRestore(); log.mockRestore(); } diff --git a/tests/cli/cli-export-command.test.ts b/tests/cli/cli-export-command.test.ts index 90b7f4c5bad..1a7d4da8bfe 100644 --- a/tests/cli/cli-export-command.test.ts +++ b/tests/cli/cli-export-command.test.ts @@ -577,7 +577,7 @@ describe("export allowlist parity", () => { }); describe("keyless exports use the live management admission policy", () => { - for (const client of ["raycast", "droid"] as const) for (const secondary of [false, true]) { + for (const client of ["raycast", "droid", "qoder"] as const) for (const secondary of [false, true]) { test(`${client} live wildcard bind with secondary=${secondary} wins over saved loopback config`, async () => { const oldHome = process.env.OPENCODEX_HOME; const oldCodexHome = process.env.CODEX_HOME; @@ -604,8 +604,9 @@ describe("keyless exports use the live management admission policy", () => { }); if (secondary) { expect(result.code).toBe(0); - const document = JSON.parse(result.stdout) as { providers?: Array<{ base_url: string }>; customModels?: Array<{ baseUrl: string }> }; - expect(client === "raycast" ? document.providers?.[0]?.base_url : document.customModels?.[0]?.baseUrl) + const document = JSON.parse(result.stdout) as { providers?: Array<{ base_url: string }> & { opencodex?: { baseUrl: string } }; customModels?: Array<{ baseUrl: string }> }; + expect(client === "raycast" ? document.providers?.[0]?.base_url + : client === "qoder" ? document.providers?.opencodex?.baseUrl : document.customModels?.[0]?.baseUrl) .toBe("http://127.0.0.1:10237/v1"); expect(readFileSync(out, "utf8")).toContain("10237/v1"); expect(readFileSync(out, "utf8")).not.toContain("10999"); diff --git a/tests/clients/qoder-client.test.ts b/tests/clients/qoder-client.test.ts index 5ee8e6d213a..56605304248 100644 --- a/tests/clients/qoder-client.test.ts +++ b/tests/clients/qoder-client.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { buildClientConfigText, buildClientContribution, buildQoderClientConfig, EXPORT_CLIENTS, qoderConfigPath, type ExportContext } from "../../src/clients/config-export"; @@ -9,6 +9,7 @@ import { applyIntegration, disableIntegration, refreshIntegration, restoreIntegr import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { handleExportCommand } from "../../src/cli/export-command"; +import { handleManagementAPI } from "../../src/server/management-api"; const config = { port: 10100, hostname: "127.0.0.1", defaultProvider: "mock", @@ -45,7 +46,21 @@ function fixture() { describe("Qoder client integration", () => { test("the export CLI writes Qoder JSON and refuses a remote placeholder without creating a file", async () => { const f = fixture(); - const server = Bun.serve({ port: 0, fetch: () => Response.json(context.models) }); + // The CLI asks the live management route, so the serving process's bind decides admission. + const oldHome = process.env.OPENCODEX_HOME; + const oldCodexHome = process.env.CODEX_HOME; + process.env.OPENCODEX_HOME = join(f.input.home!, "ocx"); + process.env.CODEX_HOME = join(f.input.home!, "codex"); + mkdirSync(process.env.CODEX_HOME, { recursive: true }); + const live = (hostname: string) => ({ + ...config, hostname, + providers: { mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1/v1", liveModels: false, models: ["a", "b"] } }, + }) as OcxConfig; + let liveConfig = live("127.0.0.1"); + const server = Bun.serve({ + port: 0, + fetch: async req => await handleManagementAPI(req, new URL(req.url), liveConfig) ?? new Response("not found", { status: 404 }), + }); const log = console.log; const error = console.error; console.log = () => {}; @@ -54,19 +69,44 @@ describe("Qoder client integration", () => { const output = join(f.input.home!, "export.json"); const deps = { baseUrl: `http://127.0.0.1:${server.port}`, configImpl: () => config }; expect(await handleExportCommand(["--client", "qoder", "--out", output], deps)).toBe(0); - expect(JSON.parse(readFileSync(output, "utf8")).providers.opencodex.baseUrl).toBe(`${deps.baseUrl}/v1`); + expect(JSON.parse(readFileSync(output, "utf8")).providers.opencodex.baseUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/v1$/); const before = readFileSync(output, "utf8"); - expect(await handleExportCommand(["--client", "qoder", "--out", output, "--force"], { - ...deps, configImpl: () => ({ ...config, hostname: "0.0.0.0" }), - })).not.toBe(0); + liveConfig = live("0.0.0.0"); + expect(await handleExportCommand(["--client", "qoder", "--out", output, "--force"], deps)).not.toBe(0); expect(readFileSync(output, "utf8")).toBe(before); } finally { console.log = log; console.error = error; server.stop(true); + if (oldHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = oldHome; + if (oldCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = oldCodexHome; } }); + test("QODER_CONFIG_DIR relocates both the written settings file and detection", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-qoder-dir-")); + roots.push(root); + const relocated = join(root, "relocated-qoder"); + const env = { QODER_CONFIG_DIR: relocated }; + expect(qoderConfigPath(env, "/home/test")).toBe(join(relocated, "settings.json")); + expect(INTEGRATION_CLIENTS.qoder.detectDir(env, "/home/test")).toBe(relocated); + expect(qoderConfigPath({ QODER_CONFIG_DIR: "~/q" }, "/home/test")).toBe(join("/home/test", "q", "settings.json")); + expect(() => qoderConfigPath({ QODER_CONFIG_DIR: "relative/qoder" }, "/home/test")).toThrow("QODER_CONFIG_DIR"); + mkdirSync(relocated, { recursive: true }); + writeFileSync(join(relocated, "settings.json"), JSON.stringify({ theme: "dark" })); + const input: IntegrationWriteInput = { + clientId: "qoder", config, port: 10100, models: context.models, + home: root, env, store: createIntegrationStateStore(join(root, "store")), + }; + expect(applyIntegration(input).ok).toBe(true); + expect(JSON.parse(readFileSync(join(relocated, "settings.json"), "utf8")).providers.opencodex).toBeDefined(); + expect(existsSync(join(root, ".qoder"))).toBe(false); + expect(disableIntegration(input).ok).toBe(true); + expect(JSON.parse(readFileSync(join(relocated, "settings.json"), "utf8"))).toEqual({ theme: "dark" }); + }); + test("exports both JSON catalogs with the requested field casing and normalized model order", () => { const doc = buildQoderClientConfig(context); expect(doc.providers.opencodex).toEqual({ diff --git a/tests/clients/sync-client-integrations-unattended.test.ts b/tests/clients/sync-client-integrations-unattended.test.ts index b76d9776b0e..e1403fb92c0 100644 --- a/tests/clients/sync-client-integrations-unattended.test.ts +++ b/tests/clients/sync-client-integrations-unattended.test.ts @@ -79,7 +79,7 @@ describe("unattended native and file client admission", () => { expect(await syncEnabledClientIntegrations(12345, config, deps(fetch), unattended)).toEqual([]); expect(fetch).not.toHaveBeenCalled(); expect(writer).not.toHaveBeenCalled(); const args = refresh.mock.calls[0]!; - expect(args[1]).toEqual(["mcode", "pi", "aside", "raycast", "omo", "commandcode", "droid", "opencode", "kilo"]); + expect(args[1]).toEqual(["mcode", "pi", "aside", "raycast", "omo", "commandcode", "droid", "opencode", "kilo", "qoder"]); expect(args[2]?.refreshOnly).toBe(true); expect(args[2]?.admit?.()).toBe(true); config.runtimeRole = "hub"; persist(); expect(args[2]?.admit?.()).toBe(false); config.unauthenticatedLoopbackListener = { enabled: true, port: 12346 }; persist(); diff --git a/tests/clients/sync-client-integrations.test.ts b/tests/clients/sync-client-integrations.test.ts index 7d7caf43d2f..f280cd2c30a 100644 --- a/tests/clients/sync-client-integrations.test.ts +++ b/tests/clients/sync-client-integrations.test.ts @@ -65,7 +65,7 @@ describe("ocx sync fans out to enabled native clients and owned file integration expect(fn).toContain("grokIntegrationEnabled(config)"); expect(fn).toContain("claudeDesktopIntegrationEnabled(config)"); - expect(fn).toContain('["mcode", "pi", "aside", "raycast", "omo", "cline", "commandcode", "droid", "opencode", "kilo"]'); + expect(fn).toContain('["mcode", "pi", "aside", "raycast", "omo", "cline", "commandcode", "droid", "opencode", "kilo", "qoder"]'); expect(fn).toContain("refreshOwnedCatalogIntegrations"); // Native clients keep their catches; the owned catalog helper isolates file clients. expect(fn.match(/catch \(error\)/g)?.length).toBe(2); @@ -910,7 +910,7 @@ test("the direct ocx sync command refreshes owned clients including OpenCode and expect(command).toContain("refreshOwnedCatalogIntegrations"); // Command Code joined this fan-out with its integration: a sync must refresh its // provider block too, or the client keeps a stale catalog until the next apply. - expect(command).toContain('["mcode", "pi", "raycast", "omo", "cline", "commandcode", "droid", "opencode", "kilo"]'); + expect(command).toContain('["mcode", "pi", "raycast", "omo", "cline", "commandcode", "droid", "opencode", "kilo", "qoder"]'); expect(command).toContain("refreshAsideProfilesThroughServer"); expect(command.indexOf("syncModelsToCodex")).toBeLessThan(command.indexOf("refreshOwnedCatalogIntegrations")); expect(command).toContain('synced.status !== "refused"'); From 62745db4428497b0237a12d8ddc39c9294769d44 Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 14 Oct 2026 02:55:58 +0900 Subject: [PATCH 5/5] fix(gui): clarify the Qoder loopback requirement in French --- gui/src/i18n/fr.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 2da87f9dc43..9fe9862b17e 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2109,7 +2109,7 @@ export const fr: Record = { "integrations.tab.omo": "omo", "integrations.tab.qoder": "Qoder", "api.clientConfig.clientQoder": "Qoder", - "integrations.semantics.qoder": "Gère les fournisseurs OpenCodex et les modèles personnalisés dans ~/.qoder/settings.json. Nécessite un accès local en boucle. Les autres paramètres sont conservés ; Annuler restaure la sauvegarde.", + "integrations.semantics.qoder": "Gère les fournisseurs OpenCodex et les modèles personnalisés dans ~/.qoder/settings.json. Nécessite qu’opencodex soit joignable sur une adresse de bouclage. Les autres paramètres sont conservés ; Annuler restaure la sauvegarde.", "integrations.tab.cline": "Cline CLI", "integrations.tab.kilo": "Kilo", "integrations.tab.droid": "Factory Droid",