diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index 4cb6ead058c..e62c1ba6dce 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -550,6 +550,37 @@ ocx integration client enable --client kilo ocx export --client kilo --out ./kilo.jsonc ``` +## Command Code + +Command Code keeps its custom providers in `~/.commandcode/providers.json`. This +integration writes `provider.opencodex` into that file and is loopback-only. + +**Which root it writes.** Command Code resolves its provider map as +`document.provider ?? document.providers`, so the singular root wins whenever it +exists. The integration therefore reads the target file first and writes into the +root that file already uses: a document that already carries a `providers` map keeps +it, and your other providers stay readable. A fresh file gets the singular `provider` +root. Both roots are declared as managed paths, so Disable removes the block from +whichever one it was written into. + +**The credential form.** The provider is written with `apiKey: false`, the documented +form for an endpoint that needs no key. A raw string is refused by Command Code, and +no service-token file is read, referenced, or exported by this integration. + +**Home resolution.** There is no `COMMANDCODE_HOME` override here. The published +client resolves `HOME ?? USERPROFILE` and then appends `/.commandcode/providers.json`; +it does not consult that variable. An override this integration honoured but the +client ignored would make Apply report success at a path Command Code never opens, so +the path is always `~/.commandcode/providers.json`. + +Paste the key yourself if you later move the provider off loopback: Command Code +stores keys in `~/.commandcode/auth.json` (via `/connect`), not in `providers.json`. + +```bash +ocx integration client enable --client commandcode +ocx export --client commandcode --out ./providers.json +``` + ## Factory Droid Run Droid once to create `~/.factory`, then explicitly enable this integration with diff --git a/gui/src/app-routing.ts b/gui/src/app-routing.ts index ee5be633727..e7a0c4a53b0 100644 --- a/gui/src/app-routing.ts +++ b/gui/src/app-routing.ts @@ -104,6 +104,7 @@ export const INTEGRATION_TAB_HASHES = [ "integrations/dsh", "integrations/mcode", "integrations/zcode", + "integrations/commandcode", "integrations/prime", "integrations/aside", "integrations/raycast", diff --git a/gui/src/components/apikeys-workspace/client-config-clients.ts b/gui/src/components/apikeys-workspace/client-config-clients.ts index e4ac4a62e1a..8689f3b0a46 100644 --- a/gui/src/components/apikeys-workspace/client-config-clients.ts +++ b/gui/src/components/apikeys-workspace/client-config-clients.ts @@ -8,7 +8,7 @@ * 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", "prime", "aside", "raycast", "omo", "cline", "kilo", "droid"] as const; +export const CLIENTS = ["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 = { @@ -22,6 +22,7 @@ export const CLIENT_LABEL_KEYS = { dsh: "api.clientConfig.clientDsh", mcode: "api.clientConfig.clientMcode", zcode: "api.clientConfig.clientZcode", + commandcode: "api.clientConfig.clientCommandCode", prime: "api.clientConfig.clientPrime", aside: "api.clientConfig.clientAside", raycast: "api.clientConfig.clientRaycast", @@ -81,6 +82,7 @@ export const CLIENT_MARKS: Partial> = { // so it would paint the plate and throw the face away — see the README. omo: "/provider-icons/omo.svg", cline: "/provider-icons/cline-color.svg", + commandcode: "/provider-icons/commandcode-color.svg", kilo: "/provider-icons/kilo.svg", droid: "/provider-icons/factory-droid.svg", }; diff --git a/gui/src/components/integration-marks.ts b/gui/src/components/integration-marks.ts index 669cc2ba114..a7db3023ba9 100644 --- a/gui/src/components/integration-marks.ts +++ b/gui/src/components/integration-marks.ts @@ -60,6 +60,7 @@ export const INTEGRATION_MARKS: Record = { raycast: CLIENT_MARKS.raycast ?? null, omo: CLIENT_MARKS.omo ?? null, cline: CLIENT_MARKS.cline ?? null, + commandcode: CLIENT_MARKS.commandcode ?? null, kilo: CLIENT_MARKS.kilo ?? null, droid: CLIENT_MARKS.droid ?? null, }; diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 6ddb8ba1d59..f2cbf4908ed 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1445,6 +1445,7 @@ export const de: Record = { "integrations.tab.dsh": "DSH", "integrations.tab.mcode": "MiniMax Code", "integrations.tab.zcode": "ZCode", + "integrations.tab.commandcode": "Command Code", "integrations.tab.prime": "Prime Agent", "integrations.tab.aside": "Aside", "integrations.tab.raycast": "Raycast", @@ -1656,6 +1657,7 @@ export const de: Record = { "integrations.semantics.dsh": "OpenCodex verwaltet nur llm-pi-ai.providers.opencodex in $DSH_HOME/settings.yaml. DSH lädt diesen Anbieter im laufenden Betrieb neu; Ihr Standardmodell und deepseek-official bleiben unverändert. Derzeit nur über Loopback; es werden keine echten Zugangsdaten geschrieben.", "integrations.semantics.mcode": "Verwaltet nur custom_provider.opencodex. Standardmodell und MiniMax-Anmeldung bleiben unverändert.", "integrations.semantics.zcode": "Verwaltet nur provider.opencodex in ~/.zcode/v2/config.json. Z.ai-Anmeldung und andere Provider bleiben unverändert. ZCode nach Änderungen neu starten.", + "integrations.semantics.commandcode": "Verwaltet nur provider.opencodex in ~/.commandcode/providers.json. Ihre anderen Provider und Einstellungen bleiben unverändert.", "integrations.semantics.prime": "Verwaltet nur providers.opencodex in der models.json von Prime Agent — ~/.prime/agent, sofern PRIME_AGENT_CODING_AGENT_DIR sie nicht umleitet. Andere Provider und Modell-Overrides bleiben unverändert. Gilt für neue Sitzungen.", "integrations.semantics.aside": "Verwaltet nur providers.opencodex in der ~/.aside/u//models.json dieses Profils. Andere Provider bleiben unverändert. Beende Aside nach dem Anwenden vollständig und öffne es erneut.", "integrations.semantics.raycast": "Fügt einen OpenCodex-Provider-Eintrag in die providers.yaml von Raycast ein, damit jedes geroutete Modell in der Modellauswahl von Raycast AI erscheint. Raycast Pro erforderlich.", @@ -2152,6 +2154,7 @@ export const de: Record = { "api.clientConfig.clientDsh": "DeepSeek Harness (DSH)", "api.clientConfig.clientMcode": "MiniMax Code", "api.clientConfig.clientZcode": "ZCode", + "api.clientConfig.clientCommandCode": "Command Code", "api.clientConfig.clientPrime": "Prime Agent", "api.clientConfig.clientAside": "Aside", "api.clientConfig.clientRaycast": "Raycast", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index cd0bf7c4953..34967a3a6ab 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2003,6 +2003,7 @@ export const en = { "integrations.tab.dsh": "DSH", "integrations.tab.mcode": "MiniMax Code", "integrations.tab.zcode": "ZCode", + "integrations.tab.commandcode": "Command Code", "integrations.tab.prime": "Prime Agent", "integrations.tab.aside": "Aside", "integrations.tab.raycast": "Raycast", @@ -2259,6 +2260,7 @@ export const en = { "integrations.semantics.dsh": "OpenCodex manages only llm-pi-ai.providers.opencodex in $DSH_HOME/settings.yaml. DSH hot reloads this provider; your default model and deepseek-official stay unchanged. Currently loopback-only; no real credential is written.", "integrations.semantics.mcode": "Manages only custom_provider.opencodex. Your default model and MiniMax login stay unchanged.", "integrations.semantics.zcode": "Manages only provider.opencodex in ~/.zcode/v2/config.json. Your Z.ai login and other providers stay unchanged. Restart ZCode after changes.", + "integrations.semantics.commandcode": "Manages only provider.opencodex in ~/.commandcode/providers.json. Your other providers and settings stay unchanged.", "integrations.semantics.prime": "Manages only providers.opencodex in Prime Agent's models.json — ~/.prime/agent unless PRIME_AGENT_CODING_AGENT_DIR redirects it. Your other providers and model overrides stay unchanged. Applies to new sessions.", "integrations.semantics.aside": "Manages only providers.opencodex in this profile’s ~/.aside/u//models.json. Your other providers stay unchanged. Fully quit and reopen Aside after applying.", "integrations.semantics.raycast": "Adds an OpenCodex provider entry to Raycast's providers.yaml so every routed model appears in the Raycast AI model picker. Raycast Pro required.", @@ -2766,6 +2768,7 @@ export const en = { "api.clientConfig.clientDsh": "DeepSeek Harness (DSH)", "api.clientConfig.clientMcode": "MiniMax Code", "api.clientConfig.clientZcode": "ZCode", + "api.clientConfig.clientCommandCode": "Command Code", "api.clientConfig.clientPrime": "Prime Agent", "api.clientConfig.clientAside": "Aside", "api.clientConfig.clientRaycast": "Raycast", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index b146c51151c..55d34a66336 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1975,6 +1975,7 @@ export const fr: Record = { "integrations.tab.dsh": "DSH", "integrations.tab.mcode": "MiniMax Code", "integrations.tab.zcode": "ZCode", + "integrations.tab.commandcode": "Command Code", "integrations.tab.prime": "Prime Agent", "integrations.tab.aside": "Aside", "integrations.tab.raycast": "Raycast", @@ -2186,6 +2187,7 @@ export const fr: Record = { "integrations.semantics.dsh": "OpenCodex gère uniquement llm-pi-ai.providers.opencodex dans $DSH_HOME/settings.yaml. DSH recharge ce fournisseur à chaud ; votre modèle par défaut et deepseek-official restent inchangés. Seule l’adresse de bouclage est actuellement prise en charge ; aucun identifiant réel n’est écrit.", "integrations.semantics.mcode": "Gère uniquement custom_provider.opencodex. Votre modèle par défaut et votre connexion MiniMax restent inchangés.", "integrations.semantics.zcode": "Gère uniquement provider.opencodex dans ~/.zcode/v2/config.json. Votre connexion Z.ai et les autres fournisseurs restent inchangés. Redémarrez ZCode après toute modification.", + "integrations.semantics.commandcode": "Gère uniquement provider.opencodex dans ~/.commandcode/providers.json. Vos autres fournisseurs et paramètres restent inchangés.", "integrations.semantics.prime": "Gère uniquement providers.opencodex dans le models.json de Prime Agent — ~/.prime/agent, sauf si PRIME_AGENT_CODING_AGENT_DIR le redirige. Vos autres fournisseurs et surcharges de modèles restent inchangés. S'applique aux nouvelles sessions.", "integrations.semantics.aside": "Gère uniquement providers.opencodex dans le fichier ~/.aside/u//models.json de ce profil. Vos autres fournisseurs restent inchangés. Quittez complètement Aside et relancez-le après application.", "integrations.semantics.raycast": "Ajoute une entrée de fournisseur OpenCodex dans le providers.yaml de Raycast afin que chaque modèle routé apparaisse dans le sélecteur de modèles de Raycast AI. Raycast Pro requis.", @@ -2682,6 +2684,7 @@ export const fr: Record = { "api.clientConfig.clientDsh": "DeepSeek Harness (DSH)", "api.clientConfig.clientMcode": "MiniMax Code", "api.clientConfig.clientZcode": "ZCode", + "api.clientConfig.clientCommandCode": "Command Code", "api.clientConfig.clientPrime": "Prime Agent", "api.clientConfig.clientAside": "Aside", "api.clientConfig.clientRaycast": "Raycast", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 479576d4484..b1af99a6d94 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1878,6 +1878,7 @@ export const ja: Record = { "integrations.tab.dsh": "DSH", "integrations.tab.mcode": "MiniMax Code", "integrations.tab.zcode": "ZCode", + "integrations.tab.commandcode": "Command Code", "integrations.tab.prime": "Prime Agent", "integrations.tab.aside": "Aside", "integrations.tab.raycast": "Raycast", @@ -2089,6 +2090,7 @@ export const ja: Record = { "integrations.semantics.dsh": "OpenCodex が管理するのは $DSH_HOME/settings.yaml 内の llm-pi-ai.providers.opencodex だけです。DSH はこのプロバイダーをホットリロードし、既定のモデルと deepseek-official は変更しません。現在はループバック専用で、実際の認証情報は書き込みません。", "integrations.semantics.mcode": "custom_provider.opencodex のみを管理します。既定モデルと MiniMax ログインは変更しません。", "integrations.semantics.zcode": "~/.zcode/v2/config.json の provider.opencodex のみを管理します。Z.ai ログインと他のプロバイダーは変更しません。変更後は ZCode を再起動してください。", + "integrations.semantics.commandcode": "~/.commandcode/providers.json の provider.opencodex のみを管理します。他のプロバイダーや設定は変更しません。", "integrations.semantics.prime": "Prime Agent の models.json 内の providers.opencodex のみを管理します。場所は ~/.prime/agent ですが、PRIME_AGENT_CODING_AGENT_DIR が設定されている場合はそちらが優先されます。他のプロバイダーとモデルオーバーライドは変更しません。新しいセッションから適用されます。", "integrations.semantics.aside": "このプロファイルの ~/.aside/u//models.json 内の providers.opencodex のみを管理します。他のプロバイダーは変更しません。適用後は Aside を完全に終了してから開き直してください。", "integrations.semantics.raycast": "Raycast の providers.yaml に OpenCodex のプロバイダーエントリを追加し、ルーティングされたすべてのモデルを Raycast AI のモデル選択に表示します。Raycast Pro が必要です。", @@ -2591,6 +2593,7 @@ export const ja: Record = { "api.clientConfig.clientDsh": "DeepSeek Harness (DSH)", "api.clientConfig.clientMcode": "MiniMax Code", "api.clientConfig.clientZcode": "ZCode", + "api.clientConfig.clientCommandCode": "Command Code", "api.clientConfig.clientPrime": "Prime Agent", "api.clientConfig.clientAside": "Aside", "api.clientConfig.clientRaycast": "Raycast", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 55ad082da30..6cc2f9fcdae 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1481,6 +1481,7 @@ export const ko: Record = { "integrations.tab.dsh": "DSH", "integrations.tab.mcode": "MiniMax Code", "integrations.tab.zcode": "ZCode", + "integrations.tab.commandcode": "Command Code", "integrations.tab.prime": "Prime Agent", "integrations.tab.aside": "Aside", "integrations.tab.raycast": "Raycast", @@ -1692,6 +1693,7 @@ export const ko: Record = { "integrations.semantics.dsh": "OpenCodex는 $DSH_HOME/settings.yaml의 llm-pi-ai.providers.opencodex만 관리합니다. DSH는 이 provider를 hot reload하며 기본 model과 deepseek-official은 변경하지 않습니다. 현재 loopback 전용이며 실제 credential을 기록하지 않습니다.", "integrations.semantics.mcode": "custom_provider.opencodex만 관리하며 기본 모델과 MiniMax 로그인은 변경하지 않습니다.", "integrations.semantics.zcode": "~/.zcode/v2/config.json의 provider.opencodex만 관리하며 Z.ai 로그인과 다른 프로바이더는 변경하지 않습니다. 변경 후 ZCode를 재시작하세요.", + "integrations.semantics.commandcode": "~/.commandcode/providers.json의 provider.opencodex만 관리합니다. 다른 프로바이더와 설정은 변경되지 않습니다.", "integrations.semantics.prime": "Prime Agent의 models.json에서 providers.opencodex만 관리합니다. 위치는 ~/.prime/agent이며 PRIME_AGENT_CODING_AGENT_DIR가 설정되면 그쪽이 우선합니다. 다른 프로바이더와 모델 오버라이드는 변경하지 않습니다. 새 세션부터 적용됩니다.", "integrations.semantics.aside": "이 프로필의 ~/.aside/u//models.json에서 providers.opencodex만 관리합니다. 다른 프로바이더는 그대로 유지됩니다. 적용 후 Aside를 완전히 종료하고 다시 여세요.", "integrations.semantics.raycast": "Raycast의 providers.yaml에 OpenCodex 프로바이더 항목을 추가해 라우팅된 모든 모델이 Raycast AI 모델 선택기에 표시되도록 합니다. Raycast Pro가 필요합니다.", @@ -2191,6 +2193,7 @@ export const ko: Record = { "api.clientConfig.clientDsh": "DeepSeek Harness (DSH)", "api.clientConfig.clientMcode": "MiniMax Code", "api.clientConfig.clientZcode": "ZCode", + "api.clientConfig.clientCommandCode": "Command Code", "api.clientConfig.clientPrime": "Prime Agent", "api.clientConfig.clientAside": "Aside", "api.clientConfig.clientRaycast": "Raycast", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 545641e5a71..3d5e344d179 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1969,6 +1969,7 @@ export const ru: Record = { "integrations.tab.dsh": "DSH", "integrations.tab.mcode": "MiniMax Code", "integrations.tab.zcode": "ZCode", + "integrations.tab.commandcode": "Command Code", "integrations.tab.prime": "Prime Agent", "integrations.tab.aside": "Aside", "integrations.tab.raycast": "Raycast", @@ -2180,6 +2181,7 @@ export const ru: Record = { "integrations.semantics.dsh": "OpenCodex управляет только llm-pi-ai.providers.opencodex в $DSH_HOME/settings.yaml. DSH применяет этот провайдер горячей перезагрузкой; модель по умолчанию и deepseek-official остаются без изменений. Сейчас поддерживается только loopback; реальные учётные данные не записываются.", "integrations.semantics.mcode": "Управляет только custom_provider.opencodex. Модель по умолчанию и вход MiniMax не меняются.", "integrations.semantics.zcode": "Управляет только provider.opencodex в ~/.zcode/v2/config.json. Вход Z.ai и другие провайдеры не меняются. Перезапустите ZCode после изменений.", + "integrations.semantics.commandcode": "Управляет только provider.opencodex в ~/.commandcode/providers.json. Другие провайдеры и настройки остаются без изменений.", "integrations.semantics.prime": "Управляет только providers.opencodex в models.json Prime Agent — ~/.prime/agent, если PRIME_AGENT_CODING_AGENT_DIR не переопределяет путь. Другие провайдеры и переопределения моделей не меняются. Применяется к новым сессиям.", "integrations.semantics.aside": "Управляет только providers.opencodex в файле ~/.aside/u//models.json этого профиля. Другие провайдеры остаются без изменений. После применения полностью закройте и снова откройте Aside.", "integrations.semantics.raycast": "Добавляет запись провайдера OpenCodex в providers.yaml Raycast, чтобы каждая маршрутизируемая модель появилась в выборе моделей Raycast AI. Требуется Raycast Pro.", @@ -2681,6 +2683,7 @@ export const ru: Record = { "api.clientConfig.clientDsh": "DeepSeek Harness (DSH)", "api.clientConfig.clientMcode": "MiniMax Code", "api.clientConfig.clientZcode": "ZCode", + "api.clientConfig.clientCommandCode": "Command Code", "api.clientConfig.clientPrime": "Prime Agent", "api.clientConfig.clientAside": "Aside", "api.clientConfig.clientRaycast": "Raycast", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 6de30bf8766..45661172462 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1988,6 +1988,7 @@ export const tr: Record = { "integrations.tab.dsh": "DSH", "integrations.tab.mcode": "MiniMax Code", "integrations.tab.zcode": "ZCode", + "integrations.tab.commandcode": "Command Code", "integrations.tab.prime": "Prime Agent", "integrations.tab.aside": "Aside", "integrations.tab.raycast": "Raycast", @@ -2198,6 +2199,7 @@ export const tr: Record = { "integrations.semantics.dsh": "OpenCodex yalnızca $DSH_HOME/settings.yaml içindeki llm-pi-ai.providers.opencodex bölümünü yönetir. DSH bu sağlayıcıyı çalışırken yeniden yükler; varsayılan modeliniz ve deepseek-official değişmez. Şimdilik yalnızca geri döngü desteklenir; gerçek kimlik bilgisi yazılmaz.", "integrations.semantics.mcode": "Yalnızca custom_provider.opencodex bölümünü yönetir. Varsayılan model ve MiniMax oturumu değişmez.", "integrations.semantics.zcode": "Yalnızca ~/.zcode/v2/config.json içindeki provider.opencodex bölümünü yönetir. Z.ai oturumu ve diğer sağlayıcılar değişmez. Değişikliklerden sonra ZCode'u yeniden başlatın.", + "integrations.semantics.commandcode": "Yalnızca ~/.commandcode/providers.json içindeki provider.opencodex bölümünü yönetir. Diğer sağlayıcılarınız ve ayarlarınız değişmez.", "integrations.semantics.prime": "Yalnızca Prime Agent'ın models.json dosyasındaki providers.opencodex bölümünü yönetir — PRIME_AGENT_CODING_AGENT_DIR ayarlı değilse ~/.prime/agent. Diğer sağlayıcılar ve model geçersiz kılmaları değişmez. Yeni oturumlarda geçerli olur.", "integrations.semantics.aside": "Yalnızca bu profilin ~/.aside/u//models.json dosyasındaki providers.opencodex bölümünü yönetir. Diğer sağlayıcılarınız değişmez. Uyguladıktan sonra Aside’ı tamamen kapatıp yeniden açın.", "integrations.semantics.raycast": "Raycast'in providers.yaml dosyasına bir OpenCodex sağlayıcı girdisi ekler; böylece yönlendirilen her model Raycast AI model seçicisinde görünür. Raycast Pro gerekir.", @@ -2700,6 +2702,7 @@ export const tr: Record = { "api.clientConfig.clientDsh": "DeepSeek Harness (DSH)", "api.clientConfig.clientMcode": "MiniMax Code", "api.clientConfig.clientZcode": "ZCode", + "api.clientConfig.clientCommandCode": "Command Code", "api.clientConfig.clientPrime": "Prime Agent", "api.clientConfig.clientAside": "Aside", "api.clientConfig.clientRaycast": "Raycast", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 23a799cd258..7afd939e460 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -1958,6 +1958,7 @@ export const vi: Record = { "integrations.tab.dsh": "DSH", "integrations.tab.mcode": "MiniMax Code", "integrations.tab.zcode": "ZCode", + "integrations.tab.commandcode": "Command Code", "integrations.tab.prime": "Prime Agent", "integrations.tab.aside": "Aside", "integrations.tab.raycast": "Raycast", @@ -2214,6 +2215,7 @@ export const vi: Record = { "integrations.semantics.dsh": "OpenCodex chỉ quản lý llm-pi-ai.providers.opencodex trong $DSH_HOME/settings.yaml. DSH sẽ hot reload provider này; model mặc định của bạn và deepseek-official không thay đổi. Hiện chỉ hỗ trợ loopback; không ghi credential thật nào.", "integrations.semantics.mcode": "Chỉ quản lý custom_provider.opencodex. Model mặc định của bạn và đăng nhập MiniMax không thay đổi.", "integrations.semantics.zcode": "Chỉ quản lý provider.opencodex trong ~/.zcode/v2/config.json. Đăng nhập Z.ai của bạn và các provider khác không thay đổi. Khởi động lại ZCode sau khi thay đổi.", + "integrations.semantics.commandcode": "Chỉ quản lý provider.opencodex trong ~/.commandcode/providers.json. Các provider và cài đặt khác của bạn vẫn không thay đổi.", "integrations.semantics.prime": "Chỉ quản lý providers.opencodex trong models.json của Prime Agent — ~/.prime/agent trừ khi PRIME_AGENT_CODING_AGENT_DIR điều hướng nó. Các provider khác và ngoại lệ model của bạn không thay đổi. Áp dụng cho các session mới.", "integrations.semantics.aside": "Chỉ quản lý providers.opencodex trong ~/.aside/u//models.json của profile này. Các provider khác của bạn không thay đổi. Hãy thoát hoàn toàn và mở lại Aside sau khi áp dụng.", "integrations.semantics.raycast": "Thêm một provider OpenCodex vào providers.yaml của Raycast để mọi model được định tuyến xuất hiện trong bộ chọn model AI của Raycast. Yêu cầu Raycast Pro.", @@ -2700,6 +2702,7 @@ export const vi: Record = { "api.clientConfig.clientDsh": "DeepSeek Harness (DSH)", "api.clientConfig.clientMcode": "MiniMax Code", "api.clientConfig.clientZcode": "ZCode", + "api.clientConfig.clientCommandCode": "Command Code", "api.clientConfig.clientPrime": "Prime Agent", "api.clientConfig.clientAside": "Aside", "api.clientConfig.clientRaycast": "Raycast", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index b8b8786e243..f38adf584bc 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2780,6 +2780,7 @@ export const zhTW: Record = { "integrations.tab.dsh": "DSH", "integrations.tab.mcode": "MiniMax Code", "integrations.tab.zcode": "ZCode", + "integrations.tab.commandcode": "Command Code", "integrations.tab.prime": "Prime Agent", "integrations.tab.aside": "Aside", "integrations.tab.raycast": "Raycast", @@ -2991,6 +2992,7 @@ export const zhTW: Record = { "integrations.semantics.dsh": "OpenCodex 只管理 $DSH_HOME/settings.yaml 中的 llm-pi-ai.providers.opencodex。DSH 會熱重載該 provider;你的預設模型與 deepseek-official 維持不變。目前僅支援 loopback,且不會寫入真實憑證。", "integrations.semantics.mcode": "僅管理 custom_provider.opencodex,不會變更預設模型或 MiniMax 登入狀態。", "integrations.semantics.zcode": "僅管理 ~/.zcode/v2/config.json 中的 provider.opencodex,不會變更 Z.ai 登入狀態或其他供應商。變更後請重新啟動 ZCode。", + "integrations.semantics.commandcode": "僅管理 ~/.commandcode/providers.json 中的 provider.opencodex。其他供應商和設定保持不變。", "integrations.semantics.prime": "僅管理 Prime Agent 的 models.json 中的 providers.opencodex;預設位於 ~/.prime/agent,若設定 PRIME_AGENT_CODING_AGENT_DIR 則以其為準。不會變更其他供應商或模型覆寫設定。對新工作階段生效。", "integrations.semantics.aside": "僅管理此設定檔的 ~/.aside/u//models.json 中的 providers.opencodex。其他供應商維持不變。套用後請完全結束並重新開啟 Aside。", "integrations.semantics.raycast": "在 Raycast 的 providers.yaml 中新增一個 OpenCodex 供應商項目,讓所有已路由的模型出現在 Raycast AI 模型選擇器中。需要 Raycast Pro。", @@ -3046,6 +3048,7 @@ export const zhTW: Record = { "api.clientConfig.clientDsh": "DeepSeek Harness (DSH)", "api.clientConfig.clientMcode": "MiniMax Code", "api.clientConfig.clientZcode": "ZCode", + "api.clientConfig.clientCommandCode": "Command Code", "api.clientConfig.clientPrime": "Prime Agent", "api.clientConfig.clientAside": "Aside", "api.clientConfig.clientRaycast": "Raycast", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index c93b513d65a..f2f2a55338f 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1462,6 +1462,7 @@ export const zh: Record = { "integrations.tab.dsh": "DSH", "integrations.tab.mcode": "MiniMax Code", "integrations.tab.zcode": "ZCode", + "integrations.tab.commandcode": "Command Code", "integrations.tab.prime": "Prime Agent", "integrations.tab.aside": "Aside", "integrations.tab.raycast": "Raycast", @@ -1673,6 +1674,7 @@ export const zh: Record = { "integrations.semantics.dsh": "OpenCodex 只管理 $DSH_HOME/settings.yaml 中的 llm-pi-ai.providers.opencodex。DSH 会热重载该 provider;你的默认模型和 deepseek-official 保持不变。目前仅支持环回地址,且不会写入真实凭据。", "integrations.semantics.mcode": "仅管理 custom_provider.opencodex,不会更改默认模型或 MiniMax 登录状态。", "integrations.semantics.zcode": "仅管理 ~/.zcode/v2/config.json 中的 provider.opencodex,不会更改 Z.ai 登录状态或其他提供商。更改后请重启 ZCode。", + "integrations.semantics.commandcode": "仅管理 ~/.commandcode/providers.json 中的 provider.opencodex。其他提供商和设置保持不变。", "integrations.semantics.prime": "仅管理 Prime Agent 的 models.json 中的 providers.opencodex;默认位于 ~/.prime/agent,若设置 PRIME_AGENT_CODING_AGENT_DIR 则以其为准。不会更改其他提供商或模型覆盖设置。对新会话生效。", "integrations.semantics.aside": "仅管理此配置文件的 ~/.aside/u//models.json 中的 providers.opencodex。其他提供商保持不变。应用后请完全退出并重新打开 Aside。", "integrations.semantics.raycast": "在 Raycast 的 providers.yaml 中添加一个 OpenCodex 提供商条目,让所有已路由的模型出现在 Raycast AI 模型选择器中。需要 Raycast Pro。", @@ -2172,6 +2174,7 @@ export const zh: Record = { "api.clientConfig.clientDsh": "DeepSeek Harness (DSH)", "api.clientConfig.clientMcode": "MiniMax Code", "api.clientConfig.clientZcode": "ZCode", + "api.clientConfig.clientCommandCode": "Command Code", "api.clientConfig.clientPrime": "Prime Agent", "api.clientConfig.clientAside": "Aside", "api.clientConfig.clientRaycast": "Raycast", diff --git a/gui/src/pages/integrations/FileIntegrationPage.tsx b/gui/src/pages/integrations/FileIntegrationPage.tsx index bcfa783178e..8e2216be70b 100644 --- a/gui/src/pages/integrations/FileIntegrationPage.tsx +++ b/gui/src/pages/integrations/FileIntegrationPage.tsx @@ -79,6 +79,7 @@ const SEMANTICS_KEY: Record = { dsh: "integrations.semantics.dsh", mcode: "integrations.semantics.mcode", zcode: "integrations.semantics.zcode", + commandcode: "integrations.semantics.commandcode", prime: "integrations.semantics.prime", aside: "integrations.semantics.aside", raycast: "integrations.semantics.raycast", @@ -99,6 +100,7 @@ const TAB_LABEL_KEY: Record = { dsh: "integrations.tab.dsh", mcode: "integrations.tab.mcode", zcode: "integrations.tab.zcode", + commandcode: "integrations.tab.commandcode", prime: "integrations.tab.prime", aside: "integrations.tab.aside", raycast: "integrations.tab.raycast", diff --git a/gui/src/pages/integrations/integration-api.ts b/gui/src/pages/integrations/integration-api.ts index 873e28683fa..741b9317167 100644 --- a/gui/src/pages/integrations/integration-api.ts +++ b/gui/src/pages/integrations/integration-api.ts @@ -12,6 +12,7 @@ export const FILE_INTEGRATION_CLIENTS = [ "dsh", "mcode", "zcode", + "commandcode", "prime", "aside", "raycast", diff --git a/gui/src/pages/integrations/integration-tabs.ts b/gui/src/pages/integrations/integration-tabs.ts index 4978aaeebcc..150d9608e6f 100644 --- a/gui/src/pages/integrations/integration-tabs.ts +++ b/gui/src/pages/integrations/integration-tabs.ts @@ -44,6 +44,7 @@ export const TABS: readonly TabDefinition[] = [ { id: "dsh", hash: "integrations/dsh", labelKey: "integrations.tab.dsh" }, { id: "mcode", hash: "integrations/mcode", labelKey: "integrations.tab.mcode" }, { id: "zcode", hash: "integrations/zcode", labelKey: "integrations.tab.zcode" }, + { id: "commandcode", hash: "integrations/commandcode", labelKey: "integrations.tab.commandcode" }, { id: "prime", hash: "integrations/prime", labelKey: "integrations.tab.prime" }, { id: "aside", hash: "integrations/aside", labelKey: "integrations.tab.aside" }, { id: "raycast", hash: "integrations/raycast", labelKey: "integrations.tab.raycast" }, @@ -64,6 +65,7 @@ export const FILE_CLIENTS = new Set([ "dsh", "mcode", "zcode", + "commandcode", "prime", "aside", "raycast", diff --git a/gui/src/pages/integrations/overview-clients.ts b/gui/src/pages/integrations/overview-clients.ts index df02ccf29b5..3b14324eac8 100644 --- a/gui/src/pages/integrations/overview-clients.ts +++ b/gui/src/pages/integrations/overview-clients.ts @@ -151,6 +151,7 @@ const FILE_LABEL_KEY: Record = { dsh: "integrations.tab.dsh", mcode: "integrations.tab.mcode", zcode: "integrations.tab.zcode", + commandcode: "integrations.tab.commandcode", prime: "integrations.tab.prime", aside: "integrations.tab.aside", raycast: "integrations.tab.raycast", diff --git a/gui/tests/client-config-panel.test.tsx b/gui/tests/client-config-panel.test.tsx index 57d7a577ebb..5d144e200d5 100644 --- a/gui/tests/client-config-panel.test.tsx +++ b/gui/tests/client-config-panel.test.tsx @@ -171,10 +171,11 @@ 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", "prime", "aside", "raycast", "omo", "cline", "kilo", "droid"]); + expect(CLIENTS).toEqual(["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"); + expect(CLIENT_LABEL_KEYS.commandcode).toBe("api.clientConfig.clientCommandCode"); expect(CLIENT_LABEL_KEYS.aside).toBe("api.clientConfig.clientAside"); expect(CLIENT_LABEL_KEYS.omo).toBe("api.clientConfig.clientOmo"); }); diff --git a/gui/tests/integrations-api.test.ts b/gui/tests/integrations-api.test.ts index 546bb3dc473..c80a99d71c8 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", "prime", "aside", "raycast", "omo", "cline", "kilo", "droid", + "opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "commandcode", "prime", "aside", "raycast", "omo", "cline", "kilo", "droid", ]); }); diff --git a/gui/tests/integrations-overview-rows.test.ts b/gui/tests/integrations-overview-rows.test.ts index f37bcecd54c..f0ea30315b4 100644 --- a/gui/tests/integrations-overview-rows.test.ts +++ b/gui/tests/integrations-overview-rows.test.ts @@ -312,6 +312,7 @@ test("an unsettled file list renders unknown rows instead of dropping them", () const built = buildOverviewRows(sources({ clients: [], clientsSettled: false })); expect(built.rows).toHaveLength(FILE_INTEGRATION_CLIENTS.length + 5); expect(rowById(built, "cline")).toMatchObject({ hash: "integrations/cline", labelKey: "integrations.tab.cline", state: "unknown" }); + expect(rowById(built, "commandcode")).toMatchObject({ hash: "integrations/commandcode", labelKey: "integrations.tab.commandcode", state: "unknown" }); expect(rowById(built, "kilo")).toMatchObject({ hash: "integrations/kilo", labelKey: "integrations.tab.kilo", state: "unknown" }); expect(rowById(built, "droid")).toMatchObject({ hash: "integrations/droid", labelKey: "integrations.tab.droid", state: "unknown" }); expect(rowById(built, "omp").state).toBe("unknown"); diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 3fc00931e08..1a1c818404e 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -429,6 +429,7 @@ "cline-pass-reasoning-efforts.test.ts": "providers", "cline-provider.test.ts": "providers", "cline-writer.test.ts": "clients", + "command-code-client.test.ts": "clients", "closed-pr-branch-cleanup.test.ts": "ci-workflows", "codebuddy-adapter.test.ts": "providers", "codebuddy-live-acceptance.test.ts": "providers", diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 450829c20d1..3b34525f145 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -513,7 +513,7 @@ const commandRunners: Record = { }, config, port: live.port, - }, ["mcode", "pi", "raycast", "omo", "cline", "droid"])); + }, ["mcode", "pi", "raycast", "omo", "cline", "commandcode", "droid"])); } catch (error) { console.warn(`Client integrations were not refreshed: ${error instanceof Error ? error.message : String(error)}`); } @@ -978,6 +978,14 @@ const commandRunners: Record = { const { handleZcodeCommand } = await import("./integrations"); return await handleZcodeCommand(deps.args.slice(1)); }, + commandcode: async deps => { + const { handleCommandcodeCommand } = await import("./integrations"); + return await handleCommandcodeCommand(deps.args.slice(1)); + }, + cmd: async deps => { + const { handleCommandcodeCommand } = await import("./integrations"); + return await handleCommandcodeCommand(deps.args.slice(1)); + }, help: async () => { printUsage(); return 0; diff --git a/src/cli/help.ts b/src/cli/help.ts index 488df7bc4ff..e14963aa72e 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -83,7 +83,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 (17 clients) + ocx export --client Print a client config wired to the running proxy (18 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 @@ -96,6 +96,8 @@ Usage: ocx mcode [args...] Launch MiniMax Code through its managed provider ocx mmx text [args] Launch MiniMax CLI text through the proxy ocx zcode [sub] Connect ZCode to the proxy (managed provider) + ocx commandcode [sub] Connect Command Code CLI to the proxy (managed provider) + ocx cmd [sub] Alias of ocx commandcode ocx help [command] Show help ocx --version | -v Print version diff --git a/src/cli/integrations.ts b/src/cli/integrations.ts index 1caa2be0cb0..2cd8df08771 100644 --- a/src/cli/integrations.ts +++ b/src/cli/integrations.ts @@ -368,3 +368,24 @@ export async function handleZcodeCommand(argv: string[], deps: RuntimeApiDeps = } return code; } + +const COMMANDCODE_USAGE = "Usage: ocx commandcode [status|show|list|enable|disable|history|journal|restore] [--json]"; + +export async function handleCommandcodeCommand(argv: string[], deps: RuntimeApiDeps = {}): Promise { + const args = [...argv]; + const verbIndex = args.findIndex(arg => !arg.startsWith("-")); + const action = (verbIndex === -1 ? "status" : args[verbIndex]).toLowerCase(); + const known = ["status", "show", "list", "enable", "disable", "history", "journal", "restore"]; + if (!known.includes(action)) { + console.error(`unknown commandcode command ${action}`); + console.error(COMMANDCODE_USAGE); + return 2; + } + const rest = verbIndex === -1 ? args : [...args.slice(0, verbIndex), ...args.slice(verbIndex + 1)]; + const forwarded = action === "restore" ? [action, ...rest] : [action, ...rest, "--client", "commandcode"]; + const code = await handleClientIntegrationCommand(forwarded, deps); + if (code === 0 && (action === "enable" || action === "disable")) { + console.error("Command Code reads providers on startup."); + } + return code; +} diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 3dcc639c7e2..5dfe85457fc 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -416,8 +416,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, 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) 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.", @@ -536,6 +536,22 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ "Select OpenCodex Proxy// from ZCode's model picker.", ], }, + { + name: "commandcode", + usage: "ocx commandcode [status|show|list|enable|disable|history|restore --op ] [--json]", + summary: "Connect Command Code CLI to the proxy via its managed provider.", + details: [ + "Alias of ocx integration client --client commandcode.", + "enable writes the managed provider.opencodex block into ~/.commandcode/providers.json; disable removes only that block.", + "Command Code reads providers on startup.", + "`restore --op ` replays the exact previous managed block; take the id from `ocx commandcode history`.", + ], + }, + { + name: "cmd", + usage: "ocx cmd [status|show|list|enable|disable|history|restore --op ] [--json]", + summary: "Alias of ocx commandcode.", + }, { name: "restart", usage: "ocx restart", diff --git a/src/clients/config-export.ts b/src/clients/config-export.ts index 63efee0c0f3..240fa1e9f92 100644 --- a/src/clients/config-export.ts +++ b/src/clients/config-export.ts @@ -67,6 +67,17 @@ import { buildKiloClientConfig, summarizeKilo, buildKiloContribution, kiloConfig export { kiloConfigPath, kiloHomeDir, kiloCandidatePath, KILO_CONFIG_CANDIDATES } from "./config-export/kilo"; export type { KiloGeneratedConfig, KiloProviderBlock, KiloModelEntry } from "./config-export/kilo"; import { droidConfigPath, buildDroidClientConfig, summarizeDroid, buildDroidContribution } from "./config-export/droid"; +import { + buildCommandCodeClientConfig, + summarizeCommandCode, + buildCommandCodeContribution, + type CommandCodeGeneratedConfig, + type CommandCodeModelEntry, + type CommandCodeProviderBlock, +} from "./config-export/commandcode"; + +export type { CommandCodeGeneratedConfig, CommandCodeModelEntry, CommandCodeProviderBlock }; +export { buildCommandCodeClientConfig, summarizeCommandCode, buildCommandCodeContribution }; @@ -465,6 +476,30 @@ export function zcodeConfigPath(env: OpencodeLaunchEnv = process.env, home: stri return join(zcodeHomeDir(env, home), "v2", "config.json"); } +/** + * Command Code's provider store directory — `~/.commandcode`, with no override. + * + * There is deliberately no `COMMANDCODE_HOME` here, unlike the other clients in this + * file. The published client (`command-code@1.66.0`) resolves its store as + * `homeDir15() + "/.commandcode/providers.json"`, and `homeDir15` is exactly + * `env.HOME ?? env.USERPROFILE` — `grep -c COMMANDCODE_HOME` over the shipped + * `dist/cli.mjs` and `dist/index.mjs` returns 0. + * + * An override we honour but the client ignores is worse than none: `enable` would + * report success and write a provider block at a path no Command Code process ever + * opens, and the user would see an empty model list with no error anywhere. Every + * other client in this file earns its override because that client documents it; + * this one does not, so an apply against a relocated home is refused by `detectDir` + * simply not existing rather than being silently written to the wrong place. + */ +export function commandCodeHomeDir(_env: OpencodeLaunchEnv = process.env, home: string = homedir()): string { + return join(home, ".commandcode"); +} + +export function commandCodeConfigPath(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string { + return join(commandCodeHomeDir(env, home), "providers.json"); +} + /** * The provider store a current ZCode reads, which is NOT the file above. * @@ -1501,6 +1536,20 @@ export const EXPORT_CLIENTS: Record = { // remote binds refuse — same reasoning as MCode. loopbackOnly: true, }, + commandcode: { + id: "commandcode", + filename: "providers.json", + destination: env => commandCodeConfigPath(env), + // No env var exists behind this integration: buildCommandCodeClientConfig + // writes `apiKey: false`, the documented keyless form for a loopback endpoint. + apiKeyEnv: "", + exportHint: "The provider is written with `apiKey: false` — Command Code treats a keyless loopback endpoint as already authenticated. No key is stored or referenced.", + build: buildCommandCodeClientConfig, + format: "json", + summarize: summarizeCommandCode, + buildContribution: buildCommandCodeContribution, + loopbackOnly: true, + }, prime: { id: "prime", filename: "prime-models.json", diff --git a/src/clients/config-export/commandcode.ts b/src/clients/config-export/commandcode.ts new file mode 100644 index 00000000000..aa88e05279c --- /dev/null +++ b/src/clients/config-export/commandcode.ts @@ -0,0 +1,133 @@ +// Command Code config export. +import type { ExportContext, ManagedContribution } from "./contracts"; +import { normalizeExportModels, authoritativeContextWindow, singleFragment } from "./model-metadata"; +import { OPENCODE_PROVIDER_ID, LOOPBACK_API_KEY_PLACEHOLDER } from "./constants"; +import { sanitizeCodexReasoningEfforts } from "../../reasoning-effort"; + +export interface CommandCodeModelEntry { + reasoningEfforts?: string[]; + contextWindow?: number; +} + +export interface CommandCodeProviderBlock { + name: "OpenCodex"; + api: "openai-completions"; + baseURL: string; + apiKey: string | boolean; + models: Record; +} + +export interface CommandCodeGeneratedConfig { + provider: Record; + /** The root a pre-existing target already uses; see {@link commandCodeProviderRoot}. */ + providers?: Record; +} + +/** + * LOSSY spelling-collision key for a routed selector — not model identity. + * + * One provider model reaches this exporter under interchangeable spellings: the raw + * selector keeps inner slashes (`command-code/deepseek/deepseek-v4.1-flash`, what + * `/v1/models` publishes) while the Codex-facing form encodes them as dashes + * (`command-code/deepseek-deepseek-v4.1-flash`, what `~/.codex/config.toml` stores and + * what an operator typing `--model` copies). Command Code addresses models by exact key, + * so a catalog that carries both spellings can resolve the active model against one and + * miss it in the other. + * + * The slash is meaningful: it separates the provider from the model id, and a provider id + * never contains one. But the encoding is NOT bijective — `p/a/b` and `p/a-b` are + * different model ids that encode to the same string. That is the same lossy relation + * `slugEquivalenceKey` documents in src/providers/slug-codec.ts and the same ambiguity + * `resolveSlugAliasCollisions` guards in the catalog. Callers must therefore never drop + * a row on this key alone; buildCommandCodeClientConfig additionally compares provider + * and id before folding two rows into one. + */ +function canonicalSpellingOf(namespaced: string): string { + const slash = namespaced.indexOf("/"); + if (slash <= 0) return namespaced; + return namespaced.slice(0, slash) + "/" + namespaced.slice(slash + 1).replaceAll("/", "-"); +} + +export function buildCommandCodeClientConfig(ctx: ExportContext): CommandCodeGeneratedConfig { + const models: Record = {}; + // Fold interchangeable spellings of one model onto a single key. The first + // occurrence wins per provider+id identity; distinct model ids that happen to + // share a lossy spelling collision must both remain addressable. + const emittedIdentitiesByCollision = new Map>(); + for (const model of normalizeExportModels(ctx.models)) { + const emittedKey = model.namespaced; + const collisionKey = canonicalSpellingOf(model.namespaced); + const identity = `${model.provider}\u0000${model.id}`; + const identities = emittedIdentitiesByCollision.get(collisionKey) ?? new Set(); + if (identities.has(identity)) continue; + identities.add(identity); + emittedIdentitiesByCollision.set(collisionKey, identities); + const entry: CommandCodeModelEntry = {}; + const context = authoritativeContextWindow(model.contextWindow); + if (context !== undefined) { + entry.contextWindow = context; + } + const efforts = sanitizeCodexReasoningEfforts(model.reasoningEfforts) + ?.filter(effort => ["low", "medium", "high", "xhigh", "max"].includes(effort)); + if (efforts && efforts.length > 0) { + entry.reasoningEfforts = efforts; + } + models[emittedKey] = entry; + } + return { + provider: { + [OPENCODE_PROVIDER_ID]: { + name: "OpenCodex", + api: "openai-completions", + baseURL: ctx.baseUrl.replace(/\/v1\/?$/, "") + "/v1", + apiKey: false, + models, + }, + }, + }; +} + +/** + * The root key Command Code actually reads: `provider`, else `providers`. + * + * Verified against the published client (`command-code@1.66.0`, `dist/cli.mjs`): + * `const o = e.provider ?? e.providers;` — singular wins, and the plural branch is + * only reached when the singular root is `undefined`. Adding a singular root to a + * document that already carries a plural one therefore does not merge the two: the + * old entries stay on disk, byte for byte, and become invisible to the consumer. + * + * The grammar `isPlainRecord` check mirrors the client's own guard, so a `null` or + * non-object root is treated as absent exactly as the client treats it. + */ +export function commandCodeProviderRoot(document: unknown): "provider" | "providers" { + const doc = document as { provider?: unknown; providers?: unknown } | null | undefined; + if (isPlainObject(doc?.provider)) return "provider"; + if (isPlainObject(doc?.providers)) return "providers"; + // Nothing established yet: the singular root is the one this exporter writes. + return "provider"; +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function summarizeCommandCode(document: unknown): { modelCount: number; modelsWithoutLimits: number } { + const doc = document as CommandCodeGeneratedConfig | undefined; + const root = commandCodeProviderRoot(document); + const models = Object.values(doc?.[root]?.[OPENCODE_PROVIDER_ID]?.models ?? {}) as CommandCodeModelEntry[]; + return { modelCount: models.length, modelsWithoutLimits: models.filter(model => model.contextWindow === undefined).length }; +} + +/** + * The provider block, written under whichever root the target document already uses. + * + * `ExportContext` carries the parsed target document so the root can be chosen from + * what is on disk rather than assumed. A fresh file gets the singular root this + * exporter has always written; a document that already carries a plural root keeps + * it, so enabling OpenCodex never makes the user's other providers unreadable. + */ +export function buildCommandCodeContribution(ctx: ExportContext): ManagedContribution { + const doc = buildCommandCodeClientConfig(ctx); + const root = commandCodeProviderRoot(ctx.document); + return singleFragment("commandcode", [root, OPENCODE_PROVIDER_ID], doc.provider[OPENCODE_PROVIDER_ID]); +} diff --git a/src/clients/config-export/contracts.ts b/src/clients/config-export/contracts.ts index f32d4abff96..90c0f7e87f8 100644 --- a/src/clients/config-export/contracts.ts +++ b/src/clients/config-export/contracts.ts @@ -88,6 +88,17 @@ export interface ExportContext { * admission from `apiKey` to the `x-opencodex-api-key` header. */ config?: OcxConfig; + /** + * The parsed target document, when the caller has one in hand. + * + * A client whose reader picks between two roots needs the bytes on disk to + * choose: Command Code resolves `document.provider ?? document.providers`, so + * writing the singular root into a document that already carries the plural one + * leaves the user's providers unreadable rather than merging with them. Absent + * for `ocx export`, which has no target file — the builder then writes its + * default root, exactly as before. + */ + document?: unknown; } export type ExportClientId = @@ -106,6 +117,7 @@ export type ExportClientId = | "raycast" | "omo" | "cline" + | "commandcode" | "kilo" | "droid"; diff --git a/src/integrations/mutation-plan.ts b/src/integrations/mutation-plan.ts index c35ee4e27d5..96c69fea48c 100644 --- a/src/integrations/mutation-plan.ts +++ b/src/integrations/mutation-plan.ts @@ -141,6 +141,13 @@ const CLIENT_MANAGED_PATHS = { [...ZCODE_STORE_PROVIDER_RULES_PATH, `[providerId=${OPENCODE_PROVIDER_ID}]`], [...ZCODE_STORE_MODEL_RULES_PATH, DYNAMIC_SEGMENT], ], + /* + * Both roots, because the client picks between them rather than reading both: + * `document.provider ?? document.providers` in `command-code@1.66.0`. The + * builder chooses the one the target already uses, and a block we wrote under + * either root has to be removable from either root afterwards. + */ + commandcode: [["provider", OPENCODE_PROVIDER_ID], ["providers", OPENCODE_PROVIDER_ID]], prime: [["providers", OPENCODE_PROVIDER_ID]], aside: [["providers", OPENCODE_PROVIDER_ID]], raycast: [["providers", `[id=${OPENCODE_PROVIDER_ID}]`]], @@ -936,7 +943,7 @@ export function observeIntegration( * reads a different document there, and a write in the config file's shape * would be as unread as a write to the config file itself. */ - const contribution = effective.buildContribution(exportContextOf(input)); + const contribution = effective.buildContribution(exportContextOf({ ...input, document: parsed })); // A record proves ownership of the file it was written FOR. Matching only by // client id let a record for one home authorize a write to another whose // bytes happened to hash the same — which deleted a config we never touched. diff --git a/src/integrations/registry.ts b/src/integrations/registry.ts index 798d0900071..1fa3dcf003e 100644 --- a/src/integrations/registry.ts +++ b/src/integrations/registry.ts @@ -54,6 +54,8 @@ import { zcodeStoreSchemaEstablished, type BuildContribution, type ConfigFormat, + commandCodeConfigPath, + commandCodeHomeDir, kiloConfigPath, kiloHomeDir, kiloCandidatePath, @@ -320,6 +322,12 @@ export const INTEGRATION_CLIENTS: Record commandCodeConfigPath(env, home), + detectDir: (env = process.env, home = homedir()) => commandCodeHomeDir(env, home), + writerLock: { suffix: ".lock" }, + }, prime: { id: "prime", configPath: (env = process.env, home = homedir()) => primeConfigPath(env, home), diff --git a/src/integrations/state.ts b/src/integrations/state.ts index 1e396eb8659..20eacb63669 100644 --- a/src/integrations/state.ts +++ b/src/integrations/state.ts @@ -462,7 +462,8 @@ export function exportContextOf(input: { models: readonly ExportModel[]; config: OcxConfig; port: number; -}): { baseUrl: string; models: readonly ExportModel[]; config: OcxConfig } { + document?: unknown; +}): { baseUrl: string; models: readonly ExportModel[]; config: OcxConfig; document?: unknown } { return { /* * Composed through the SAME helper `ocx export` uses. Interpolating the @@ -476,6 +477,9 @@ export function exportContextOf(input: { baseUrl: opencodeProxyBaseUrl(input.port, input.config.hostname, input.config), models: input.models, config: input.config, + // Forwarded only when the caller parsed a target: a client that resolves + // between two document roots (Command Code) needs the bytes on disk. + ...(input.document === undefined ? {} : { document: input.document }), }; } diff --git a/structure/clients/integrations.md b/structure/clients/integrations.md index 117a1fc8b67..ce23894d0bb 100644 --- a/structure/clients/integrations.md +++ b/structure/clients/integrations.md @@ -460,6 +460,31 @@ holds the single ownership record. Committing the older row's prior record would ownership back at the old file and leave the active block on disk with nothing to disable it. +## Command Code + +Command Code owns one block in `~/.commandcode/providers.json`, the store its +published client opens: `homeDir15()` returns `env.HOME ?? env.USERPROFILE`, and the +path is that plus `/.commandcode/providers.json`. That variable does not appear +anywhere in the shipped `command-code@1.66.0` bundle, so `commandCodeHomeDir` takes no +override — honouring one would let Apply report success at a path no Command Code +process opens, and the user would see an empty model list with no error anywhere. + +The client's reader is `const o = e.provider ?? e.providers`, so the singular root +wins whenever it exists. `commandCodeProviderRoot` mirrors that grammar against the +parsed target document and `buildCommandCodeContribution` writes into whichever root +the target already uses; a fresh file gets the singular root. Both roots are declared +in `CLIENT_MANAGED_PATHS`, so Disable removes the block from either one. A target +carrying both roots is left in the shape the client resolves, and only the root it +reads is modified. + +The provider is written with `apiKey: false`, the documented keyless form. The +published credential check accepts a `$VAR` / `{env:VAR}` / `!command` reference or +`false` and refuses a raw string, so no service-token file is read, referenced, or +exported. Loopback-only, like the other `api: "openai-completions"` clients. +`tests/clients/command-code-client-contract.test.ts` states the client-side +expressions it is proving and exercises the before/after lifecycle through a real +file. + ## Cline paired files Cline CLI uses `providers.json` for connection settings and sibling `models.json` for its diff --git a/tests/clients/command-code-client-contract.test.ts b/tests/clients/command-code-client-contract.test.ts new file mode 100644 index 00000000000..220343e23b5 --- /dev/null +++ b/tests/clients/command-code-client-contract.test.ts @@ -0,0 +1,156 @@ +/** + * Client-contract regression for the Command Code integration. + * + * The two cases below were found by running the PUBLISHED consumer + * (`command-code@1.66.0`) against our exported provider, not by reading our own + * output. Each test carries the client-side expression it is proving, taken from + * `dist/cli.mjs` of that release: + * + * - root selection: `const o = e.provider ?? e.providers;` + * - home resolution: `homeDir15(e) => e.env().HOME ?? e.env().USERPROFILE`, + * then `${home}/.commandcode/providers.json` + * - credential form: a raw string is refused; a `$ENV` / `{env:VAR}` / `!command` + * reference, or `false` for a keyless endpoint, is accepted + * + * The parser is reproduced here as `publishedCommandCodeRoot`, not imported, so + * the test states the contract explicitly and fails loudly if we ever contradict + * it. `COMMANDCODE_HOME` does not appear anywhere in the shipped bundle + * (`grep -c COMMANDCODE_HOME dist/cli.mjs` → 0), which is why the path resolver + * takes no override. + */ +import { describe, expect, test } from "bun:test"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { + buildClientContribution, + buildCommandCodeClientConfig, + commandCodeConfigPath, + commandCodeHomeDir, + type ExportContext, +} from "../../src/clients/config-export"; +import { setPath } from "../../src/integrations/merge"; +import { OPENCODE_PROVIDER_ID } from "../../src/clients/config-export/constants"; +import { MANAGED_PATH_TEMPLATES } from "../../src/integrations/mutation-plan"; + +/** `const o = e.provider ?? e.providers;` — verbatim from command-code@1.66.0. */ +function publishedCommandCodeRoot(document: unknown): Record | undefined { + const doc = document as { provider?: unknown; providers?: unknown } | null; + const o = doc?.provider ?? doc?.providers; + return typeof o === "object" && o !== null && !Array.isArray(o) + ? (o as Record) + : undefined; +} + +/** `isApiKeyReference` — a raw secret is not one. */ +function publishedAcceptsApiKey(value: unknown): boolean { + if (value === false) return true; + if (typeof value !== "string") return false; + return /^\$[A-Za-z_][A-Za-z0-9_]*$/.test(value) + || /^\{env:[A-Za-z_][A-Za-z0-9_]*\}$/.test(value) + || value.startsWith("!"); +} + +const CONTEXT: ExportContext = { + baseUrl: "http://127.0.0.1:10100/v1", + models: [ + { namespaced: "anthropic/claude-opus-5", provider: "anthropic", id: "claude-opus-5", contextWindow: 200_000 }, + { namespaced: "openai/gpt-5.6-sol", provider: "openai", id: "gpt-5.6-sol", contextWindow: 922_000 }, + ], +}; + +describe("Command Code client contract (published command-code@1.66.0)", () => { + test("a plural-root target keeps its root, and the client still reads every provider", () => { + // BEFORE: the user's own plural-root document. + const before = { + providers: { + acme: { name: "Acme", api: "openai-completions", baseURL: "https://api.acme.test/v1", apiKey: "$ACME_KEY", models: { "acme/large": {} } }, + }, + }; + + const contribution = buildClientContribution("commandcode", { ...CONTEXT, document: before }); + const after = contribution.fragments.reduce( + (doc, fragment) => setPath(doc, fragment.path, fragment.value), + before, + ); + + // The fragment landed under the root the target already used. + expect(contribution.fragments).toHaveLength(1); + expect(contribution.fragments[0]!.path).toEqual(["providers", OPENCODE_PROVIDER_ID]); + + // THE REGRESSION THIS GUARDS: the published client resolves + // `document.provider ?? document.providers`. If we had written a singular root + // into this document, `provider` would win and the user's `acme` provider would + // still be on disk but invisible to the consumer. + const resolved = publishedCommandCodeRoot(after); + expect(resolved).toBeDefined(); + expect(Object.keys(resolved!).sort()).toEqual([OPENCODE_PROVIDER_ID, "acme"].sort()); + expect(resolved!.acme).toEqual(before.providers.acme); + expect((resolved as Record }>)[OPENCODE_PROVIDER_ID]!.models["openai/gpt-5.6-sol"]).toBeDefined(); + }); + + test("a singular-root target keeps the singular root", () => { + const before = { provider: { legacy: { name: "Legacy", api: "openai-completions", baseURL: "https://legacy.test/v1", apiKey: false, models: {} } } }; + const contribution = buildClientContribution("commandcode", { ...CONTEXT, document: before }); + expect(contribution.fragments[0]!.path).toEqual(["provider", OPENCODE_PROVIDER_ID]); + + const after = contribution.fragments.reduce((doc, f) => setPath(doc, f.path, f.value), before); + const resolved = publishedCommandCodeRoot(after); + expect(Object.keys(resolved!).sort()).toEqual([OPENCODE_PROVIDER_ID, "legacy"].sort()); + }); + + test("a fresh target writes the singular root and the client resolves it", () => { + const doc = buildCommandCodeClientConfig(CONTEXT); + const resolved = publishedCommandCodeRoot(doc); + expect(resolved).toBeDefined(); + expect(Object.keys(resolved!)).toEqual([OPENCODE_PROVIDER_ID]); + }); + + test("both roots are declared as managed paths so disable can remove either", () => { + // `mutation-plan` refuses to publish an undeclared path; if only the singular + // root were declared, a block written under the plural root could never be + // removed again. + expect(MANAGED_PATH_TEMPLATES.commandcode).toEqual([ + ["provider", OPENCODE_PROVIDER_ID], + ["providers", OPENCODE_PROVIDER_ID], + ]); + }); + + test("the exported provider is accepted by the published credential check", () => { + const provider = buildCommandCodeClientConfig(CONTEXT).provider[OPENCODE_PROVIDER_ID]!; + // The literal "opencodex-loopback" this exporter used to write is NOT accepted. + expect(publishedAcceptsApiKey("opencodex-loopback")).toBe(false); + expect(publishedAcceptsApiKey(provider.apiKey)).toBe(true); + }); + + test("the config path ignores COMMANDCODE_HOME, which the client does not read", () => { + const relocated = "/tmp/commandcode-relocated"; + // The client resolves `HOME ?? USERPROFILE` + `/.commandcode/providers.json` + // and never consults COMMANDCODE_HOME, so honouring it would make `enable` + // report success at a path Command Code never opens. + expect(commandCodeHomeDir({ COMMANDCODE_HOME: relocated }, "/home/user")).toBe(join("/home/user", ".commandcode")); + expect(commandCodeConfigPath({ COMMANDCODE_HOME: relocated }, "/home/user")).toBe(join("/home/user", ".commandcode", "providers.json")); + // The real home still resolves the ordinary way. + expect(commandCodeConfigPath({}, homedir())).toBe(join(homedir(), ".commandcode", "providers.json")); + }); + + test("a before/after write through the real path leaves the file parseable by the client", () => { + const dir = mkdtempSync(join(tmpdir(), "cc-contract-")); + const path = join(dir, "providers.json"); + const before = { providers: { acme: { name: "Acme", api: "openai-completions", baseURL: "https://api.acme.test/v1", apiKey: "$ACME_KEY", models: { "acme/large": {} } } } }; + writeFileSync(path, JSON.stringify(before, null, 2), "utf8"); + + const onDisk = JSON.parse(require("node:fs").readFileSync(path, "utf8")) as unknown; + const contribution = buildClientContribution("commandcode", { ...CONTEXT, document: onDisk }); + const after = contribution.fragments.reduce((doc, f) => setPath(doc, f.path, f.value), onDisk); + writeFileSync(path, JSON.stringify(after, null, 2), "utf8"); + + // Re-read exactly as the client does, and confirm nothing the user configured + // was lost or shadowed. + const reloaded = publishedCommandCodeRoot(JSON.parse(require("node:fs").readFileSync(path, "utf8"))); + expect(reloaded).toBeDefined(); + expect(reloaded!.acme).toEqual(before.providers.acme); + expect(reloaded![OPENCODE_PROVIDER_ID]).toBeDefined(); + }); +}); diff --git a/tests/clients/command-code-client.test.ts b/tests/clients/command-code-client.test.ts new file mode 100644 index 00000000000..d082cdc2459 --- /dev/null +++ b/tests/clients/command-code-client.test.ts @@ -0,0 +1,252 @@ +import { describe, expect, test } from "bun:test"; +import { join } from "node:path"; +import { + ClientPathError, + EXPORT_CLIENTS, + LOOPBACK_API_KEY_PLACEHOLDER, + OPENCODE_PROVIDER_ID, + buildClientConfig, + buildClientConfigText, + buildClientContribution, + buildCommandCodeClientConfig, + commandCodeConfigPath, + commandCodeHomeDir, + type CommandCodeGeneratedConfig, + type ExportContext, +} from "../../src/clients/config-export"; +import { INTEGRATION_CLIENTS } from "../../src/integrations/registry"; +import type { OcxConfig } from "../../src/types"; + +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; + +function context(): ExportContext { + return { + baseUrl: "http://127.0.0.1:10100/v1", + config: CONFIG, + models: [ + { namespaced: "anthropic/claude-opus-5", provider: "anthropic", id: "claude-opus-5", contextWindow: 200_000, inputModalities: ["text", "image"] }, + { namespaced: "openai/gpt-5.6-sol", provider: "openai", id: "gpt-5.6-sol", contextWindow: 922_000, reasoningEfforts: ["low", "medium", "high"] }, + { namespaced: "google-antigravity/gemini-3.8-flash", provider: "google-antigravity", id: "gemini-3.8-flash", contextWindow: 1_048_576, reasoningEfforts: ["low", "medium", "high"] }, + { namespaced: "mystery/model", provider: "mystery", id: "model" }, + ], + }; +} + +describe("Command Code client config", () => { + test("generates valid JSON with provider.opencodex block", () => { + const built = buildClientConfigText("commandcode", context()); + expect(built.format).toBe("json"); + expect(JSON.parse(built.text)).toEqual(built.document as never); + }); + + test("adds only provider.opencodex, wired to the loopback proxy", () => { + const document = buildClientConfig("commandcode", context()) as CommandCodeGeneratedConfig; + expect(Object.keys(document)).toEqual(["provider"]); + expect(Object.keys(document.provider)).toEqual([OPENCODE_PROVIDER_ID]); + const provider = document.provider[OPENCODE_PROVIDER_ID]!; + expect(provider.name).toBe("OpenCodex"); + expect(provider.api).toBe("openai-completions"); + expect(provider.baseURL).toBe("http://127.0.0.1:10100/v1"); + expect(provider.apiKey).toBe(false); + }); + + test("emits contextWindow and reasoningEfforts correctly without guessing", () => { + const document = buildClientConfig("commandcode", context()) as CommandCodeGeneratedConfig; + const provider = document.provider[OPENCODE_PROVIDER_ID]!; + const gemini = provider.models["google-antigravity/gemini-3.8-flash"]; + expect(gemini).toBeDefined(); + expect(gemini?.contextWindow).toBe(1_048_576); + expect(gemini?.reasoningEfforts).toEqual(["low", "medium", "high"]); + + const mystery = provider.models["mystery/model"]; + expect(mystery).toBeDefined(); + expect(mystery?.contextWindow).toBeUndefined(); + expect(mystery?.reasoningEfforts).toBeUndefined(); + }); + + test("the contribution owns exactly the provider.opencodex path under its own id", () => { + const contribution = buildClientContribution("commandcode", context()); + expect(contribution.clientId).toBe("commandcode"); + expect(contribution.fragments.map(f => f.path)).toEqual([["provider", OPENCODE_PROVIDER_ID]]); + }); + + test("resolves ~/.commandcode and the documented destination, with no override", () => { + expect(commandCodeHomeDir({}, "/home/u")).toBe(join("/home/u", ".commandcode")); + expect(commandCodeConfigPath({}, "/home/u")).toBe(join("/home/u", ".commandcode", "providers.json")); + // COMMANDCODE_HOME is deliberately ignored: the published client + // (`command-code@1.66.0`) resolves `HOME ?? USERPROFILE` + `/.commandcode` and + // never reads it, so honouring it here would report an enable as successful at + // a path no Command Code process opens. See commandCodeHomeDir's doc comment + // and tests/clients/command-code-client-contract.test.ts. + expect(commandCodeConfigPath({ COMMANDCODE_HOME: "/elsewhere" }, "/home/u")).toBe(join("/home/u", ".commandcode", "providers.json")); + expect(commandCodeConfigPath({ COMMANDCODE_HOME: "relative" }, "/home/u")).toBe(join("/home/u", ".commandcode", "providers.json")); + }); + + test("detects installation by ~/.commandcode, which is where the client looks", () => { + const spec = INTEGRATION_CLIENTS.commandcode; + expect(spec.detectDir({}, "/home/u")).toBe(join("/home/u", ".commandcode")); + expect(spec.detectDir({ COMMANDCODE_HOME: "/elsewhere" } as NodeJS.ProcessEnv, "/home/u")).toBe(join("/home/u", ".commandcode")); + }); + + test("ships as a loopback-only integration with proper export metadata", () => { + const spec = EXPORT_CLIENTS.commandcode; + expect(spec.id).toBe("commandcode"); + expect(spec.filename).toBe("providers.json"); + expect(spec.format).toBe("json"); + expect(spec.loopbackOnly).toBe(true); + }); +}); + +// A routed model reaches this exporter under ONE canonical selector, but the proxy +// publishes it under two interchangeable spellings: the raw selector with inner +// slashes (what /v1/models emits) and the Codex-facing encoded form where inner +// slashes became dashes (what ~/.codex/config.toml stores). Command Code addresses +// models by exact key, so a client that resolves the active model from one surface +// and looks it up in the other writes a config whose own active model is absent +// from its catalog. +describe("Command Code model-key spelling", () => { + test("keys every model by the spelling the client can actually call", () => { + const document = buildCommandCodeClientConfig({ + ...context(), + models: [ + { namespaced: "command-code/deepseek/deepseek-v4.1-flash", provider: "command-code", id: "deepseek/deepseek-v4.1-flash", contextWindow: 1_000_000 }, + ], + }) as CommandCodeGeneratedConfig; + const keys = Object.keys(document.provider[OPENCODE_PROVIDER_ID]!.models); + expect(keys).toContain("command-code/deepseek/deepseek-v4.1-flash"); + }); + + test("never emits two keys that differ only by slash-versus-dash encoding", () => { + const document = buildCommandCodeClientConfig({ + ...context(), + models: [ + { namespaced: "command-code/deepseek/deepseek-v4.1-flash", provider: "command-code", id: "deepseek/deepseek-v4.1-flash", contextWindow: 1_000_000 }, + { namespaced: "command-code/deepseek-deepseek-v4.1-flash", provider: "command-code", id: "deepseek/deepseek-v4.1-flash", contextWindow: 1_000_000 }, + ], + }) as CommandCodeGeneratedConfig; + const keys = Object.keys(document.provider[OPENCODE_PROVIDER_ID]!.models); + const encoded = keys.map(key => key.replaceAll("/", "-")); + expect(new Set(encoded).size).toBe(encoded.length); + }); + + test("collapses a duplicate pair to one entry instead of shipping both", () => { + const document = buildCommandCodeClientConfig({ + ...context(), + models: [ + { namespaced: "command-code/meta/muse-spark-1.3-contributor", provider: "command-code", id: "meta/muse-spark-1.3-contributor", contextWindow: 1_048_576 }, + { namespaced: "command-code/meta-muse-spark-1.3-contributor", provider: "command-code", id: "meta/muse-spark-1.3-contributor", contextWindow: 1_048_576 }, + ], + }) as CommandCodeGeneratedConfig; + const models = document.provider[OPENCODE_PROVIDER_ID]!.models; + const matching = Object.keys(models).filter(key => key.includes("muse-spark-1.3-contributor")); + expect(matching.length).toBe(1); + expect(models[matching[0]!]?.contextWindow).toBe(1_048_576); + }); + + test("keeps genuinely distinct models apart", () => { + const document = buildCommandCodeClientConfig({ + ...context(), + models: [ + { namespaced: "command-code/deepseek/deepseek-v4-pro", provider: "command-code", id: "deepseek/deepseek-v4-pro", contextWindow: 1_000_000 }, + { namespaced: "command-code/deepseek/deepseek-v4.1-flash", provider: "command-code", id: "deepseek/deepseek-v4.1-flash", contextWindow: 1_000_000 }, + ], + }) as CommandCodeGeneratedConfig; + expect(Object.keys(document.provider[OPENCODE_PROVIDER_ID]!.models).length).toBe(2); + }); + + test("keeps two genuinely distinct ids that encode to the same dash form callable", () => { + // The known dangerous pair: vendor/a/b (id with an inner slash) and vendor/a-b + // (id with a literal dash) both encode to vendor/a-b. They are DISTINCT models. + const document = buildCommandCodeClientConfig({ + ...context(), + models: [ + { namespaced: "command-code/vendor/a/b", provider: "command-code", id: "vendor/a/b", contextWindow: 1_000_000 }, + { namespaced: "command-code/vendor/a-b", provider: "command-code", id: "vendor/a-b", contextWindow: 500_000 }, + ], + }) as CommandCodeGeneratedConfig; + const models = document.provider[OPENCODE_PROVIDER_ID]!.models; + // Both must survive: neither is a spelling of the other. + expect(Object.keys(models).length).toBe(2); + expect(models["command-code/vendor/a/b"]?.contextWindow).toBe(1_000_000); + expect(models["command-code/vendor/a-b"]?.contextWindow).toBe(500_000); + }); + + test("folds the same model across spellings regardless of input order", () => { + const build = (order: "raw-first" | "encoded-first") => buildCommandCodeClientConfig({ + ...context(), + models: order === "raw-first" + ? [ + { namespaced: "command-code/deepseek/deepseek-v4.1-flash", provider: "command-code", id: "deepseek/deepseek-v4.1-flash", contextWindow: 1_000_000 }, + { namespaced: "command-code/deepseek-deepseek-v4.1-flash", provider: "command-code", id: "deepseek/deepseek-v4.1-flash", contextWindow: 1_000_000 }, + ] + : [ + { namespaced: "command-code/deepseek-deepseek-v4.1-flash", provider: "command-code", id: "deepseek/deepseek-v4.1-flash", contextWindow: 1_000_000 }, + + { namespaced: "command-code/deepseek/deepseek-v4.1-flash", provider: "command-code", id: "deepseek/deepseek-v4.1-flash", contextWindow: 1_000_000 }, + ], + }) as CommandCodeGeneratedConfig; + const a = build("raw-first"); + const b = build("encoded-first"); + const keysA = Object.keys(a.provider[OPENCODE_PROVIDER_ID]!.models); + const keysB = Object.keys(b.provider[OPENCODE_PROVIDER_ID]!.models); + // One entry either way, and the surviving key is the same (normalizeExportModels + // sorts, so input order cannot change the winner). + expect(keysA.length).toBe(1); + expect(keysB.length).toBe(1); + expect(keysA[0]).toBe(keysB[0]); + // Pin the EXACT compatibility contract: the encoded spelling is the surviving key, + // because that is the form ~/.codex/config.toml stores and what an operator's + // --model reference resolves against. + expect(keysA[0]).toBe("command-code/deepseek-deepseek-v4.1-flash"); + // Metadata is the first occurrence's, never a merge or a max: document that so a + // future metadata-merge policy is a deliberate change, not an accident. + expect(a.provider[OPENCODE_PROVIDER_ID]!.models[keysA[0]!]?.contextWindow).toBe(1_000_000); + // And the serialized bytes are identical regardless of input order. + const rawFirst = buildClientConfigText("commandcode", { + ...context(), + models: [ + { namespaced: "command-code/deepseek/deepseek-v4.1-flash", provider: "command-code", id: "deepseek/deepseek-v4.1-flash", contextWindow: 1_000_000 }, + { namespaced: "command-code/deepseek-deepseek-v4.1-flash", provider: "command-code", id: "deepseek/deepseek-v4.1-flash", contextWindow: 1_000_000 }, + ], + }); + const encodedFirst = buildClientConfigText("commandcode", { + ...context(), + models: [ + { namespaced: "command-code/deepseek-deepseek-v4.1-flash", provider: "command-code", id: "deepseek/deepseek-v4.1-flash", contextWindow: 1_000_000 }, + { namespaced: "command-code/deepseek/deepseek-v4.1-flash", provider: "command-code", id: "deepseek/deepseek-v4.1-flash", contextWindow: 1_000_000 }, + ], + }); + expect(rawFirst.text).toBe(encodedFirst.text); + }); + + test("never normalizes the provider segment", () => { + // Two providers whose names differ around dash handling must never collapse. + const document = buildCommandCodeClientConfig({ + ...context(), + models: [ + { namespaced: "my-provider/model", provider: "my-provider", id: "model", contextWindow: 100 }, + { namespaced: "my_provider/model", provider: "my_provider", id: "model", contextWindow: 200 }, + ], + }) as CommandCodeGeneratedConfig; + const models = document.provider[OPENCODE_PROVIDER_ID]!.models; + expect(Object.keys(models).length).toBe(2); + expect(models["my-provider/model"]?.contextWindow).toBe(100); + expect(models["my_provider/model"]?.contextWindow).toBe(200); + }); + test("preserves distinct ids that collide only after slash encoding", () => { + const document = buildCommandCodeClientConfig({ + ...context(), + models: [ + { namespaced: "command-code/a/b-c", provider: "command-code", id: "a/b-c" }, + { namespaced: "command-code/a-b/c", provider: "command-code", id: "a-b/c" }, + ], + }) as CommandCodeGeneratedConfig; + const models = document.provider[OPENCODE_PROVIDER_ID]!.models; + expect(Object.keys(models)).toEqual(["command-code/a-b/c", "command-code/a/b-c"]); + }); +}); diff --git a/tests/clients/integrations-state.test.ts b/tests/clients/integrations-state.test.ts index 66516cde100..d04310324ce 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, prime, aside, raycast and omo are loopback-only and nobody else is", () => { + test("omp, pi, kimi, gajae, dsh, mcode, zcode, commandcode, prime, aside, raycast, omo and cline are loopback-only and nobody else is", () => { const loopbackOnly = INTEGRATION_CLIENT_IDS.filter(id => isLoopbackOnly(id)); - expect(loopbackOnly).toEqual(["pi", "omp", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast", "omo", "cline", "droid"]); + expect(loopbackOnly).toEqual(["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/integrations-writer.test.ts b/tests/clients/integrations-writer.test.ts index 57311a676db..4c4ac402614 100644 --- a/tests/clients/integrations-writer.test.ts +++ b/tests/clients/integrations-writer.test.ts @@ -806,7 +806,7 @@ describe("apply", () => { }); test("refuses a loopback-only client on a remote bind without denying manual OMP headers", () => { - for (const clientId of ["gajae", "omp", "dsh"] as const) { + for (const clientId of ["gajae", "omp", "dsh", "commandcode"] as const) { const spec = INTEGRATION_CLIENTS[clientId]; mkdirSync(spec.detectDir(TEST_ENV, home), { recursive: true }); const result = applyIntegration(input({ diff --git a/tests/clients/sync-client-integrations.test.ts b/tests/clients/sync-client-integrations.test.ts index 7b65f9dee10..fd6a2bcb9f2 100644 --- a/tests/clients/sync-client-integrations.test.ts +++ b/tests/clients/sync-client-integrations.test.ts @@ -908,7 +908,9 @@ test("the direct ocx sync command refreshes MCode, Pi, Raycast, omo and server-o const start = src.indexOf("sync: async deps =>"); const command = src.slice(start, src.indexOf("v2: async deps =>", start)); expect(command).toContain("refreshOwnedCatalogIntegrations"); - expect(command).toContain('["mcode", "pi", "raycast", "omo", "cline", "droid"]'); + // 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"]'); 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 13979e9b4af..726dd2a226e 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", "prime", "aside", "raycast", "omo", "cline", "droid"]); + expect(loopbackOnly).toEqual(["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", () => { diff --git a/tests/config/client-config-export.test.ts b/tests/config/client-config-export.test.ts index 97bd31148a9..f19c75f40a1 100644 --- a/tests/config/client-config-export.test.ts +++ b/tests/config/client-config-export.test.ts @@ -847,8 +847,8 @@ describe("hub-resolved Fast exports", () => { }); describe("EXPORT_CLIENTS registry", () => { - test("covers exactly the seventeen file-toggle clients", () => { - expect(EXPORT_CLIENT_IDS).toEqual(["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast", "omo", "cline", "kilo", "droid"]); + 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"]); 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 fa709f82902..0e5f78a2952 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -439,6 +439,7 @@ "cline-pass-reasoning-efforts.test.ts": "providers", "cline-provider.test.ts": "providers", "cline-writer.test.ts": "clients", + "command-code-client.test.ts": "clients", "closed-pr-branch-cleanup.test.ts": "ci-workflows", "codebuddy-adapter.test.ts": "providers", "codebuddy-live-acceptance.test.ts": "providers", diff --git a/tests/gui/integrations-invariants.test.ts b/tests/gui/integrations-invariants.test.ts index f172ae9059f..39318ff0adb 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(17); + expect(expected).toHaveLength(18); expect([...INTEGRATION_CLIENT_IDS].sort()).toEqual(expected); expect([...gui.CLIENTS].sort()).toEqual(expected); @@ -209,9 +209,10 @@ describe("the client registries cannot drift apart", () => { expect(INTEGRATION_CLIENTS.dsh.sourcePreservingYaml?.path).toEqual([ "llm-pi-ai", "providers", "opencodex", ]); - expect(INTEGRATION_CLIENT_IDS.filter(id => INTEGRATION_CLIENTS[id].writerLock)).toEqual(["dsh", "mcode", "cline"]); + expect(INTEGRATION_CLIENT_IDS.filter(id => INTEGRATION_CLIENTS[id].writerLock)).toEqual(["dsh", "mcode", "commandcode", "cline"]); expect(INTEGRATION_CLIENTS.dsh.writerLock).toEqual({ suffix: ".lock" }); expect(INTEGRATION_CLIENTS.mcode.writerLock).toEqual({ suffix: ".lock" }); + expect(INTEGRATION_CLIENTS.commandcode.writerLock).toEqual({ suffix: ".lock" }); }); }); @@ -252,6 +253,7 @@ describe("every client survives a full lifecycle", () => { /** A pre-existing user document in each client's own format. */ const SEED: Record = { 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', opencode: '{\n "provider": {\n "mine": { "npm": "keep-me" }\n }\n}\n', pi: '{\n "providers": {\n "mine": { "api": "http://keep-me" }\n }\n}\n',