diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index 0badb584218..5bd17d8072e 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 | `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`) | @@ -33,6 +34,25 @@ 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 `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. 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 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..9fe9862b17e 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 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", 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/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/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/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/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/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", 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/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/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..701257e9f18 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, qoderHomeDir, 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..694c264a18d --- /dev/null +++ b/src/clients/config-export/qoder.ts @@ -0,0 +1,93 @@ +import { homedir } from "node:os"; +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"; + +/** + * 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. */ +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/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/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..b988e8c7977 100644 --- a/src/integrations/registry.ts +++ b/src/integrations/registry.ts @@ -17,6 +17,8 @@ import { dirname, join } from "node:path"; import { ClientPathError, buildDroidContribution, + qoderConfigPath, + qoderHomeDir, clineConfigPath, clineSettingsDir, droidConfigPath, @@ -280,6 +282,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()) => qoderHomeDir(env, home), + }, opencode: { id: "opencode", // These take `home` explicitly. The export registry's `destination` reads 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[`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 f39642644d6..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 diff --git a/structure/clients/qoder.md b/structure/clients/qoder.md new file mode 100644 index 00000000000..7ae4ef16732 --- /dev/null +++ b/structure/clients/qoder.md @@ -0,0 +1,23 @@ +# 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 `settings.json` under `QODER_CONFIG_DIR`, default `~/.qoder`; +`qoderHomeDir` resolves that directory once for export, writing and detection, and refuses a +relative override. 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. `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. Owned-catalog refresh in `ocx sync`, +`/api/sync` and catalog refresh includes Qoder. 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 93aed0a3c40..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. +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, diff --git a/tests/cli/cli-dispatch.test.ts b/tests/cli/cli-dispatch.test.ts index 6d766cb27c9..246238a8cd1 100644 --- a/tests/cli/cli-dispatch.test.ts +++ b/tests/cli/cli-dispatch.test.ts @@ -182,7 +182,7 @@ describe("dispatchCommand exit codes", () => { 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/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..56605304248 --- /dev/null +++ b/tests/clients/qoder-client.test.ts @@ -0,0 +1,168 @@ +import { afterEach, describe, expect, test } from "bun:test"; +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"; +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"; +import { handleManagementAPI } from "../../src/server/management-api"; + +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(); + // 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 = () => {}; + 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).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/v1$/); + const before = readFileSync(output, "utf8"); + 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({ + 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/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"'); 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',