diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index 5bd17d8072e..78e83640a67 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -1,15 +1,16 @@ --- title: Integrations -description: Connect opencodex to OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, gjc, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent, Aside, Raycast, omo, Cline CLI, Kilo, Command Code and Factory Droid from the dashboard — one switch per client, with a backup taken before every write. +description: Connect opencodex to OpenCode, Pi, Gentle Shell, OMP, Hermes, OpenClaw, Kimi Code, gjc, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent, Aside, Raycast, omo, Cline CLI, Kilo, Command Code and Factory Droid from the dashboard — one switch per client, with a backup taken before every write. --- The **Connect** page writes opencodex's provider block into a client's own config -file, and removes it again. Nineteen clients work this way, each with a switch: +file, and removes it again. Twenty clients work this way, each with a switch: | Client | Config file | Format | When the change takes effect | Credential | |---|---|---|---|---| | OpenCode | `~/.config/opencode/opencode.json` | JSON | next direct launch | `OPENCODEX_OPENCODE_API_KEY` | | Pi | `~/.pi/agent/models.json` | JSON | new sessions | loopback placeholder | +| Gentle Shell | `~/.gentle-shell/agent/models.json` (persistent selected home) | JSON | after restarting Gentle Shell | loopback placeholder | | OMP | `~/.omp/agent/models.yml` | YAML | after restarting OMP | `opencodex-loopback` placeholder | | Hermes | `~/.hermes/config.yaml` | YAML | new sessions | `OPENCODEX_HERMES_API_KEY` | | OpenClaw | `~/.openclaw/openclaw.json` | JSON5 | immediately, on a running gateway | `OPENCODEX_OPENCLAW_API_KEY` | @@ -28,6 +29,36 @@ file, and removes it again. Nineteen clients work this way, each with a switch: | 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`) | | Factory Droid | `~/.factory/settings.json` (`%USERPROFILE%\.factory\settings.json` on Windows) | JSON | immediately via file watching | none — keyless loopback | +## Gentle Shell + +Choose **Connect → Gentle Shell**, or use the same managed-integration commands: + +```bash +ocx export --client gentle-shell --json +ocx integration client enable --client gentle-shell +ocx integration client disable --client gentle-shell +``` + +Gentle Shell has a separate destination and rollback history from Pi. Only +`providers.opencodex` is managed; other providers and preferences stay intact. +Catalog changes refresh an already connected, unchanged block. An existing manually +copied block is a conflict, not permission to overwrite it: inspect the preview and +use the explicit overwrite confirmation only if you intend to replace it. + +The saved `home` in `~/.gentle-shell/config.json` selects the destination. A saved +custom path wins; isolated/default mode uses `GENTLE_SHELL_HOME` or +`~/.gentle-shell/agent`. `GENTLE_SHELL_CONFIG` selects an alternate launcher config. +These environment variables must be set on the **OpenCodex process**, not just in a +later shell. Selectors must be absolute paths; literal `~` and relative paths refuse. +One-off `gentle-shell --home` flags cannot be discovered by a background proxy. + +Link mode or a home aliasing Pi refuses with a Pi-integration remedy; use **Connect → Pi** +for that shared file instead. OpenCodex does not change launcher preferences. An +unreadable/unsafe launcher config refuses; malformed JSON falls back to isolated, +matching the launcher. If you move a connected home, disable it using its original +selectors before reconnecting the new one. Restart Gentle Shell after a catalog update. +This integration retains Pi’s loopback-only export policy and stores no real API key. + "omo" names three products that share the `~/.omo` folder. The **omo** tab manages Pi-based omo (the senpi engine) through `~/.omo/agent/models.json`, as in the table above. Codex-based omo (LazyCodex) gets its own section on the same tab, shown only when LazyCodex is detected, described in diff --git a/gui/src/app-routing.ts b/gui/src/app-routing.ts index 195504dd223..51ec534ee3c 100644 --- a/gui/src/app-routing.ts +++ b/gui/src/app-routing.ts @@ -131,6 +131,7 @@ export const INTEGRATION_TAB_HASHES = [ "integrations/cursor", "integrations/opencode", "integrations/pi", + "integrations/gentle-shell", "integrations/omp", "integrations/hermes", "integrations/openclaw", diff --git a/gui/src/components/apikeys-workspace/client-config-clients.ts b/gui/src/components/apikeys-workspace/client-config-clients.ts index 11b0d35b26e..f33555f7b23 100644 --- a/gui/src/components/apikeys-workspace/client-config-clients.ts +++ b/gui/src/components/apikeys-workspace/client-config-clients.ts @@ -8,13 +8,14 @@ * with EXPORT_CLIENT_IDS by hand; adding a client server-side renders no row * until this tuple changes. */ -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 const CLIENTS = ["qoder", "opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "commandcode", "prime", "aside", "raycast", "omo", "cline", "kilo", "droid", "gentle-shell"] 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", + "gentle-shell": "integrations.tab.gentleShell", omp: "api.clientConfig.clientOmp", hermes: "api.clientConfig.clientHermes", openclaw: "api.clientConfig.clientOpenclaw", diff --git a/gui/src/components/integration-marks.ts b/gui/src/components/integration-marks.ts index 40e7e277f5e..e31c6cc0f99 100644 --- a/gui/src/components/integration-marks.ts +++ b/gui/src/components/integration-marks.ts @@ -47,6 +47,8 @@ export const INTEGRATION_MARKS: Record = { ...NATIVE_MARKS, opencode: CLIENT_MARKS.opencode ?? null, pi: CLIENT_MARKS.pi ?? null, + // No verified square Gentle Shell asset: use its own monogram, not Pi\'s mark. + "gentle-shell": null, omp: CLIENT_MARKS.omp ?? null, hermes: CLIENT_MARKS.hermes ?? null, openclaw: CLIENT_MARKS.openclaw ?? null, diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 9a2031d7c59..d227f543db7 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1558,6 +1558,8 @@ export const de: Record = { "integrations.tab.grok": "Grok Build", "integrations.tab.opencode": "OpenCode", "integrations.tab.pi": "Pi", + "integrations.tab.gentleShell": "Gentle Shell", + "integrations.semantics.gentleShell": "Gentle Shell neu starten, um den Katalog im gespeicherten Verzeichnis zu laden. Für Link-/gemeinsame Verzeichnisse die Pi-Integration verwenden.", "integrations.tab.omp": "OMP", "integrations.tab.hermes": "Hermes", "integrations.tab.openclaw": "OpenClaw", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 981ce38cc44..f4c8cb14b84 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2124,6 +2124,8 @@ export const en = { "integrations.tab.cursor": "Cursor", "integrations.tab.opencode": "OpenCode", "integrations.tab.pi": "Pi", + "integrations.tab.gentleShell": "Gentle Shell", + "integrations.semantics.gentleShell": "Restart Gentle Shell to load the catalog in its saved home. Link/shared homes use the Pi integration.", "integrations.tab.omp": "OMP", "integrations.tab.hermes": "Hermes", "integrations.tab.openclaw": "OpenClaw", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 220f04191d9..69b230dcefc 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2096,6 +2096,8 @@ export const fr: Record = { "integrations.tab.grok": "Grok Build", "integrations.tab.opencode": "OpenCode", "integrations.tab.pi": "Pi", + "integrations.tab.gentleShell": "Gentle Shell", + "integrations.semantics.gentleShell": "Redémarrez Gentle Shell pour charger le catalogue du répertoire enregistré. Pour un répertoire lié ou partagé, utilisez l’intégration Pi.", "integrations.tab.omp": "OMP", "integrations.tab.hermes": "Hermes", "integrations.tab.openclaw": "OpenClaw", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 30f57587e0c..492628ae5ab 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1991,6 +1991,8 @@ export const ja: Record = { "integrations.tab.grok": "Grok Build", "integrations.tab.opencode": "OpenCode", "integrations.tab.pi": "Pi", + "integrations.tab.gentleShell": "Gentle Shell", + "integrations.semantics.gentleShell": "Gentle Shell を再起動すると保存済みホームのカタログが読み込まれます。リンク/共有ホームには Pi 連携を使用してください。", "integrations.tab.omp": "OMP", "integrations.tab.hermes": "Hermes", "integrations.tab.openclaw": "OpenClaw", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 8934db05a8f..5c954a0b749 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1602,6 +1602,8 @@ export const ko: Record = { "integrations.tab.grok": "Grok Build", "integrations.tab.opencode": "OpenCode", "integrations.tab.pi": "Pi", + "integrations.tab.gentleShell": "Gentle Shell", + "integrations.semantics.gentleShell": "저장된 홈의 카탈로그를 불러오려면 Gentle Shell을 재시작하세요. 연결/공유 홈은 Pi 연동을 사용하세요.", "integrations.tab.omp": "OMP", "integrations.tab.hermes": "Hermes", "integrations.tab.openclaw": "OpenClaw", diff --git a/gui/src/i18n/pt.ts b/gui/src/i18n/pt.ts index bcd3b1a50e9..00bdd35829e 100644 --- a/gui/src/i18n/pt.ts +++ b/gui/src/i18n/pt.ts @@ -2108,6 +2108,8 @@ export const pt: Record = { "integrations.tab.cursor": "Cursor", "integrations.tab.opencode": "OpenCode", "integrations.tab.pi": "Pi", + "integrations.tab.gentleShell": "Gentle Shell", + "integrations.semantics.gentleShell": "Reinicie o Gentle Shell para carregar o catálogo no diretório salvo. Para diretórios vinculados ou compartilhados, use a integração Pi.", "integrations.tab.omp": "OMP", "integrations.tab.hermes": "Hermes", "integrations.tab.openclaw": "OpenClaw", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 3a83dc23861..ce65acee994 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2082,6 +2082,8 @@ export const ru: Record = { "integrations.tab.grok": "Grok Build", "integrations.tab.opencode": "OpenCode", "integrations.tab.pi": "Pi", + "integrations.tab.gentleShell": "Gentle Shell", + "integrations.semantics.gentleShell": "Перезапустите Gentle Shell для загрузки каталога из сохранённого каталога. Для связанных или общих каталогов используйте интеграцию Pi.", "integrations.tab.omp": "OMP", "integrations.tab.hermes": "Hermes", "integrations.tab.openclaw": "OpenClaw", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 0e5f2ae96ed..407caf254f7 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2109,6 +2109,8 @@ export const tr: Record = { "integrations.tab.grok": "Grok Build", "integrations.tab.opencode": "OpenCode", "integrations.tab.pi": "Pi", + "integrations.tab.gentleShell": "Gentle Shell", + "integrations.semantics.gentleShell": "Kayıtlı ana dizindeki kataloğu yüklemek için Gentle Shell’i yeniden başlatın. Bağlı/ortak dizinler için Pi entegrasyonunu kullanın.", "integrations.tab.omp": "OMP", "integrations.tab.hermes": "Hermes", "integrations.tab.openclaw": "OpenClaw", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index df7ed575e63..d990a4fbdd1 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -2072,6 +2072,8 @@ export const vi: Record = { "integrations.tab.cursor": "Cursor", "integrations.tab.opencode": "OpenCode", "integrations.tab.pi": "Pi", + "integrations.tab.gentleShell": "Gentle Shell", + "integrations.semantics.gentleShell": "Khởi động lại Gentle Shell để tải danh mục trong thư mục đã lưu. Với thư mục liên kết/dùng chung, hãy dùng tích hợp Pi.", "integrations.tab.omp": "OMP", "integrations.tab.hermes": "Hermes", "integrations.tab.openclaw": "OpenClaw", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 78d2df1fc1c..9ceb31e2635 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -3018,6 +3018,8 @@ export const zhTW: Record = { "integrations.tab.grok": "Grok Build", "integrations.tab.opencode": "OpenCode", "integrations.tab.pi": "Pi", + "integrations.tab.gentleShell": "Gentle Shell", + "integrations.semantics.gentleShell": "重新啟動 Gentle Shell 以載入已儲存目錄中的模型清單。連結或共用目錄請使用 Pi 整合。", "integrations.tab.omp": "OMP", "integrations.tab.hermes": "Hermes", "integrations.tab.openclaw": "OpenClaw", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 82ee6e33e4f..d8b1bf577bc 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1575,6 +1575,8 @@ export const zh: Record = { "integrations.tab.grok": "Grok Build", "integrations.tab.opencode": "OpenCode", "integrations.tab.pi": "Pi", + "integrations.tab.gentleShell": "Gentle Shell", + "integrations.semantics.gentleShell": "重启 Gentle Shell 以加载已保存目录中的模型目录。链接或共享目录请使用 Pi 集成。", "integrations.tab.omp": "OMP", "integrations.tab.hermes": "Hermes", "integrations.tab.openclaw": "OpenClaw", diff --git a/gui/src/pages/integrations/FileIntegrationPage.tsx b/gui/src/pages/integrations/FileIntegrationPage.tsx index 85d6d101c6f..130afd3d1bf 100644 --- a/gui/src/pages/integrations/FileIntegrationPage.tsx +++ b/gui/src/pages/integrations/FileIntegrationPage.tsx @@ -72,6 +72,7 @@ const DISABLE_COPY: ConsequenceCopy = { const SEMANTICS_KEY: Record = { opencode: "integrations.semantics.opencode", pi: "integrations.semantics.pi", + "gentle-shell": "integrations.semantics.gentleShell", omp: "integrations.semantics.omp", hermes: "integrations.semantics.hermes", openclaw: "integrations.semantics.openclaw", @@ -94,6 +95,7 @@ const SEMANTICS_KEY: Record = { const TAB_LABEL_KEY: Record = { opencode: "integrations.tab.opencode", pi: "integrations.tab.pi", + "gentle-shell": "integrations.tab.gentleShell", omp: "integrations.tab.omp", hermes: "integrations.tab.hermes", openclaw: "integrations.tab.openclaw", diff --git a/gui/src/pages/integrations/integration-api.ts b/gui/src/pages/integrations/integration-api.ts index b922ac80333..d72609b29bf 100644 --- a/gui/src/pages/integrations/integration-api.ts +++ b/gui/src/pages/integrations/integration-api.ts @@ -21,6 +21,7 @@ export const FILE_INTEGRATION_CLIENTS = [ "cline", "kilo", "droid", + "gentle-shell", ] as const; export type FileIntegrationClientId = (typeof FILE_INTEGRATION_CLIENTS)[number]; diff --git a/gui/src/pages/integrations/integration-tabs.ts b/gui/src/pages/integrations/integration-tabs.ts index c34b214b126..2ce517ebd84 100644 --- a/gui/src/pages/integrations/integration-tabs.ts +++ b/gui/src/pages/integrations/integration-tabs.ts @@ -40,6 +40,7 @@ export const TABS: readonly TabDefinition[] = [ { id: "cursor", hash: "integrations/cursor", labelKey: "integrations.tab.cursor" }, { id: "opencode", hash: "integrations/opencode", labelKey: "integrations.tab.opencode" }, { id: "pi", hash: "integrations/pi", labelKey: "integrations.tab.pi" }, + { id: "gentle-shell", hash: "integrations/gentle-shell", labelKey: "integrations.tab.gentleShell" }, { id: "omp", hash: "integrations/omp", labelKey: "integrations.tab.omp" }, { id: "hermes", hash: "integrations/hermes", labelKey: "integrations.tab.hermes" }, { id: "openclaw", hash: "integrations/openclaw", labelKey: "integrations.tab.openclaw" }, @@ -62,6 +63,7 @@ export const TABS: readonly TabDefinition[] = [ export const FILE_CLIENTS = new Set([ "opencode", "pi", + "gentle-shell", "omp", "hermes", "openclaw", diff --git a/gui/src/pages/integrations/overview-clients.ts b/gui/src/pages/integrations/overview-clients.ts index f92906001b5..9454a4ca046 100644 --- a/gui/src/pages/integrations/overview-clients.ts +++ b/gui/src/pages/integrations/overview-clients.ts @@ -143,6 +143,7 @@ export interface OverviewSources { const FILE_LABEL_KEY: Record = { opencode: "integrations.tab.opencode", pi: "integrations.tab.pi", + "gentle-shell": "integrations.tab.gentleShell", omp: "integrations.tab.omp", hermes: "integrations.tab.hermes", openclaw: "integrations.tab.openclaw", diff --git a/gui/tests/integration-marks.test.ts b/gui/tests/integration-marks.test.ts index 8b60b94de7d..daed6d91142 100644 --- a/gui/tests/integration-marks.test.ts +++ b/gui/tests/integration-marks.test.ts @@ -30,15 +30,15 @@ test("every Integrations row has a mark that resolves to a committed file", () = /* * A null value is a legitimate answer -- it renders a monogram -- but none of the - * current rows should be taking it. If one does, either an asset was dropped or a + * existing rows should be taking it, except Gentle Shell (no verified square asset). If one does, either an asset was dropped or a * client was added without a mark decision, and both look like a rendering bug * from the outside. */ -test("no Integrations row falls back to a monogram today", () => { +test("only Gentle Shell deliberately falls back to its own monogram", () => { const monogram = Object.entries(INTEGRATION_MARKS) .filter(([, src]) => src === null) .map(([id]) => id); - expect(monogram).toEqual([]); + expect(monogram).toEqual(["gentle-shell"]); }); /* diff --git a/gui/tests/integrations-tab-coverage.test.ts b/gui/tests/integrations-tab-coverage.test.ts index 2caa6b76711..9e38491c0a9 100644 --- a/gui/tests/integrations-tab-coverage.test.ts +++ b/gui/tests/integrations-tab-coverage.test.ts @@ -33,6 +33,11 @@ test("every tab hash is routable, so a tab can actually be reached", () => { expect(unroutable.map(tab => tab.hash)).toEqual([]); }); +test("Gentle Shell has a separate routable Connect tab", () => { + expect(TABS.find(tab => (tab.id as string) === "gentle-shell")?.hash).toBe("integrations/gentle-shell"); + expect(resolveAppHashChange("integrations/pi").replaceTo).toBeNull(); +}); + test("FILE_CLIENTS carries no id the API does not know", () => { const known = new Set(FILE_INTEGRATION_CLIENTS); expect([...FILE_CLIENTS].filter(id => !known.has(id))).toEqual([]); diff --git a/scripts/test-layout/layout-clients.json b/scripts/test-layout/layout-clients.json index 84089ffc1de..2f4ef80fea4 100644 --- a/scripts/test-layout/layout-clients.json +++ b/scripts/test-layout/layout-clients.json @@ -1,4 +1,6 @@ { + "gentle-shell-path.test.ts": "clients", + "gentle-shell-client.test.ts": "clients", "droid-managed-reasoning-defaults.test.ts": "clients", "droid-normalized-reasoning-defaults.test.ts": "clients", "desktop-app-server-shim.test.ts": "clients", diff --git a/src/cli/help.ts b/src/cli/help.ts index 9f20b1c8853..b3df5696d7a 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 (19 clients) + ocx export --client Print a client config wired to the running proxy (20 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 09135d2a4cc..850de05f1e5 100644 --- a/src/cli/integration-plan-dto.ts +++ b/src/cli/integration-plan-dto.ts @@ -27,6 +27,7 @@ export const FILE_INTEGRATION_CLIENTS = [ "kilo", "droid", "qoder", + "gentle-shell", ] as const; export const INTEGRATION_REFUSAL_REASONS: ReadonlySet = new Set([ diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 17adee29ee6..7f871bfa9b1 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -488,13 +488,14 @@ 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, Qoder) 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, Gentle Shell) 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.", "Cline exports a two-document bundle: settings for providers.json and catalog for sibling models.json. Use integration client enable --client cline for a journaled write.", "Droid exports documented customModels for settings.json; use integration client enable --client droid for a journaled write.", + "Gentle Shell uses its persistent selected home; use integration client enable --client gentle-shell for a journaled write, or Pi for link/shared homes.", "The config never contains a real key; it carries a documented env reference or a non-secret loopback placeholder.", "The destination path is printed for merging by hand — ocx never writes your real client config.", ], diff --git a/src/clients/config-export.ts b/src/clients/config-export.ts index 701257e9f18..d2f91959af6 100644 --- a/src/clients/config-export.ts +++ b/src/clients/config-export.ts @@ -20,6 +20,7 @@ * targeting it is the caller's explicit act. */ import { homedir } from "node:os"; +import { gentleShellPaths } from "./gentle-shell-path"; import { existsSync, lstatSync, readFileSync, realpathSync, statSync } from "node:fs"; import { basename, dirname, isAbsolute, join, resolve, win32 } from "node:path"; import { shouldInjectApiAuthHeader, standaloneCodexRoutingTarget } from "../codex/inject"; @@ -1711,6 +1712,21 @@ export const EXPORT_CLIENTS: Record = { buildContribution: buildDroidContribution, loopbackOnly: true, }, + "gentle-shell": { + id: "gentle-shell", + filename: "gentle-shell-models.json", + destination: env => gentleShellPaths(env).configPath, + apiKeyEnv: "", + exportHint: "Gentle Shell uses its persistent selected home; restart the shell to load the catalog. Link/shared homes use the Pi integration.", + build: ctx => buildPiClientConfig(ctx, PI_EXPORT_OPTIONS), + format: "json", + summarize: summarizePi, + buildContribution: ctx => { + const doc = buildPiClientConfig(ctx, PI_EXPORT_OPTIONS); + return singleFragment("gentle-shell", ["providers", OPENCODE_PROVIDER_ID], doc.providers[OPENCODE_PROVIDER_ID]); + }, + loopbackOnly: true, + }, }; export const EXPORT_CLIENT_IDS: readonly ExportClientId[] = Object.keys(EXPORT_CLIENTS) as ExportClientId[]; diff --git a/src/clients/config-export/contracts.ts b/src/clients/config-export/contracts.ts index 75b05ed348e..92337f2f194 100644 --- a/src/clients/config-export/contracts.ts +++ b/src/clients/config-export/contracts.ts @@ -220,7 +220,8 @@ export type ExportClientId = | "cline" | "commandcode" | "kilo" - | "droid"; + | "droid" + | "gentle-shell"; export interface ExportClientSpec { id: ExportClientId; diff --git a/src/clients/gentle-shell-path.ts b/src/clients/gentle-shell-path.ts new file mode 100644 index 00000000000..21158c11f5e --- /dev/null +++ b/src/clients/gentle-shell-path.ts @@ -0,0 +1,87 @@ +import { realpathSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { basename, dirname, isAbsolute, join, resolve } from "node:path"; +import { readBoundedRegularFileSync } from "../lib/bounded-file-read"; +import { ClientPathError, piAgentDir } from "./config-export"; + +interface BootstrapIO { + readText(path: string): { kind: "text"; text: string } | { kind: "missing" } | { kind: "failed" }; +} + +function absolute(raw: string, label: string): string { + // Unlike Pi's resolver, the Shell launcher does not expand a literal '~'. + if (!isAbsolute(raw)) throw new ClientPathError(`${label} must be an absolute path; the proxy cannot infer the launcher's working directory.`); + return resolve(raw); +} + +function readBootstrap(path: string, io?: BootstrapIO): string | undefined { + try { + if (!io) return readBoundedRegularFileSync(path, 64 * 1024).toString("utf8"); + const read = io.readText(path); + if (read.kind === "missing") return undefined; + if (read.kind === "text" && Buffer.byteLength(read.text, "utf8") <= 64 * 1024) return read.text; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + } + throw new ClientPathError(`Cannot read Gentle Shell launcher config at ${path}; no catalog was selected.`); +} + +/** Resolve existing ancestors too, so a not-yet-created catalog cannot hide a directory alias. */ +function canonical(path: string): string { + const missing: string[] = []; + let current = resolve(path); + for (;;) { + try { + const value = join(realpathSync.native(current), ...missing); + return process.platform === "win32" ? value.toLowerCase() : value; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT" || dirname(current) === current) { + throw new ClientPathError(`Cannot verify Gentle Shell destination ${path}.`); + } + missing.unshift(basename(current)); + current = dirname(current); + } + } +} + +function sameFile(left: string, right: string): boolean { + if (canonical(left) === canonical(right)) return true; + // Hard-linked catalogs can have different canonical names but the same identity. + try { + const a = statSync(left, { bigint: true }); + const b = statSync(right, { bigint: true }); + return a.ino !== 0n && a.dev === b.dev && a.ino === b.ino; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw new ClientPathError("Cannot verify Gentle Shell and Pi catalog identities."); + } +} + +/** Persistent selectors only: a proxy cannot observe one-off `gentle-shell --home` flags. */ +export function gentleShellPaths( + env: NodeJS.ProcessEnv = process.env, + home: string = homedir(), + io?: BootstrapIO, +): { configPath: string; detectDir: string } { + const config = env.GENTLE_SHELL_CONFIG + ? absolute(env.GENTLE_SHELL_CONFIG, "GENTLE_SHELL_CONFIG") + : join(home, ".gentle-shell", "config.json"); + const text = readBootstrap(config, io); + let selector: unknown; + try { + const parsed: unknown = text === undefined ? undefined : JSON.parse(text); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) selector = (parsed as Record).home; + } catch { /* The launcher also falls back to isolated for malformed JSON. */ } + const shared = "Gentle Shell uses a Pi destination; use the Pi integration for link/shared homes."; + if (selector === "link") throw new ClientPathError(shared); + const dir = typeof selector === "string" && selector.length > 0 && selector !== "isolated" + ? absolute(selector, "Gentle Shell saved home") + : env.GENTLE_SHELL_HOME + ? absolute(env.GENTLE_SHELL_HOME, "GENTLE_SHELL_HOME") + : join(home, ".gentle-shell", "agent"); + const configPath = join(dir, "models.json"); + const piHomes = [join(home, ".pi", "agent"), piAgentDir(env, home)]; + if (env.GENTLE_SHELL_USER_PI_HOME) piHomes.push(absolute(env.GENTLE_SHELL_USER_PI_HOME, "GENTLE_SHELL_USER_PI_HOME")); + if (piHomes.some(pi => sameFile(configPath, join(pi, "models.json")))) throw new ClientPathError(shared); + return { configPath, detectDir: dir }; +} diff --git a/src/integrations/catalog-refresh.ts b/src/integrations/catalog-refresh.ts index 35347c636c1..3c0ddcbc32d 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", "qoder"], + clientIds: readonly IntegrationClientId[] = ["pi", "aside", "raycast", "omo", "commandcode", "droid", "opencode", "kilo", "qoder", "gentle-shell"], 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 5dc16f47526..6a287fe9b78 100644 --- a/src/integrations/mutation-plan.ts +++ b/src/integrations/mutation-plan.ts @@ -134,6 +134,7 @@ 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]], + "gentle-shell": [["providers", OPENCODE_PROVIDER_ID]], omp: [["providers", OPENCODE_PROVIDER_ID]], hermes: [["providers", OPENCODE_PROVIDER_ID]], openclaw: [["models", "providers", OPENCODE_PROVIDER_ID]], diff --git a/src/integrations/registry.ts b/src/integrations/registry.ts index b988e8c7977..b53228fdaa5 100644 --- a/src/integrations/registry.ts +++ b/src/integrations/registry.ts @@ -9,6 +9,7 @@ * Design of record: devlog/_fin/260802_client_toggle_api/021 §1. */ import { homedir } from "node:os"; +import { gentleShellPaths } from "../clients/gentle-shell-path"; import { assertDroidSettingsUnambiguous } from "./droid-settings"; import { readPath } from "./merge"; import type { OwnershipRecord } from "./ownership"; @@ -505,6 +506,13 @@ export const INTEGRATION_CLIENTS: Record gentleShellPaths(env, home, io).configPath, + detectDir: (env, home, io) => gentleShellPaths(env, home, io).detectDir, + resolvePaths: (env, home, _context, io) => gentleShellPaths(env, home, io), + unresolvedPathHint: (_env, home = homedir()) => join(home, ".gentle-shell", "agent", "models.json"), + }, }; export const INTEGRATION_CLIENT_IDS: readonly IntegrationClientId[] = diff --git a/structure/cli-management.md b/structure/cli-management.md index 0e539b2e1e5..1b8fbba45f1 100644 --- a/structure/cli-management.md +++ b/structure/cli-management.md @@ -16,6 +16,9 @@ replacement health proof: lifecycle decisions, attestation and exit codes stay a and a known supervised target never earns a competing CLI recovery start. Command guard continuity follows [runtime ownership](runtime.md#background-service-runtime-ownership). +The export roster and full help include `gentle-shell`, with a persistent-home and +Pi link/shared-home remedy; this adds a client identity, not a new command or flag. + ## Head and help navigation `src/cli/root.ts`, `src/cli/help.ts`, `src/cli/help-navigation.ts` diff --git a/structure/clients/integrations.md b/structure/clients/integrations.md index 9688b1206c4..1376bc93c1c 100644 --- a/structure/clients/integrations.md +++ b/structure/clients/integrations.md @@ -483,6 +483,27 @@ sibling policy. Profile journal views retain source-store provenance for older l The shared atomic replacement publisher also identifies explicit Remote Workspace file writes as `remote-workspace`; its isolated owner and support limits are documented in [Remote Workspace](../remote-workspace.md). +## Gentle Shell home discovery + +The `gentle-shell` client reuses Pi's serializer/options but stamps its own ownership +identity for `providers.opencodex`. The registry resolves config and detect paths from +one bootstrap read per observation; export uses that same resolver. Owned-catalog +refresh includes this client, while Pi's existing path and behavior remain unchanged. + +`src/clients/gentle-shell-path.ts` resolves the persistent launcher home from +`GENTLE_SHELL_CONFIG` (default `~/.gentle-shell/config.json`) and its `home` selector. +Saved custom paths win over `GENTLE_SHELL_HOME`; isolated/default selection uses that +variable or `~/.gentle-shell/agent`. Malformed JSON follows the launcher's isolated +fallback, but an unreadable, unsafe or oversized bootstrap refuses discovery. +Relative paths (including literal `~`) refuse: a proxy cannot infer another process's +working directory. One-off launcher flags are not observable by this integration. + +Link mode and destinations aliasing Pi's default, configured or preserved original +home refuse with a Pi-integration remedy. Canonical existing ancestors and existing +file identities catch directory aliases and hard-linked catalogs. The resolver never +changes launcher preferences or client files. The existing writer retains responsibility +for target validation, ownership, snapshots and atomic replacement. + ## omo variants "omo" names three products, and each surface here serves exactly one of them: diff --git a/tests/clients/gentle-shell-client.test.ts b/tests/clients/gentle-shell-client.test.ts new file mode 100644 index 00000000000..7560ccbcbd5 --- /dev/null +++ b/tests/clients/gentle-shell-client.test.ts @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildClientConfig, buildClientContribution, type ExportModel } from "../../src/clients/config-export"; +import { refreshOwnedCatalogIntegrations } from "../../src/integrations/catalog-refresh"; +import { previewIntegration } from "../../src/integrations/mutation-plan"; +import { readIntegrationState } from "../../src/integrations/state"; +import { createIntegrationStateStore } from "../../src/integrations/store"; +import { applyIntegration, disableIntegration, restoreIntegration } from "../../src/integrations/writer"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const config: OcxConfig = { + port: 10100, hostname: "127.0.0.1", defaultProvider: "mock", + providers: { mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1:10100/v1" } }, +}; +const models: ExportModel[] = [{ namespaced: "mock/reasoner", provider: "mock", id: "reasoner", + contextWindow: 128000, maxTokens: 8192, reasoningEfforts: ["low", "high"], inputModalities: ["text", "image"] }]; +const original = '{"providers":{"personal":{"models":[]}},"settings":{"keep":true}}\n'; +let home: string; +let path: string; +let pi: string; +let store: ReturnType; +const input = () => ({ clientId: "gentle-shell" as const, config, models, port: 10100, env: {}, home, store }); +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-gentle-connect-")); + path = join(home, ".gentle-shell", "agent", "models.json"); + pi = join(home, ".pi", "agent", "models.json"); + mkdirSync(join(home, ".gentle-shell", "agent"), { recursive: true }); + mkdirSync(join(home, ".pi", "agent"), { recursive: true }); + writeFileSync(path, original); + writeFileSync(pi, original); + store = createIntegrationStateStore(join(home, "state", "integrations")); +}); +afterEach(() => removeTreeWithRetry(home)); + +test("Gentle Shell export reuses Pi protocol and stamps independent ownership", () => { + const ctx = { config, models, baseUrl: "http://127.0.0.1:10100/v1" }; + const document = buildClientConfig("gentle-shell", ctx); + expect(document).toEqual(buildClientConfig("pi", ctx)); + expect(buildClientContribution("gentle-shell", ctx)).toEqual({ + ...buildClientContribution("pi", ctx), clientId: "gentle-shell", + }); +}); + +test("preview, enable, disable and restore preserve Pi and foreign settings", () => { + const preview = previewIntegration(input(), { operation: "apply" }); + expect(preview.canApply).toBe(true); + expect(readFileSync(path, "utf8")).toBe(original); + expect(applyIntegration(input()).ok).toBe(true); + expect(readIntegrationState(input()).state).toBe("current"); + const applied = readFileSync(path, "utf8"); + expect(JSON.parse(applied)).toMatchObject(JSON.parse(original)); + expect(disableIntegration(input()).ok).toBe(true); + expect(JSON.parse(readFileSync(path, "utf8"))).toEqual(JSON.parse(original)); + const operation = store.listOperations("gentle-shell")[0]!; + expect(restoreIntegration({ ...input(), opId: operation.opId }).ok).toBe(true); + expect(readFileSync(path, "utf8")).toBe(applied); + expect(readFileSync(pi, "utf8")).toBe(original); +}); + +test("default refresh touches only owned catalogs and preserves Pi ownership", async () => { + let loads = 0; + expect(await refreshOwnedCatalogIntegrations({ ...input(), models: () => { loads++; return models; } })).toEqual([]); + expect(loads).toBe(0); + expect(applyIntegration(input()).ok).toBe(true); + expect(applyIntegration({ ...input(), clientId: "pi" }).ok).toBe(true); + const piBefore = readIntegrationState({ ...input(), clientId: "pi" }); + const updated = [...models, { namespaced: "mock/new", provider: "mock", id: "new" }]; + expect(await refreshOwnedCatalogIntegrations({ ...input(), models: updated })).toEqual([ + { client: "pi", ok: true, changed: true }, { client: "gentle-shell", ok: true, changed: true }, + ]); + expect(readIntegrationState({ ...input(), models: updated }).state).toBe("current"); + expect(readIntegrationState({ ...input(), clientId: "pi", models: updated }).state).toBe("current"); + expect(piBefore.state).toBe("current"); +}); + +test("foreign edits and changed launcher home refuse without new operations", async () => { + expect(applyIntegration(input()).ok).toBe(true); + const doc = JSON.parse(readFileSync(path, "utf8")); + doc.providers.opencodex.baseUrl = "http://127.0.0.1:1234/v1"; + const edited = JSON.stringify(doc); + writeFileSync(path, edited); + const count = store.listOperations("gentle-shell").length; + expect(await refreshOwnedCatalogIntegrations(input())).toMatchObject([{ client: "gentle-shell", ok: false }]); + expect(readFileSync(path, "utf8")).toBe(edited); + expect(store.listOperations("gentle-shell")).toHaveLength(count); + writeFileSync(join(home, ".gentle-shell", "config.json"), '{"home":"link"}'); + expect(previewIntegration(input(), { operation: "apply" }).canApply).toBe(false); + expect(applyIntegration(input()).ok).toBe(false); + expect(readFileSync(pi, "utf8")).toBe(original); + expect(readFileSync(path, "utf8")).toBe(edited); + expect(store.listOperations("gentle-shell")).toHaveLength(count); +}); diff --git a/tests/clients/gentle-shell-path.test.ts b/tests/clients/gentle-shell-path.test.ts new file mode 100644 index 00000000000..00b2effdb5a --- /dev/null +++ b/tests/clients/gentle-shell-path.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { gentleShellPaths } from "../../src/clients/gentle-shell-path"; +import { piConfigPath } from "../../src/clients/config-export"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +function fixture(run: (home: string, save: (value: unknown) => void) => void): void { + const home = mkdtempSync(join(tmpdir(), "ocx-gentle-shell-")); + mkdirSync(join(home, ".gentle-shell")); + try { + run(home, value => writeFileSync(join(home, ".gentle-shell", "config.json"), JSON.stringify(value))); + } finally { removeTreeWithRetry(home); } +} + +describe("Gentle Shell persistent home", () => { + test("defaults to a separate catalog without moving Pi", () => fixture(home => { + expect(gentleShellPaths({}, home)).toEqual({ + configPath: join(home, ".gentle-shell", "agent", "models.json"), + detectDir: join(home, ".gentle-shell", "agent"), + }); + expect(piConfigPath({}, home)).toBe(join(home, ".pi", "agent", "models.json")); + })); + + test("saved path wins over isolated override; saved isolated honors it", () => fixture((home, save) => { + const env = { GENTLE_SHELL_HOME: join(home, "isolated") }; + save({ home: join(home, "custom") }); + expect(gentleShellPaths(env, home).detectDir).toBe(join(home, "custom")); + save({ home: "isolated" }); + expect(gentleShellPaths(env, home).detectDir).toBe(env.GENTLE_SHELL_HOME); + })); + + test("honors the launcher config override and malformed-config fallback", () => fixture(home => { + const config = join(home, "launcher.json"); + const env = { GENTLE_SHELL_CONFIG: config }; + writeFileSync(config, JSON.stringify({ home: join(home, "selected") })); + expect(gentleShellPaths(env, home).detectDir).toBe(join(home, "selected")); + writeFileSync(config, "{broken"); + expect(gentleShellPaths(env, home).detectDir).toBe(join(home, ".gentle-shell", "agent")); + })); + + test("refuses link mode, Pi destinations, and cwd-dependent selectors", () => fixture((home, save) => { + save({ home: "link" }); + expect(() => gentleShellPaths({}, home)).toThrow("Pi integration"); + save({ home: join(home, ".pi", "agent") }); + expect(() => gentleShellPaths({}, home)).toThrow("Pi integration"); + save({ home: "relative" }); + expect(() => gentleShellPaths({}, home)).toThrow("absolute path"); + save({ home: "isolated" }); + expect(() => gentleShellPaths({ GENTLE_SHELL_HOME: "~/agent" }, home)).toThrow("absolute path"); + expect(() => gentleShellPaths({ GENTLE_SHELL_CONFIG: "relative.json" }, home)).toThrow("absolute path"); + expect(() => gentleShellPaths({ GENTLE_SHELL_HOME: join(home, "pi"), GENTLE_SHELL_USER_PI_HOME: join(home, "pi") }, home)).toThrow("Pi integration"); + expect(() => gentleShellPaths({ GENTLE_SHELL_HOME: join(home, "pi"), PI_CODING_AGENT_DIR: join(home, "pi") }, home)).toThrow("Pi integration"); + })); + + test("refuses a directory alias of Pi even before models.json exists", () => fixture(home => { + const pi = join(home, ".pi", "agent"); + mkdirSync(pi, { recursive: true }); + const alias = join(home, "alias"); + symlinkSync(pi, alias, process.platform === "win32" ? "junction" : "dir"); + expect(() => gentleShellPaths({ GENTLE_SHELL_HOME: alias }, home)).toThrow("Pi integration"); + })); + + test("unreadable bootstrap is uncertainty, never missing", () => fixture(home => { + expect(() => gentleShellPaths({}, home, { readText: () => ({ kind: "failed" }) })).toThrow("Cannot read"); + expect(gentleShellPaths({}, home, { readText: () => ({ kind: "missing" }) }).detectDir) + .toBe(join(home, ".gentle-shell", "agent")); + })); +}); diff --git a/tests/clients/integrations-state.test.ts b/tests/clients/integrations-state.test.ts index 909d5da0d29..2ccc03b4975 100644 --- a/tests/clients/integrations-state.test.ts +++ b/tests/clients/integrations-state.test.ts @@ -796,7 +796,7 @@ describe("installation detection is independent of config state", () => { describe("the loopback-only set is one fact, read through one seam", () => { test("identifies exactly the registered loopback-only clients", () => { const loopbackOnly = INTEGRATION_CLIENT_IDS.filter(id => isLoopbackOnly(id)); - expect(loopbackOnly).toEqual(["qoder", "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", "gentle-shell"]); }); test("the registry restates nothing — it reads the export spec", () => { diff --git a/tests/config/client-config-export.test.ts b/tests/config/client-config-export.test.ts index cc76905bd8c..fc79e1732ea 100644 --- a/tests/config/client-config-export.test.ts +++ b/tests/config/client-config-export.test.ts @@ -851,7 +851,7 @@ describe("hub-resolved Fast exports", () => { describe("EXPORT_CLIENTS registry", () => { 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"]); + expect(EXPORT_CLIENT_IDS).toEqual(["qoder", "opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "commandcode", "prime", "aside", "raycast", "omo", "cline", "kilo", "droid", "gentle-shell"]); 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 674f4756f6d..c3c04b811e9 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -5,6 +5,8 @@ "messages-native-empty-inline.test.ts": "claude-integration", "explicit-empty-tool-catalog.test.ts": "responses", "explicit-empty-tool-catalog-history.test.ts": "responses", + "gentle-shell-path.test.ts": "clients", + "gentle-shell-client.test.ts": "clients", "codex-history-pending-wal.test.ts": "codex-integration", "compaction-history-framing.test.ts": "responses", "service-log.test.ts": "service", diff --git a/tests/gui/integrations-invariants.test.ts b/tests/gui/integrations-invariants.test.ts index a2680b61d6d..8a91ab971d2 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(19); + expect(expected).toHaveLength(20); expect([...INTEGRATION_CLIENT_IDS].sort()).toEqual(expected); expect([...gui.CLIENTS].sort()).toEqual(expected); @@ -266,6 +266,7 @@ describe("every client survives a full lifecycle", () => { dsh: "llm-pi-ai:\n providers:\n mine:\n api: openai-completions\n", mcode: "custom_provider:\n mine:\n name: Keep Me\n", zcode: '{\n "provider": {\n "builtin:zai-start-plan": { "name": "Keep Me", "kind": "anthropic" }\n }\n}\n', + "gentle-shell": '{"providers":{"mine":{"api":"http://keep-me"}}}\n', // Prime reads Pi's models.json contract, so it seeds the same shape. prime: '{\n "providers": {\n "mine": { "api": "http://keep-me" }\n }\n}\n', // Aside reads the same models.json contract as Pi and Prime.