diff --git a/devlog/_plan/260904_raycast_integration/000_plan.md b/devlog/_plan/260904_raycast_integration/000_plan.md new file mode 100644 index 0000000000..c98701a2cd --- /dev/null +++ b/devlog/_plan/260904_raycast_integration/000_plan.md @@ -0,0 +1,121 @@ +# Raycast Custom Providers integration — plan + +Raycast (Pro-only) reads `~/.config/raycast/ai/providers.yaml` and watches it, so a +file-toggle client is the right shape. Spec: https://manual.raycast.com/ai/custom-providers. + +Decisions taken with the maintainer: + +1. Install signal is `~/.config/raycast/ai` (the directory Raycast creates on + "Reveal Providers Config"), not `Raycast.app`. +2. A non-Pro plan is a warning in status/GUI, never a refusal. +3. Every exported model declares `tools: supported: true` (same stance as Hermes: + every routed model is tool-capable). +4. Array ownership goes into the shared merge/classifier layer as a path-segment + selector rather than a Raycast-only patcher. `structure/09_client-integrations.md` + forbids a special case that lives only in the writer or only in status; a + selector segment that `readPath`/`setPath`/`deletePath` all understand is the + one way both keep agreeing. + +## Raycast file shape + +```yaml +providers: + - id: opencodex # <- our one owned sequence item + name: OpenCodex + base_url: http://127.0.0.1:10100/v1 + models: + - id: anthropic/claude-opus-5 + name: Claude Opus 5 + context: 200000 + abilities: + temperature: { supported: true } + vision: { supported: true } + system_message: { supported: true } + tools: { supported: true } + reasoning_effort: { supported: false } +``` + +No `api_keys`: loopback is unauthenticated and the file has no env interpolation, +so the client is `loopbackOnly: true`. + +## Pro signal (macOS) + +`defaults read com.raycast.macos.v1 subscriptions_active` → `1` / `0`. Read via +`Bun.spawnSync`, not by parsing the binary plist (cfprefsd caches). Windows: `unknown`. + +## Work packages (disjoint files, run in parallel) + +| WP | Files | +|---|---| +| 1 merge selector | `src/integrations/merge.ts`, `src/integrations/state.ts`, `tests/integrations-merge.test.ts` | +| 2 client | `src/clients/config-export.ts`, `src/integrations/registry.ts`, `src/cli/registry.ts`, `src/cli/help.ts`, `tests/raycast-client.test.ts`, list-assertion tests | +| 3 sync fan-out | `src/integrations/owned-refresh.ts`, `src/cli/dispatch.ts`, `src/server/management/config-routes.ts`, `src/cli/index.ts`, `tests/sync-client-integrations.test.ts` | +| 4 detect + API + GUI | `src/integrations/raycast-detect.ts`, `src/server/management/integration-routes.ts`, `src/cli/integrations.ts`, `gui/**`, i18n | +| 5 docs | `docs-site/**` | + +### WP1 — `[field=value]` path segment + +```ts +// merge.ts +const ARRAY_SELECTOR = /^\[([A-Za-z_][A-Za-z0-9_]*)=([^\]]+)\]$/u; +export type PathSegment = { kind: "key"; key: string } | { kind: "select"; field: string; value: string }; +export function parseSegment(raw: string): PathSegment; +export class AmbiguousSelectorError extends Error {} +``` + +- `setPath`: a `select` segment addresses the element of an array whose + `item[field] === value`. Missing parent → `[]` is created (recorded by + `createdContainerPaths`). Match found → replace in place; none → push; ≥2 → + throw `AmbiguousSelectorError` (writer maps it to `unsafe` alongside + `UnserializableValueError`). +- `deletePath`: splice the match; an emptied array we created is pruned by the + existing `createdContainers` walk. +- `state.ts readPath`: `select` → `Array.prototype.find`. Because the classifier + and the writer share this one function, status and mutation cannot disagree. +- `blockedContainerPath`: a non-array, non-undefined value where a `select` + segment expects an array is blocked (`providers: {}` written by the user). +- `createdContainerPaths`: unchanged join rule; a `select` segment is never a + container prefix on its own. +- A key-only path is byte-for-byte the old behaviour; the twelve existing clients + do not change. + +### WP2 — client registration + +`config-export.ts`: `"raycast"` in `ExportClientId`; `raycastAiDir(env, home)` = +`join(home, ".config", "raycast", "ai")` (Raycast ignores XDG; same path on Windows); +`raycastConfigPath` = `…/providers.yaml`; types `RaycastAbility`, +`RaycastModelEntry`, `RaycastProviderEntry`, `RaycastGeneratedConfig`; +`buildRaycastClientConfig(ctx)` over `normalizeExportModels(ctx.models)` with +`exportModelLabel(model)` as `name`, `contextWindow` → `context`, abilities: +`temperature: !(reasoningEfforts?.length)`, `vision: inputModalities?.includes("image") ?? false`, +`system_message: true`, `tools: true`, `reasoning_effort: (reasoningEfforts?.length ?? 0) > 0`. +`buildRaycastContribution` = `singleFragment("raycast", ["providers", "[id=opencodex]"], providers[0])`. +`summarizeRaycast` finds the `opencodex` item. `EXPORT_CLIENTS.raycast`: +`filename: "raycast-providers.yaml"`, `format: "yaml"`, `apiKeyEnv: ""`, `loopbackOnly: true`. + +`registry.ts`: `configPath: raycastConfigPath`, `detectDir: raycastAiDir`, no +`sourcePreservingYaml` (that patcher handles block-map leaves only), no `writerLock`. + +### WP3 — sync fan-out + +Raycast joins the shared `refreshOwnedCatalogIntegrations` coordinator. Model +selection changes use its default `["pi", "aside", "raycast"]` set; +`POST /api/sync` uses `["mcode", "pi", "aside", "raycast"]`; direct CLI sync +updates `["mcode", "pi", "raycast"]` locally and keeps Aside behind its +server-owned multi-profile route. Startup and ensure refresh the owned Raycast +catalog after the Codex catalog publishes, using the live port. + +### WP4 — detection, API, GUI + +`raycast-detect.ts` mirrors `cursor-detect.ts` (injectable deps, read-only): +`RaycastPlan = "pro" | "free" | "unknown"`, `detectRaycast(deps)` → +`{ appPath, aiDirPresent, plan }`. `GET /api/client-integrations/raycast` +adds `raycast: { plan, appPath, aiDirPresent }` to the envelope (only for this +client). `ocx integration client status --client raycast` prints `plan`. GUI: +every surface in `devlog/_fin/260831_aside_client_and_integrations_ux/002_registration_checklist.md` +plus one `RaycastPlanNotice` shown when `plan !== "pro"` or `!aiDirPresent`. + +### WP5 — docs + +`guides/integrations.md` row + paragraph (Pro, reveal-first), `reference/cli/agents.md`, +translated locales, `bun run build` in `docs-site`. diff --git a/docs-site/public/pr-screenshots/raycast-integration.png b/docs-site/public/pr-screenshots/raycast-integration.png new file mode 100644 index 0000000000..e17261c158 Binary files /dev/null and b/docs-site/public/pr-screenshots/raycast-integration.png differ diff --git a/docs-site/src/content/docs/fr/guides/integrations.md b/docs-site/src/content/docs/fr/guides/integrations.md index c65531a4d3..c70d471f39 100644 --- a/docs-site/src/content/docs/fr/guides/integrations.md +++ b/docs-site/src/content/docs/fr/guides/integrations.md @@ -1,10 +1,10 @@ --- title: Intégrations -description: Connectez opencodex à OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness et MiniMax Code depuis le tableau de bord — un commutateur par client, avec une sauvegarde avant chaque écriture. +description: Connectez opencodex à OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness, MiniMax Code et Raycast depuis le tableau de bord — un commutateur par client, avec une sauvegarde avant chaque écriture. --- L'onglet **Intégrations** écrit le bloc fournisseur d'opencodex dans le fichier de configuration du client, -puis peut le retirer. Neuf clients fonctionnent ainsi, chacun avec son propre commutateur : +puis peut le retirer. Dix clients fonctionnent ainsi, chacun avec son propre commutateur : | Client | Fichier de configuration | Format | Prise d'effet de la modification | Identifiant | |---|---|---|---|---| @@ -17,6 +17,7 @@ puis peut le retirer. Neuf clients fonctionnent ainsi, chacun avec son propre co | Gajae Code | `~/.gjc/agent/models.yml` | YAML | dans les nouvelles sessions ou à l'ouverture de `/model` |`OPENCODEX_GAJAE_API_KEY` | | DeepSeek Harness (DSH) | `$DSH_HOME/settings.yaml` (`~/.dsh/settings.yaml` par défaut) | YAML | rechargement à chaud | jeton porteur fictif et non secret pour le bouclage | | MiniMax Code | `~/.minimax/config.yaml` | YAML | dans les nouvelles sessions ou après l’ouverture du sélecteur de modèles | valeur fictive de bouclage | +| Raycast | `~/.config/raycast/ai/providers.yaml` | YAML | immédiatement à l'enregistrement — Raycast surveille le fichier | aucun — bouclage uniquement | La prise en charge gérée de DSH exige au minimum **DSH 0.1.0-rc.6**. OpenCodex ne possède que le fragment `llm-pi-ai.providers.opencodex` : **Appliquer** et **Actualiser** remplacent ce fragment, **Désactiver** ne @@ -33,6 +34,27 @@ L’actualisation de l’intégration met également à jour les fenêtres de co d’effort de raisonnement faisant autorité ; les capacités inconnues sont omises et l’effort courant, qui appartient à la session MCode, est préservé. +Raycast a deux prérequis. Les fournisseurs personnalisés (Custom Providers) sont une fonctionnalité +**Raycast Pro** : avec un forfait gratuit, le fichier est tout de même écrit, mais +`ocx integration client status --client raycast` et la page Intégrations signalent un avertissement, +car Raycast ne le lira pas. Et Raycast ne crée son dossier `ai` que lorsque vous ouvrez une fois +Raycast → Settings → AI → **Reveal Providers Config** ; opencodex utilise ce dossier comme signal +d'installation et indique que le client n'est pas installé tant qu'il n'existe pas. Raycast lit +`~/.config/raycast/ai/providers.yaml` aussi bien sur macOS que sur Windows et n'honore pas +`XDG_CONFIG_HOME` ; ce chemin ne peut donc pas être déplacé. + +Le bloc géré est un seul élément, `id: opencodex`, dans la séquence `providers` du fichier : +`name: OpenCodex`, `base_url: http://:/v1`, et chaque modèle routé avec ses `abilities` — +`tools` et `system_message` sont toujours pris en charge, `vision` suit les modalités d'entrée du +catalogue, `reasoning_effort` est défini lorsque le modèle dispose d'une échelle d'effort, et +`temperature` est désactivé pour les modèles de raisonnement. Les autres fournisseurs du fichier sont +préservés, et la désactivation ne retire que l'élément OpenCodex. Raycast prend en compte la +modification dès l'enregistrement du fichier, sans redémarrage ; les modèles apparaissent dans le +sélecteur de modèles de Raycast regroupés sous **OpenCodex**. Le fichier n'a aucun emplacement pour +un identifiant, ce client est donc limité au bouclage : aucune entrée `api_keys` n'est écrite et une +liaison hors bouclage est refusée. Le format est documenté sur +[manual.raycast.com/ai/custom-providers](https://manual.raycast.com/ai/custom-providers). + Les chemins respectent les variables de remplacement propres à chaque client, lorsqu'elles existent. Pour OMP, la présence de `OMP_PROFILE` l'emporte sur `PI_PROFILE`, même si sa valeur est explicitement vide. Un profil nommé emploie `PI_CONFIG_DIR` comme nom de répertoire relatif au dossier personnel de l'utilisateur @@ -93,7 +115,7 @@ niveaux. Dans ces cas, le commutateur est verrouillé afin que rien ne soit modi **OMP** n'est pas affecté non plus par les modifications voisines, mais pour une autre raison : son outil d'écriture ne modifie, octet par octet, que sa propre plage `providers.opencodex` ; le reste du fichier n'est jamais réécrit. Pour les autres formats susceptibles de contenir des commentaires (Hermes, OpenClaw, -Kimi Code, Gajae Code et MiniMax Code — documents YAML, JSON5 et TOML réécrits en entier), ou lorsque les propres entrées +Kimi Code, Gajae Code, MiniMax Code et Raycast — documents YAML, JSON5 et TOML réécrits en entier), ou lorsque les propres entrées d'opencodex ont été modifiées, le commutateur se verrouille et la désactivation est refusée plutôt que de deviner quelles modifications vous appartiennent. @@ -169,9 +191,11 @@ ocx integration client enable --client mcode ocx mcode ``` -Une fois l’intégration connectée, `ocx sync` actualise également le bloc MCode géré avec les fenêtres de -contexte et les niveaux d’effort de raisonnement actuels. Les blocs absents, modifiés par un tiers, non sûrs -ou jamais gérés restent intacts ; réactivez explicitement l’intégration lorsque vous souhaitez la reconnecter. +Une fois l’intégration connectée, `ocx sync` et `POST /api/sync` actualisent les catalogues MCode, +Pi, Aside et Raycast gérés. Le démarrage du proxy actualise aussi le catalogue Raycast géré. +Les changements de visibilité, de fournisseur ou de préréglage actualisent Pi, Aside et Raycast. +Les blocs absents, modifiés par un tiers, non sûrs ou supprimés manuellement restent intacts ; +réactivez explicitement l’intégration lorsque vous souhaitez la reconnecter. Le CLI distinct de la plateforme MiniMax (`mmx`) n’est pas une intégration à commutateur de fichier. Ses commandes textuelles utilisent le point de terminaison compatible avec Anthropic de MiniMax ; OpenCodex diff --git a/docs-site/src/content/docs/fr/reference/cli/agents.md b/docs-site/src/content/docs/fr/reference/cli/agents.md index e1501048c6..749119f70a 100644 --- a/docs-site/src/content/docs/fr/reference/cli/agents.md +++ b/docs-site/src/content/docs/fr/reference/cli/agents.md @@ -164,7 +164,7 @@ Gérez et appliquez la clôture du modèle Grok Build. ## Exportation de la configuration client -### `ocx export --client ` +### `ocx export --client ` Imprimez une configuration client connectée au proxy en cours d'exécution. La commande sérialise le bloc fournisseur `opencodex` — URL de base, liste de modèles et référence d’identifiant du client @@ -175,7 +175,7 @@ les modèles Codex peuvent actuellement voir. | Option | Actions | | --- | --- | -| `--client ` | Requis. Sélectionne le dialecte de configuration client. | +| `--client ` | Requis. Sélectionne le dialecte de configuration client. | | `--json` | Imprimez le document généré en tant que JSON sur la sortie standard pour les scripts. Il s'agit de JSON même lorsque le format natif du client sélectionné est YAML, TOML ou JSON5. | | `--out ` | Écrivez le format de configuration natif du client dans ``. Refuse de remplacer un fichier existant. | | `--force` | Autoriser `--out` à remplacer un fichier existant. | @@ -205,6 +205,17 @@ propres valeurs par défaut à ces lignes. | `mcode` | `~/.minimax/config.yaml` (`MINIMAX_DATA_DIR`, puis l'ancien `MAVIS_DATA_DIR`, l'emportent une fois définis ; une valeur relative est refusée) | `mcode-config.yaml` | aucun — espace réservé de bouclage | | `zcode` | `~/.zcode/v2/config.json` (`ZCODE_DATA_DIR` l'emporte une fois défini ; une valeur relative est refusée) | `config.json` | aucun — espace réservé de bouclage | | `prime` | `~/.prime/agent/models.json` (`PRIME_AGENT_CODING_AGENT_DIR` l'emporte une fois défini ; une valeur relative est refusée) | `prime-models.json` | aucun — espace réservé de bouclage | +| `raycast` | `~/.config/raycast/ai/providers.yaml`, sur macOS comme sur Windows (Raycast n'honore pas `XDG_CONFIG_HOME`) | `raycast-providers.yaml` | aucun — bouclage uniquement, aucune entrée `api_keys` n'est écrite | + +L'exportation Raycast est un document `providers.yaml` autonome contenant un seul élément `id: opencodex` +dans la séquence `providers` : `name: OpenCodex`, l'URL de base `/v1` du proxy et chaque modèle routé avec +ses `abilities` (`tools` et `system_message` toujours pris en charge, `vision` d'après les modalités d'entrée +du catalogue, `reasoning_effort` lorsque le modèle dispose d'une échelle d'effort, `temperature` désactivé +pour les modèles de raisonnement). Les fournisseurs personnalisés sont une fonctionnalité Raycast Pro, et +Raycast surveille le fichier : une modification enregistrée prend effet sans redémarrage. Le format est +documenté sur [manual.raycast.com/ai/custom-providers](https://manual.raycast.com/ai/custom-providers). +Aucune entrée `api_keys` n'est écrite ; cette exportation est donc limitée au bouclage et une liaison hors +bouclage est refusée. L'exportation DSH gérée nécessite DSH 0.1.0-rc.6 ou plus récent et ne possède que `llm-pi-ai.providers.opencodex`. DSH recharge à chaud ce fournisseur ; le modèle par défaut de l'utilisateur et diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index 0c95908206..bb8cea7001 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -1,10 +1,10 @@ --- title: Integrations -description: Connect opencodex to OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent and Aside from the dashboard — one switch per client, with a backup taken before every write. +description: Connect opencodex to OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent, Aside and Raycast from the dashboard — one switch per client, with a backup taken before every write. --- The **Integrations** tab writes opencodex's provider block into a client's own config -file, and removes it again. Twelve clients work this way, each with a switch: +file, and removes it again. Thirteen clients work this way, each with a switch: | Client | Config file | Format | When the change takes effect | Credential | |---|---|---|---|---| @@ -20,6 +20,7 @@ file, and removes it again. Twelve clients work this way, each with a switch: | Prime Agent | `~/.prime/agent/models.json` | JSON | new sessions | loopback placeholder | | ZCode | `~/.zcode/v2/config.json` | JSON | on restart | loopback placeholder | | Aside | `~/.aside/u//models.json` | JSON | after fully quitting and reopening Aside | loopback placeholder | +| Raycast | `~/.config/raycast/ai/providers.yaml` | YAML | immediately on save — Raycast watches the file | none — loopback only | Generated catalogs include only enabled models from each provider selection. This applies to both downloads and managed integrations, including Pi and Aside. The management model list still shows @@ -61,6 +62,28 @@ One caveat specific to Aside: the running app rewrites `models.json` itself, so fully quit and reopen Aside after applying, the same way Claude Desktop needs a restart. Aside's block is loopback-only and never carries a real credential. +Raycast has two prerequisites. Custom Providers is a **Raycast Pro** feature: on a +free plan the file is still written, but `ocx integration client status --client +raycast` and the Integrations page report a warning, because Raycast will not +read it. And Raycast only creates its `ai` folder when you open Raycast → +Settings → AI → **Reveal Providers Config** once; opencodex uses that folder as +the install signal and reports the client as not installed until then. Raycast +reads `~/.config/raycast/ai/providers.yaml` on macOS and Windows alike and does +not honor `XDG_CONFIG_HOME`, so that path is not relocatable. + +The managed block is one element, `id: opencodex`, in the file's `providers` +sequence: `name: OpenCodex`, `base_url: http://:/v1`, and every +routed model with its `abilities` — `tools` and `system_message` are always +supported, `vision` follows the catalog's input modalities, `reasoning_effort` +is set when the model has an effort ladder, and `temperature` is turned off for +reasoning models. Other providers in the file are preserved, and disable removes +only the OpenCodex element. Raycast picks up the change as soon as the file is +saved, no restart needed; the models appear in Raycast's model picker grouped +under **OpenCodex**. The file has no place for a credential, so this client is +loopback-only: no `api_keys` entry is written and a non-loopback bind is refused. +The format is documented at +[manual.raycast.com/ai/custom-providers](https://manual.raycast.com/ai/custom-providers). + Cursor has a tab but is not one of these switches. Regular Cursor calls custom endpoints from its own backend, so a loopback proxy is unreachable without a public tunnel, and Cursor's separate Private Inference build is configured inside Cursor. The **Cursor** tab is read-only: @@ -130,7 +153,7 @@ than 1000 levels — which locks the switch instead, so nothing is silently chan **OMP** is unaffected by sibling edits too, for a different reason: its writer patches only its own `providers.opencodex` range byte-wise, so the rest of the file is never rewritten. For the remaining formats that can carry comments -(Hermes, OpenClaw, Kimi Code, Gajae Code, MiniMax Code — YAML, JSON5 and TOML +(Hermes, OpenClaw, Kimi Code, Gajae Code, MiniMax Code, Raycast — YAML, JSON5 and TOML written as whole documents), or whenever our own entries were edited, the switch locks and disable refuses rather than guessing which edits were yours. @@ -216,10 +239,12 @@ ocx integration client enable --client mcode ocx mcode ``` -Once connected, `ocx sync` refreshes owned MCode, Pi, and Aside catalogs with the current -model selection, context windows, and reasoning-effort ladders. Changes to model visibility, -provider selection, or presets also refresh connected Pi and Aside catalogs. Foreign-edited -or unsafe blocks stay untouched, as do previously owned blocks you removed manually. +Once connected, `ocx sync` and `POST /api/sync` refresh owned MCode, Pi, Aside, and +Raycast catalogs with the current model selection, context windows, and reasoning-effort +ladders. Proxy startup refreshes an owned Raycast catalog. Changes to model visibility, +provider selection, or presets also refresh connected Pi, Aside, and Raycast catalogs. +Missing, foreign-edited, or unsafe blocks stay untouched, as do previously owned blocks +you removed manually. An enabled Aside profile is an exception to the usual owned-only refresh: if its account directory exists and it has never had an owned block, sync may create its first block when that slot is empty. A prior Aside connection enables this behavior for all registered diff --git a/docs-site/src/content/docs/ja/reference/cli/agents.md b/docs-site/src/content/docs/ja/reference/cli/agents.md index cd1b4fa30f..a223362a56 100644 --- a/docs-site/src/content/docs/ja/reference/cli/agents.md +++ b/docs-site/src/content/docs/ja/reference/cli/agents.md @@ -125,7 +125,7 @@ Grok Build モデル フェンスを管理および適用します。 ## クライアント設定のエクスポート -### `ocx export --client ` +### `ocx export --client ` 実行中のプロキシに接続するクライアント設定を出力します。このコマンドは、ベース URL、モデル一覧、およびクライアントに応じた認証情報参照または `opencodex-loopback` プレースホルダーを含む `opencodex` プロバイダーブロックを、選択したクライアントのネイティブ形式でシリアル化します。 @@ -133,7 +133,7 @@ Grok Build モデル フェンスを管理および適用します。 |旗 |アクション | | --- | --- | -| `--client ` |必須。クライアントの設定形式を選択します。 | +| `--client ` |必須。クライアントの設定形式を選択します。 | | `--json` |構成 JSON のみを標準出力に出力するため、リダイレクトはバイト正確な出力をキャプチャします。 `--out` 書き込みメモを含むすべての診断は stderr に送られます。 | | `--out ` |設定を `` に書き込みます。既存のファイルの置き換えを拒否します。 | | `--force` | `--out` が既存のファイルを置き換えることを許可します。 | @@ -160,6 +160,9 @@ ocx export --client opencode --out ~/opencodex-opencode.json | `mcode` | `~/.minimax/config.yaml` (`MINIMAX_DATA_DIR`、次に旧 `MAVIS_DATA_DIR` が設定時に優先。相対値は拒否されます) | `mcode-config.yaml` | なし — loopback placeholder | | `zcode` | `~/.zcode/v2/config.json` (`ZCODE_DATA_DIR` が設定時に優先。相対値は拒否されます) | `config.json` | なし — loopback placeholder | | `prime` | `~/.prime/agent/models.json` (`PRIME_AGENT_CODING_AGENT_DIR` が設定時に優先。相対値は拒否されます) | `prime-models.json` | なし — loopback placeholder | +| `raycast` | `~/.config/raycast/ai/providers.yaml` (macOS と Windows で同じ。Raycast は `XDG_CONFIG_HOME` を尊重しません) | `raycast-providers.yaml` | なし — loopback のみ。`api_keys` エントリは書き込まれません | + +Raycast のエクスポートは、`providers` シーケンスに `id: opencodex` 要素を 1 つだけ持つ独立した `providers.yaml` 文書です。内容は `name: OpenCodex`、プロキシの `/v1` ベース URL、および `abilities` 付きのルーティング済み全モデルです (`tools` と `system_message` は常にサポート、`vision` はカタログの入力モダリティから、`reasoning_effort` はモデルに effort ラダーがある場合、`temperature` は推論モデルではオフ)。Custom Providers は Raycast Pro の機能で、Raycast はこのファイルを監視しているため、保存した変更は再起動なしで反映されます。形式は [manual.raycast.com/ai/custom-providers](https://manual.raycast.com/ai/custom-providers) に記載されています。`api_keys` エントリは書き込まれないため、このエクスポートは loopback 専用で、loopback 以外のバインドは拒否されます。 opencode は `{env:OPENCODEX_OPENCODE_API_KEY}` を補間します。opencodex が生成する Pi のエクスポートには環境変数が不要で、リテラルのプレースホルダー `opencodex-loopback` が入ります。この値は必須です。Pi はモデル リストを構築する際に `apiKey` を解決し、既存の設定に未設定の環境変数参照がある場合はプロバイダー全体を隠すためです。ループバックでは、生成されたプレースホルダーをプロキシが検査することはありません。 diff --git a/docs-site/src/content/docs/ko/reference/cli/agents.md b/docs-site/src/content/docs/ko/reference/cli/agents.md index a229551a83..3624a2a803 100644 --- a/docs-site/src/content/docs/ko/reference/cli/agents.md +++ b/docs-site/src/content/docs/ko/reference/cli/agents.md @@ -131,7 +131,7 @@ Grok Build model fence를 관리하고 적용합니다. ## 클라이언트 설정 내보내기 -### `ocx export --client ` +### `ocx export --client ` 실행 중인 프록시에 연결할 client config를 출력합니다. 이 명령은 base URL, model list, 그리고 client에 따라 credential reference 또는 `opencodex-loopback` placeholder를 포함한 `opencodex` provider block을 선택한 client의 네이티브 형식으로 직렬화합니다. @@ -139,7 +139,7 @@ Grok Build model fence를 관리하고 적용합니다. | 플래그 | 동작 | | --- | --- | -| `--client ` | 필수입니다. 클라이언트 설정 형식을 선택합니다. | +| `--client ` | 필수입니다. 클라이언트 설정 형식을 선택합니다. | | `--json` | config JSON만 stdout에 출력하므로, redirect가 byte-exact 출력을 캡처합니다. `--out` write note를 포함한 모든 진단 메시지는 stderr로 갑니다. | | `--out ` | config를 ``에 씁니다. 기존 파일이 있으면 덮어쓰지 않습니다. | | `--force` | `--out`이 기존 파일을 덮어쓰도록 허용합니다. | @@ -166,6 +166,9 @@ ocx export --client opencode --out ~/opencodex-opencode.json | `mcode` | `~/.minimax/config.yaml` (`MINIMAX_DATA_DIR`, 그다음 레거시 `MAVIS_DATA_DIR`가 설정되면 우선. 상대 경로는 거부됩니다) | `mcode-config.yaml` | 없음 — loopback placeholder | | `zcode` | `~/.zcode/v2/config.json` (`ZCODE_DATA_DIR`가 설정되면 우선. 상대 경로는 거부됩니다) | `config.json` | 없음 — loopback placeholder | | `prime` | `~/.prime/agent/models.json` (`PRIME_AGENT_CODING_AGENT_DIR`가 설정되면 우선. 상대 경로는 거부됩니다) | `prime-models.json` | 없음 — loopback placeholder | +| `raycast` | `~/.config/raycast/ai/providers.yaml` (macOS와 Windows 모두 동일. Raycast는 `XDG_CONFIG_HOME`을 따르지 않습니다) | `raycast-providers.yaml` | 없음 — loopback 전용. `api_keys` 항목은 쓰지 않습니다 | + +Raycast 내보내기는 `providers` 시퀀스에 `id: opencodex` 요소 하나만 담은 독립 `providers.yaml` 문서입니다. 내용은 `name: OpenCodex`, proxy의 `/v1` base URL, 그리고 `abilities`가 붙은 라우팅된 모든 모델입니다(`tools`와 `system_message`는 항상 지원, `vision`은 카탈로그의 입력 모달리티를 따름, `reasoning_effort`는 모델에 effort 사다리가 있을 때, `temperature`는 추론 모델에서 꺼짐). Custom Providers는 Raycast Pro 기능이며, Raycast가 이 파일을 감시하므로 저장한 변경은 재시작 없이 적용됩니다. 형식은 [manual.raycast.com/ai/custom-providers](https://manual.raycast.com/ai/custom-providers)에 문서화되어 있습니다. `api_keys` 항목은 쓰지 않으므로 이 내보내기는 loopback 전용이며, loopback이 아닌 bind는 거부됩니다. opencode는 `{env:OPENCODEX_OPENCODE_API_KEY}`를 보간합니다. opencodex가 생성한 Pi 블록에는 환경 변수가 필요 없으며, 리터럴 placeholder인 `opencodex-loopback`이 들어갑니다. 이 값은 필수입니다. Pi는 모델 목록을 만들 때 `apiKey`를 해석하고, 기존 config에 설정되지 않은 env 참조가 있으면 provider 전체를 숨기기 때문입니다. 루프백에서 proxy는 생성된 placeholder를 검사하지 않습니다. diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index e6470eae0e..300bb7d5e2 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -207,7 +207,7 @@ Manage and apply the Grok Build model fence. ## Client config export -### `ocx export --client ` +### `ocx export --client ` Print a client config wired to the running proxy. The command serializes the `opencodex` provider block — base URL, model list, and the client's credential @@ -218,7 +218,7 @@ models Codex can currently see. | Flag | Action | | --- | --- | -| `--client ` | Required. Selects the client config dialect. | +| `--client ` | Required. Selects the client config dialect. | | `--json` | Print the generated document as JSON on stdout for scripts. This is JSON even when the selected client's native format is YAML, TOML, or JSON5. | | `--out ` | Write the client's native config format to ``. Refuses to replace an existing file. | | `--force` | Allow `--out` to replace an existing file. | @@ -248,6 +248,7 @@ client applies its own defaults for those). | `zcode` | `~/.zcode/v2/config.json` (`ZCODE_DATA_DIR` wins when set; a relative value is refused) | `config.json` | none — loopback placeholder | | `prime` | `~/.prime/agent/models.json` (`PRIME_AGENT_CODING_AGENT_DIR` wins when set; a relative value is refused) | `prime-models.json` | none — loopback placeholder | | `aside` | `~/.aside/u//models.json` for the account Aside's own `accounts.json` names as current; an unreadable manifest is refused rather than defaulting to an account | `aside-models.json` | none — loopback placeholder | +| `raycast` | `~/.config/raycast/ai/providers.yaml` on macOS and Windows alike (Raycast does not honor `XDG_CONFIG_HOME`) | `raycast-providers.yaml` | none — loopback only, no `api_keys` entry is written | The managed DSH export requires DSH 0.1.0-rc.6 or newer and owns only `llm-pi-ai.providers.opencodex`. DSH hot reloads that provider; the user's default model and @@ -260,6 +261,15 @@ hide the whole provider when an existing config contains an unset env reference. checks the generated placeholder on loopback. OMP supports provider-level headers, but this initial integration deliberately remains loopback-only; remote `x-opencodex-api-key` wiring is deferred. +The Raycast export is a standalone `providers.yaml` document with one `id: opencodex` element +in the `providers` sequence: `name: OpenCodex`, the proxy's `/v1` base URL, and every routed model +with its `abilities` (`tools` and `system_message` always supported, `vision` from the catalog's +input modalities, `reasoning_effort` when the model has an effort ladder, `temperature` off for +reasoning models). Custom Providers is a Raycast Pro feature, and Raycast watches the file, so a +saved change takes effect without a restart. The format is documented at +[manual.raycast.com/ai/custom-providers](https://manual.raycast.com/ai/custom-providers). No +`api_keys` entry is written, so this export is loopback-only and a non-loopback bind is refused. + The MCode, ZCode and Prime exports are loopback-only for the same reason and likewise carry the `opencodex-loopback` placeholder rather than a real credential. Prime Agent reads the same `models.json` contract Pi does, so the two exports produce the same document; only the destination diff --git a/docs-site/src/content/docs/ru/reference/cli/agents.md b/docs-site/src/content/docs/ru/reference/cli/agents.md index b49162dbc6..8df8175173 100644 --- a/docs-site/src/content/docs/ru/reference/cli/agents.md +++ b/docs-site/src/content/docs/ru/reference/cli/agents.md @@ -152,7 +152,7 @@ override, но файлы на диске никогда не меняются. ## Экспорт client config -### `ocx export --client ` +### `ocx export --client ` Печатает client config, направленный на работающий прокси. Команда сериализует блок провайдера `opencodex` в нативном формате выбранного клиента: base URL, список моделей и, @@ -163,7 +163,7 @@ override, но файлы на диске никогда не меняются. | Флаг | Действие | | --- | --- | -| `--client ` | Обязателен. Выбирает формат конфигурации клиента. | +| `--client ` | Обязателен. Выбирает формат конфигурации клиента. | | `--json` | Печатать только JSON-конфиг в stdout, чтобы redirect сохранял побайтно точный вывод. Вся диагностика, включая заметку о записи через `--out`, идёт в stderr. | | `--out ` | Записать конфиг в ``. Перезаписывать существующий файл не позволит. | | `--force` | Разрешить `--out` заменить существующий файл. | @@ -193,6 +193,17 @@ ocx export --client opencode --out ~/opencodex-opencode.json | `mcode` | `~/.minimax/config.yaml` (`MINIMAX_DATA_DIR`, затем устаревшая `MAVIS_DATA_DIR`, имеют приоритет, если заданы; относительное значение отклоняется) | `mcode-config.yaml` | нет — loopback placeholder | | `zcode` | `~/.zcode/v2/config.json` (`ZCODE_DATA_DIR` имеет приоритет, если задана; относительное значение отклоняется) | `config.json` | нет — loopback placeholder | | `prime` | `~/.prime/agent/models.json` (`PRIME_AGENT_CODING_AGENT_DIR` имеет приоритет, если задана; относительное значение отклоняется) | `prime-models.json` | нет — loopback placeholder | +| `raycast` | `~/.config/raycast/ai/providers.yaml` одинаково на macOS и Windows (Raycast не учитывает `XDG_CONFIG_HOME`) | `raycast-providers.yaml` | нет — только loopback, запись `api_keys` не создаётся | + +Экспорт для Raycast — это отдельный документ `providers.yaml` с одним элементом `id: opencodex` в +последовательности `providers`: `name: OpenCodex`, базовый URL прокси с `/v1` и каждая маршрутизируемая +модель с её `abilities` (`tools` и `system_message` поддерживаются всегда, `vision` берётся из входных +модальностей каталога, `reasoning_effort` задаётся, когда у модели есть шкала усилий, `temperature` +отключена для рассуждающих моделей). Custom Providers — функция Raycast Pro, а Raycast следит за файлом, +поэтому сохранённое изменение вступает в силу без перезапуска. Формат описан на +[manual.raycast.com/ai/custom-providers](https://manual.raycast.com/ai/custom-providers). Запись +`api_keys` не создаётся, поэтому этот экспорт работает только через loopback, а привязка вне loopback +отклоняется. opencode интерполирует `{env:OPENCODEX_OPENCODE_API_KEY}`. Сгенерированный opencodex экспорт для Pi не требует переменной окружения и несёт литеральную заглушку `opencodex-loopback`. Это значение diff --git a/docs-site/src/content/docs/tr/guides/integrations.md b/docs-site/src/content/docs/tr/guides/integrations.md index fea4b37dd4..ff0dcaa41e 100644 --- a/docs-site/src/content/docs/tr/guides/integrations.md +++ b/docs-site/src/content/docs/tr/guides/integrations.md @@ -1,10 +1,10 @@ --- title: Entegrasyonlar -description: Kontrol panelinden OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness ve MiniMax Code'u opencodex'e bağlayın — istemci başına tek bir anahtar ve her yazmadan önce alınan bir yedek. +description: Kontrol panelinden OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness, MiniMax Code ve Raycast'i opencodex'e bağlayın — istemci başına tek bir anahtar ve her yazmadan önce alınan bir yedek. --- **Entegrasyonlar** sekmesi, opencodex'in sağlayıcı bloğunu istemcinin kendi -yapılandırma dosyasına yazar ve tekrar kaldırır. Dokuz istemci bu şekilde +yapılandırma dosyasına yazar ve tekrar kaldırır. On istemci bu şekilde çalışır, her biri bir anahtarla: | İstemci | Yapılandırma dosyası | Format | Değişiklik ne zaman geçerli olur? | Kimlik bilgisi | @@ -18,6 +18,7 @@ yapılandırma dosyasına yazar ve tekrar kaldırır. Dokuz istemci bu şekilde | Gajae Code | `~/.gjc/agent/models.yml` | YAML | yeni oturumlarda veya `/model` açtığınızda | `OPENCODEX_GAJAE_API_KEY` | | DeepSeek Harness (DSH) | `$DSH_HOME/settings.yaml` (varsayılan `~/.dsh/settings.yaml`) | YAML | çalışırken yeniden yükleme | gizli olmayan geri döngü bearer yer tutucusu | | MiniMax Code | `~/.minimax/config.yaml` | YAML | yeni oturumlarda veya model seçici açıldıktan sonra | geri döngü (loopback) yer tutucusu | +| Raycast | `~/.config/raycast/ai/providers.yaml` | YAML | kaydedildiği anda — Raycast dosyayı izler | yok — yalnızca geri döngü | Yönetilen DSH desteğinin en düşük uyumlu sürümü **DSH 0.1.0-rc.6**'dır. OpenCodex yalnızca `llm-pi-ai.providers.opencodex` bölümünü yönetir: Uygula ve Yenile bu bölümü değiştirir, Devre Dışı @@ -35,6 +36,29 @@ Entegrasyon yenilendiğinde model başına doğrulanmış bağlam pencereleri ve çabası seçenekleri de yenilenir; bilinmeyen yetenekler atlanır ve MCode oturumunun yönettiği geçerli çaba seçimi korunur. +Raycast'in iki ön koşulu vardır. Özel sağlayıcılar (Custom Providers) bir **Raycast Pro** +özelliğidir: ücretsiz planda dosya yine yazılır, ancak Raycast onu okumayacağı için +`ocx integration client status --client raycast` ve Entegrasyonlar sayfası bir uyarı +bildirir. Ayrıca Raycast `ai` klasörünü yalnızca Raycast → Settings → AI → +**Reveal Providers Config** seçeneğini bir kez açtığınızda oluşturur; opencodex bu +klasörü kurulum sinyali olarak kullanır ve klasör var olana kadar istemciyi kurulu değil +olarak bildirir. Raycast, `~/.config/raycast/ai/providers.yaml` dosyasını macOS ve +Windows'ta aynı şekilde okur ve `XDG_CONFIG_HOME` değerini dikkate almaz; bu nedenle bu +yol taşınamaz. + +Yönetilen blok, dosyanın `providers` dizisindeki tek bir öğedir: `id: opencodex`, +`name: OpenCodex`, `base_url: http://:/v1` ve `abilities` alanıyla birlikte +yönlendirilen her model — `tools` ve `system_message` her zaman destekli, `vision` +kataloğun giriş modalitelerini izler, `reasoning_effort` modelin bir çaba merdiveni +varsa ayarlanır ve `temperature` akıl yürütme modelleri için kapatılır. Dosyadaki diğer +sağlayıcılar korunur ve devre dışı bırakma yalnızca OpenCodex öğesini kaldırır. Raycast +değişikliği dosya kaydedilir kaydedilmez, yeniden başlatma gerekmeden alır; modeller +Raycast'in model seçicisinde **OpenCodex** altında gruplanmış olarak görünür. Dosyada +kimlik bilgisi için bir yer yoktur, bu yüzden bu istemci yalnızca geri döngü içindir: +hiçbir `api_keys` girdisi yazılmaz ve geri döngü dışı bir bağlama reddedilir. Format +[manual.raycast.com/ai/custom-providers](https://manual.raycast.com/ai/custom-providers) +adresinde belgelenmiştir. + Yollar, varsa her istemcinin kendi ortam geçersiz kılmalarını dikkate alır. OMP için `OMP_PROFILE`, açıkça boş olduğunda bile varlığıyla `PI_PROFILE`'a üstün gelir. Adlandırılmış bir profil, `PI_CONFIG_DIR`'i kullanıcının ev dizinine göre @@ -112,7 +136,7 @@ hiçbir şey sessizce değiştirilmez veya düşürülmez. **OMP** de yanındaki düzenlemelerden etkilenmez, ama başka bir nedenle: writer'ı yalnızca kendi `providers.opencodex` aralığını bayt bayt yamalar, dosyanın geri kalanı hiçbir zaman yeniden yazılmaz. Yorum taşıyabilen diğer biçimlerde (Hermes, OpenClaw, -Kimi Code, Gajae Code, MiniMax Code — bütün belge olarak yazılan YAML, JSON5 ve TOML) veya +Kimi Code, Gajae Code, MiniMax Code, Raycast — bütün belge olarak yazılan YAML, JSON5 ve TOML) veya kendi girdilerimiz düzenlenmişse, anahtar kilitlenir ve hangi düzenlemelerin size ait olduğunu tahmin etmek yerine devre dışı bırakmayı reddeder. @@ -192,10 +216,12 @@ ocx integration client enable --client mcode ocx mcode ``` -Bağlandıktan sonra `ocx sync`, yönetilen MCode bloğunu güncel bağlam pencereleri ve -akıl yürütme çabası seçenekleriyle de yeniler. Eksik, dışarıdan düzenlenmiş, güvenli -olmayan veya hiç sahiplenilmemiş bloklara dokunmaz; yeniden bağlamak istediğinizde -entegrasyonu açıkça yeniden etkinleştirin. +Bağlandıktan sonra `ocx sync` ve `POST /api/sync`, yönetilen MCode, Pi, Aside ve +Raycast kataloglarını yeniler. Proxy başlangıcı da yönetilen Raycast kataloğunu +yeniler. Model görünürlüğü, sağlayıcı veya ön ayar değişiklikleri Pi, Aside ve +Raycast kataloglarını günceller. Eksik, dışarıdan düzenlenmiş, güvenli olmayan +veya elle kaldırılmış bloklara dokunmaz; yeniden bağlamak istediğinizde +entegrasyonu açıkça etkinleştirin. Ayrı MiniMax platform CLI'si (`mmx`) bir dosya anahtarı entegrasyonu değildir. Metin komutları MiniMax'ın Anthropic uyumlu uç noktasını kullandığı için OpenCodex, diff --git a/docs-site/src/content/docs/tr/reference/cli/agents.md b/docs-site/src/content/docs/tr/reference/cli/agents.md index a3e184661d..04e72a766c 100644 --- a/docs-site/src/content/docs/tr/reference/cli/agents.md +++ b/docs-site/src/content/docs/tr/reference/cli/agents.md @@ -191,7 +191,7 @@ Grok Build model çitini yönetin ve uygulayın. ## İstemci yapılandırma dışa aktarma -### `ocx export --client ` +### `ocx export --client ` Çalışan proxy'ye bağlı bir istemci yapılandırmasını yazdırın. Komut, `opencodex` sağlayıcı bloğunu — temel URL, model listesi ve istemcinin kimlik bilgisi @@ -203,7 +203,7 @@ yalnızca Codex'in şu anda görebildiği modelleri yayınlar. | Bayrak | Eylem | | --- | --- | -| `--client ` | Gerekli. İstemci yapılandırma lehçesini seçer. | +| `--client ` | Gerekli. İstemci yapılandırma lehçesini seçer. | | `--json` | Betikler için stdout üzerinde oluşturulan belgeyi JSON olarak yazdırın. Bu, seçilen istemcinin yerel formatı YAML, TOML veya JSON5 olsa bile JSON'dur. | | `--out ` | İstemcinin yerel yapılandırma formatını `` konumuna yazın. Mevcut bir dosyanın üzerine yazmayı reddeder. | | `--force` | `--out`'un mevcut bir dosyanın üzerine yazmasına izin verin. | @@ -233,6 +233,18 @@ için kendi varsayılanlarını uygular) gelir. | `mcode` | `~/.minimax/config.yaml` (ayarlandığında `MINIMAX_DATA_DIR`, ardından eski `MAVIS_DATA_DIR` öncelikli; göreli değer reddedilir) | `mcode-config.yaml` | yok — geri döngü yer tutucusu | | `zcode` | `~/.zcode/v2/config.json` (ayarlandığında `ZCODE_DATA_DIR` öncelikli; göreli değer reddedilir) | `config.json` | yok — geri döngü yer tutucusu | | `prime` | `~/.prime/agent/models.json` (ayarlandığında `PRIME_AGENT_CODING_AGENT_DIR` öncelikli; göreli değer reddedilir) | `prime-models.json` | yok — geri döngü yer tutucusu | +| `raycast` | `~/.config/raycast/ai/providers.yaml`, macOS ve Windows'ta aynı (Raycast `XDG_CONFIG_HOME` değerini dikkate almaz) | `raycast-providers.yaml` | yok — yalnızca geri döngü, `api_keys` girdisi yazılmaz | + +Raycast dışa aktarımı, `providers` dizisinde tek bir `id: opencodex` öğesi içeren bağımsız +bir `providers.yaml` belgesidir: `name: OpenCodex`, proxy'nin `/v1` temel URL'si ve +`abilities` alanıyla birlikte yönlendirilen her model (`tools` ve `system_message` her +zaman destekli, `vision` kataloğun giriş modalitelerinden, `reasoning_effort` modelin bir +çaba merdiveni varsa, `temperature` akıl yürütme modelleri için kapalı). Özel sağlayıcılar +bir Raycast Pro özelliğidir ve Raycast dosyayı izlediği için kaydedilen bir değişiklik +yeniden başlatma gerekmeden etkili olur. Format +[manual.raycast.com/ai/custom-providers](https://manual.raycast.com/ai/custom-providers) +adresinde belgelenmiştir. Hiçbir `api_keys` girdisi yazılmaz; bu yüzden bu dışa aktarım +yalnızca geri döngü içindir ve geri döngü dışı bir bağlama reddedilir. opencode `{env:OPENCODEX_OPENCODE_API_KEY}` değerini enterpole eder. Üretilen Pi ve OMP dışa aktarımları bir ortam değişkeni gerektirmez: her biri değişmez diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/agents.md b/docs-site/src/content/docs/zh-cn/reference/cli/agents.md index 11e1c38ee1..89203420e9 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/agents.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/agents.md @@ -132,7 +132,7 @@ ocx claude desktop import [--apply] Validate and import JSON ## Client config export -### `ocx export --client ` +### `ocx export --client ` 输出连接到正在运行代理的客户端配置。此命令会以所选客户端的原生格式序列化 `opencodex` provider 块,其中包含基础 URL、模型列表,以及该客户端适用的凭据引用或 `opencodex-loopback` 占位值。 @@ -140,7 +140,7 @@ ocx claude desktop import [--apply] Validate and import JSON | 标志 | 动作 | | --- | --- | -| `--client ` | 必需。选择客户端配置格式。 | +| `--client ` | 必需。选择客户端配置格式。 | | `--json` | 仅在 stdout 打印配置 JSON,这样重定向即可捕获字节级精确输出。包括 `--out` 写入提示在内的所有诊断信息都会输出到 stderr。 | | `--out ` | 将配置写入 ``。拒绝替换已存在的文件。 | | `--force` | 允许 `--out` 替换已存在的文件。 | @@ -167,6 +167,9 @@ ocx export --client opencode --out ~/opencodex-opencode.json | `mcode` | `~/.minimax/config.yaml` (设置后 `MINIMAX_DATA_DIR` 优先,其次是旧的 `MAVIS_DATA_DIR`;相对路径会被拒绝) | `mcode-config.yaml` | 无 — loopback placeholder | | `zcode` | `~/.zcode/v2/config.json` (设置后 `ZCODE_DATA_DIR` 优先;相对路径会被拒绝) | `config.json` | 无 — loopback placeholder | | `prime` | `~/.prime/agent/models.json` (设置后 `PRIME_AGENT_CODING_AGENT_DIR` 优先;相对路径会被拒绝) | `prime-models.json` | 无 — loopback placeholder | +| `raycast` | `~/.config/raycast/ai/providers.yaml`(macOS 与 Windows 相同;Raycast 不遵循 `XDG_CONFIG_HOME`) | `raycast-providers.yaml` | 无 — 仅限回环,不会写入 `api_keys` 条目 | + +Raycast 导出是一份独立的 `providers.yaml` 文档,在 `providers` 序列中只有一个 `id: opencodex` 元素:`name: OpenCodex`、代理的 `/v1` 基础 URL,以及每个已路由模型及其 `abilities`(`tools` 与 `system_message` 始终支持,`vision` 取自目录的输入模态,`reasoning_effort` 在模型有 effort 阶梯时设置,`temperature` 对推理模型关闭)。Custom Providers 是 Raycast Pro 功能,且 Raycast 会监视该文件,因此保存后的更改无需重启即可生效。格式见 [manual.raycast.com/ai/custom-providers](https://manual.raycast.com/ai/custom-providers)。不会写入任何 `api_keys` 条目,所以该导出仅限回环,非回环绑定会被拒绝。 opencode 会插值 `{env:OPENCODEX_OPENCODE_API_KEY}`。opencodex 生成的 Pi 导出不需要环境变量,而是携带字面占位值 `opencodex-loopback`。这个值是必需的:Pi 在构建模型列表时会解析 `apiKey`,如果已有配置包含未设置的环境变量引用,它就会隐藏整个 provider。回环上的代理从不校验生成的占位值。 diff --git a/docs-site/src/content/docs/zh-tw/guides/integrations.md b/docs-site/src/content/docs/zh-tw/guides/integrations.md index 54751d5620..ebf2e6f0de 100644 --- a/docs-site/src/content/docs/zh-tw/guides/integrations.md +++ b/docs-site/src/content/docs/zh-tw/guides/integrations.md @@ -1,9 +1,9 @@ --- title: 整合 -description: 從儀表板把 opencodex 連接到 OpenCode、Pi、OMP、Hermes、OpenClaw、Kimi Code、Gajae Code、DeepSeek Harness 與 MiniMax Code——每個客戶端一個開關,每次寫入前都會先備份。 +description: 從儀表板把 opencodex 連接到 OpenCode、Pi、OMP、Hermes、OpenClaw、Kimi Code、Gajae Code、DeepSeek Harness、MiniMax Code 與 Raycast——每個客戶端一個開關,每次寫入前都會先備份。 --- -**整合(Integrations)** 分頁會把 opencodex 的 provider 區塊寫入客戶端自己的設定檔,也會把它移除。共有九個客戶端以這種方式運作,每個都有一個開關: +**整合(Integrations)** 分頁會把 opencodex 的 provider 區塊寫入客戶端自己的設定檔,也會把它移除。共有十個客戶端以這種方式運作,每個都有一個開關: | 客戶端 | 設定檔 | 格式 | 變更生效時機 | 憑證 | |---|---|---|---|---| @@ -16,6 +16,7 @@ description: 從儀表板把 opencodex 連接到 OpenCode、Pi、OMP、Hermes、 | Gajae Code | `~/.gjc/agent/models.yml` | YAML | 新 sessions,或當你開啟 `/model` 時 | `OPENCODEX_GAJAE_API_KEY` | | DeepSeek Harness (DSH) | `$DSH_HOME/settings.yaml`(預設 `~/.dsh/settings.yaml`) | YAML | 熱重載 | 非秘密的 loopback bearer 佔位符 | | MiniMax Code | `~/.minimax/config.yaml` | YAML | 新 sessions,或開啟模型選擇器後 | loopback 佔位符 | +| Raycast | `~/.config/raycast/ai/providers.yaml` | YAML | 儲存後立即生效——Raycast 會監看該檔案 | 無——僅限 loopback | 受管理 DSH 支援的相容性下限是 **DSH 0.1.0-rc.6**。OpenCodex 只擁有 `llm-pi-ai.providers.opencodex`:Apply 與 Refresh 會取代該片段,Disable 只移除該片段, @@ -30,6 +31,22 @@ MiniMax Code 依序遵循 `MINIMAX_DATA_DIR`、`MAVIS_DATA_DIR`,最後才回 逐模型 context window 與 reasoning-effort 選項;未知能力會省略,而 MCode session 目前選取的 effort 不會被覆寫。 +Raycast 有兩個前提。Custom Providers 是 **Raycast Pro** 功能:免費方案下檔案仍會被寫入,但 +`ocx integration client status --client raycast` 與整合頁面會回報警告,因為 Raycast 不會讀取它。 +另外,Raycast 只有在你開啟一次 Raycast → Settings → AI → **Reveal Providers Config** 後才會建立 +`ai` 資料夾;opencodex 以該資料夾作為安裝訊號,在它存在之前都會回報客戶端尚未安裝。Raycast 在 +macOS 與 Windows 上同樣讀取 `~/.config/raycast/ai/providers.yaml`,且不遵循 `XDG_CONFIG_HOME`, +所以該路徑無法搬移。 + +受管理區塊是檔案 `providers` 序列中的單一元素 `id: opencodex`:`name: OpenCodex`、 +`base_url: http://:/v1`,以及每個路由模型及其 `abilities`——`tools` 與 +`system_message` 一律支援,`vision` 依目錄的輸入模態而定,`reasoning_effort` 在模型有 effort +階梯時設定,`temperature` 對推理模型關閉。檔案中的其他 provider 會被保留,停用只移除 OpenCodex +元素。檔案一儲存 Raycast 就會套用變更,不需重新啟動;模型會在 Raycast 的模型選擇器中歸在 +**OpenCodex** 群組下。該檔案沒有存放憑證的位置,因此此客戶端僅限 loopback:不會寫入任何 +`api_keys` 項目,非 loopback 的 bind 會被拒絕。格式說明見 +[manual.raycast.com/ai/custom-providers](https://manual.raycast.com/ai/custom-providers)。 + 路徑遵循客戶端自己的環境覆寫(environment override)。對 OMP 而言,`OMP_PROFILE` 以存在與否優先於 `PI_PROFILE`,即使明確為空也一樣。具名 profile 會把 `PI_CONFIG_DIR` 當作相對於使用者家目錄的目錄名稱,並忽略 `PI_CODING_AGENT_DIR`;沒有具名 profile 時,`PI_CODING_AGENT_DIR` 勝出。OMP 支援 provider 層級的 headers,但這個最初的整合刻意只支援 loopback;遠端 `x-opencodex-api-key` 的連線設定被延後。搬移過的 `HERMES_HOME`、`KIMI_CODE_HOME` 與 `XDG_CONFIG_HOME` 路徑同樣會被遵循,而非猜測。表格列出每個客戶端的預設值。 對原生 OpenAI 模型,產生的 OMP 區塊會選用其模型層級的 Responses API,保留圖片輸入與 reasoning-effort 控制。路由模型則維持 provider 的 Chat Completions 方言,讓它們既有的 adapters 保持相容。 @@ -52,7 +69,7 @@ opencodex 從自己的環境讀取這些變數。如果你的 gateway 以 profil - **Restore this point…** 會出現在較舊的操作上,或當檔案在那次操作之後有變更時。跨過這樣的變更做回復會再詢問一次,才覆蓋你的較新編輯——並且也會備份它們,所以那次的回復本身也可以復原。 - 每個客戶端保留十份備份。超過之後,最舊的快照檔案會被移除,其歷史列顯示為 **Backup expired**。 -停用只移除 opencodex 記錄為自己寫入的條目。如果你的檔案在我們寫入之後有變更,後續行為取決於我們自己的條目是否完好,以及檔案的格式。對於嚴格 JSON 設定檔(OpenCode、Pi),在我們的區塊**旁邊**進行的編輯——例如新增 MCP 伺服器或你自己的 provider——會顯示為**需要更新**:重新整理會在保留你的條目的前提下合併寫入,但格式可能會被正規化。例外情況是 JSON 無法精確重寫的內容——例如 `1e999` 這類非有限數字、重寫會被四捨五入的數字(極大的整數,或小到會塌縮成零的數字)、`-0`、同一個物件裡重複出現的鍵,或巢狀層數超過 1000 層——此時開關會鎖定,確保沒有任何值被悄悄改動或刪除。**OMP** 同樣不受旁邊編輯影響,但原因不同:它的 writer 只逐位元組修補自己的 `providers.opencodex` 範圍,檔案其餘部分從不會被重寫。至於其餘可以包含註解的格式(Hermes、OpenClaw、Kimi Code、Gajae Code、MiniMax Code——以整份文件寫出的 YAML、JSON5 與 TOML),或當我們自己的條目被編輯過時,開關會鎖定,停用會拒絕執行,而不是猜測哪些編輯是你的。 +停用只移除 opencodex 記錄為自己寫入的條目。如果你的檔案在我們寫入之後有變更,後續行為取決於我們自己的條目是否完好,以及檔案的格式。對於嚴格 JSON 設定檔(OpenCode、Pi),在我們的區塊**旁邊**進行的編輯——例如新增 MCP 伺服器或你自己的 provider——會顯示為**需要更新**:重新整理會在保留你的條目的前提下合併寫入,但格式可能會被正規化。例外情況是 JSON 無法精確重寫的內容——例如 `1e999` 這類非有限數字、重寫會被四捨五入的數字(極大的整數,或小到會塌縮成零的數字)、`-0`、同一個物件裡重複出現的鍵,或巢狀層數超過 1000 層——此時開關會鎖定,確保沒有任何值被悄悄改動或刪除。**OMP** 同樣不受旁邊編輯影響,但原因不同:它的 writer 只逐位元組修補自己的 `providers.opencodex` 範圍,檔案其餘部分從不會被重寫。至於其餘可以包含註解的格式(Hermes、OpenClaw、Kimi Code、Gajae Code、MiniMax Code、Raycast——以整份文件寫出的 YAML、JSON5 與 TOML),或當我們自己的條目被編輯過時,開關會鎖定,停用會拒絕執行,而不是猜測哪些編輯是你的。 ## 誠實的預期 @@ -98,9 +115,11 @@ ocx integration client enable --client mcode ocx mcode ``` -完成一次連接後,`ocx sync` 也會以目前的 context window 與 reasoning-effort 階梯更新 -OpenCodex 已擁有的 MCode 區塊。若區塊已刪除、遭外部修改、不安全或從未由 OpenCodex -建立,sync 會保持原檔不動;只有在你確定要重新連接時才再次執行 enable。 +完成一次連接後,`ocx sync` 與 `POST /api/sync` 會更新 OpenCodex 已擁有的 +MCode、Pi、Aside 與 Raycast 目錄。proxy 啟動也會更新已擁有的 Raycast 目錄。 +模型可見性、provider 或 preset 變更會更新 Pi、Aside 與 Raycast。若區塊已刪除、 +遭外部修改、不安全或由你手動移除,sync 會保持原檔不動;只有在你確定要重新 +連接時才再次執行 enable。 另一個 MiniMax 平台 CLI(`mmx`)不是檔案開關整合。其文字命令使用 MiniMax 的 Anthropic 相容端點,因此 OpenCodex 提供憑證隔離、僅限 loopback 的 launcher: diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/agents.md b/docs-site/src/content/docs/zh-tw/reference/cli/agents.md index 497d2e4252..d04c099ebf 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/agents.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/agents.md @@ -130,7 +130,7 @@ ocx claude desktop import [--apply] 驗證並匯入 JSON ## 客戶端設定匯出 -### `ocx export --client ` +### `ocx export --client ` 印出連接到執行中代理的客戶端設定。此指令會用所選客戶端的原生格式,序列化含有 base URL、模型清單,以及適用的環境變數參考或 loopback 佔位符的 `opencodex` provider 區塊。 @@ -138,7 +138,7 @@ ocx claude desktop import [--apply] 驗證並匯入 JSON | 旗標 | 動作 | | --- | --- | -| `--client ` | 必填。選擇客戶端設定格式。 | +| `--client ` | 必填。選擇客戶端設定格式。 | | `--json` | 僅在 stdout 印出設定 JSON,使重導向能擷取逐位元組輸出。所有診斷訊息(含 `--out` 寫入提示)皆送至 stderr。 | | `--out ` | 將設定寫入 ``。拒絕覆寫既有檔案。 | | `--force` | 允許 `--out` 覆寫既有檔案。 | @@ -165,6 +165,9 @@ ocx export --client opencode --out ~/opencodex-opencode.json | `mcode` | `~/.minimax/config.yaml` (設定後 `MINIMAX_DATA_DIR` 優先,其次為舊的 `MAVIS_DATA_DIR`;相對路徑會被拒絕) | `mcode-config.yaml` | 無——loopback 佔位符 | | `zcode` | `~/.zcode/v2/config.json` (設定後 `ZCODE_DATA_DIR` 優先;相對路徑會被拒絕) | `config.json` | 無——loopback 佔位符 | | `prime` | `~/.prime/agent/models.json` (設定後 `PRIME_AGENT_CODING_AGENT_DIR` 優先;相對路徑會被拒絕) | `prime-models.json` | 無——loopback 佔位符 | +| `raycast` | `~/.config/raycast/ai/providers.yaml`(macOS 與 Windows 相同;Raycast 不遵循 `XDG_CONFIG_HOME`) | `raycast-providers.yaml` | 無——僅限 loopback,不會寫入 `api_keys` 項目 | + +Raycast 匯出是一份獨立的 `providers.yaml` 文件,在 `providers` 序列中只有一個 `id: opencodex` 元素:`name: OpenCodex`、proxy 的 `/v1` base URL,以及每個路由模型及其 `abilities`(`tools` 與 `system_message` 一律支援,`vision` 依目錄的輸入模態而定,`reasoning_effort` 在模型有 effort 階梯時設定,`temperature` 對推理模型關閉)。Custom Providers 是 Raycast Pro 功能,且 Raycast 會監看該檔案,因此儲存後的變更不需重新啟動即可生效。格式說明見 [manual.raycast.com/ai/custom-providers](https://manual.raycast.com/ai/custom-providers)。不會寫入任何 `api_keys` 項目,所以此匯出僅限 loopback,非 loopback 的 bind 會被拒絕。 opencode 會插值 `{env:OPENCODEX_OPENCODE_API_KEY}`。Pi 與 OMP 的匯出不需要環境變數, 而是帶有字面值 `opencodex-loopback`。DSH 匯出需要 DSH 0.1.0-rc.6 或更新版本,且只擁有 diff --git a/gui/public/provider-icons/README.md b/gui/public/provider-icons/README.md index 1fc7c57857..f5e64b568b 100644 --- a/gui/public/provider-icons/README.md +++ b/gui/public/provider-icons/README.md @@ -47,6 +47,16 @@ Export-client marks (used by the API tab's connect rows, not the provider list): on the web (`aside.com/favicon.svg` is a 404), so the shipping application is the first-party source. +- `raycast.svg` — fetched 2026-09-04 from + `https://fz1sd71lwhbqy6sh.public.blob.vercel-storage.com/press/images/logo/raycast-logo-dark.svg`, + the "Logo (dark)" download Raycast's own press kit (`raycast.com/press`) links. + `raycast.com/favicon.svg` and the other conventional paths are 404s, so the + press kit is the first-party source. Path data and the `#FF6363` fill are + verbatim; the fixed `width`/`height` are dropped in favour of the `viewBox`, + and the `` wrapper — a full-frame white `` the export tool left + behind — is removed because the path never leaves the frame and the rect + would read as a second ink to the mark tooling here. + - `minimax.svg` — fetched 2026-08-31 from `https://raw.githubusercontent.com/MiniMax-AI/MiniMax-01/main/figures/minimax.svg`, MiniMax's own symbol as committed in their own model repository. The API-docs @@ -135,6 +145,9 @@ Decisions that are not obvious from looking at the file: - `aside.svg` **is masked.** It already paints with `currentColor`, so it would follow the theme either way; masking keeps it consistent with the other silhouettes rather than depending on inherited color. +- `raycast.svg` **is not masked.** One ink, but that ink is #FF6363 — Raycast + red, the same case as `openai.svg` and `deepseek-harness.svg`. Legible on both + surfaces as an image. Both directions are enforced in `gui/tests/integration-marks.test.ts`, including a luminance check that fails any single-ink near-neutral mark left as an image. That diff --git a/gui/public/provider-icons/raycast.svg b/gui/public/provider-icons/raycast.svg new file mode 100644 index 0000000000..b6a40c7ba2 --- /dev/null +++ b/gui/public/provider-icons/raycast.svg @@ -0,0 +1,3 @@ + + + diff --git a/gui/src/app-routing.ts b/gui/src/app-routing.ts index bab41b1eee..c5971ffb6b 100644 --- a/gui/src/app-routing.ts +++ b/gui/src/app-routing.ts @@ -100,6 +100,7 @@ export const INTEGRATION_TAB_HASHES = [ "integrations/zcode", "integrations/prime", "integrations/aside", + "integrations/raycast", ] as const; export function hashBelongsToPage(rawHash: string, page: Page): boolean { diff --git a/gui/src/components/apikeys-workspace/client-config-clients.ts b/gui/src/components/apikeys-workspace/client-config-clients.ts index c7c42d3e56..afd4484551 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"] as const; +export const CLIENTS = ["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast"] as const; export type ExportClientId = (typeof CLIENTS)[number]; export const CLIENT_LABEL_KEYS = { @@ -24,6 +24,7 @@ export const CLIENT_LABEL_KEYS = { zcode: "api.clientConfig.clientZcode", prime: "api.clientConfig.clientPrime", aside: "api.clientConfig.clientAside", + raycast: "api.clientConfig.clientRaycast", } as const; /** @@ -70,6 +71,8 @@ export const CLIENT_MARKS: Partial> = { zcode: "/provider-icons/zcode.svg", prime: "/provider-icons/prime-agent.svg", aside: "/provider-icons/aside.svg", + // Raycast red (#FF6363) is the brand, so like `dsh` it stays an image. + raycast: "/provider-icons/raycast.svg", }; /** diff --git a/gui/src/components/integration-marks.ts b/gui/src/components/integration-marks.ts index e8786224ec..eca38510bd 100644 --- a/gui/src/components/integration-marks.ts +++ b/gui/src/components/integration-marks.ts @@ -57,6 +57,7 @@ export const INTEGRATION_MARKS: Record = { zcode: CLIENT_MARKS.zcode ?? null, prime: CLIENT_MARKS.prime ?? null, aside: CLIENT_MARKS.aside ?? null, + raycast: CLIENT_MARKS.raycast ?? null, }; /** diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 429379396f..6b7ccae630 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1079,6 +1079,7 @@ export const de: Record = { "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", "integrations.tab.aside": "Aside", + "integrations.tab.raycast": "Raycast", "integrations.aside.profilesTitle": "Aside-Profile", "integrations.aside.profilesHint": "Wähle, welche Profile die ausgewählten Modelle erhalten. Das aktive Aside-Profil bleibt unverändert.", "integrations.aside.all": "Alle Profile synchronisieren", @@ -1238,6 +1239,10 @@ export const de: Record = { "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.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.", + "integrations.raycast.proRequired": "Custom Providers ist eine Funktion von Raycast Pro. Die Datei wird geschrieben, aber Raycast ignoriert sie, bis ein Pro-Abonnement aktiv ist.", + "integrations.raycast.planUnknown": "Der Abonnementstatus von Raycast konnte nicht gelesen werden; Custom Providers erfordert Raycast Pro.", + "integrations.raycast.revealConfig": "Öffnen Sie Raycast → Einstellungen → AI und klicken Sie einmal auf „Reveal Providers Config“, damit der Providers-Ordner existiert.", "codexAuth.mainAccount": "Hauptkonto", "codexAuth.logLabel": "Log-Kennung", "codexAuth.codexApp": "Codex App", @@ -1566,6 +1571,7 @@ export const de: Record = { "api.clientConfig.clientZcode": "ZCode", "api.clientConfig.clientPrime": "Prime Agent", "api.clientConfig.clientAside": "Aside", + "api.clientConfig.clientRaycast": "Raycast", "api.clientConfig.copy": "Konfiguration kopieren", "api.clientConfig.download": "Herunterladen", "api.clientConfig.loading": "Client-Konfiguration wird erstellt…", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 9cbf8699fd..a00e8f70ff 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1586,6 +1586,7 @@ export const en = { "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", "integrations.tab.aside": "Aside", + "integrations.tab.raycast": "Raycast", "integrations.aside.profilesTitle": "Aside profiles", "integrations.aside.profilesHint": "Choose which profiles receive the selected models. Aside’s active profile stays unchanged.", "integrations.aside.all": "Sync all profiles", @@ -1785,6 +1786,10 @@ export const en = { "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.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.", + "integrations.raycast.proRequired": "Custom Providers is a Raycast Pro feature. The file will be written, but Raycast ignores it until a Pro subscription is active.", + "integrations.raycast.planUnknown": "Could not read the Raycast subscription state; Custom Providers requires Raycast Pro.", + "integrations.raycast.revealConfig": "Open Raycast → Settings → AI and click Reveal Providers Config once so the providers folder exists.", "codexAuth.mainAccount": "Main Account", "codexAuth.logLabel": "Log label", "codexAuth.codexApp": "Codex App", @@ -2124,6 +2129,7 @@ export const en = { "api.clientConfig.clientZcode": "ZCode", "api.clientConfig.clientPrime": "Prime Agent", "api.clientConfig.clientAside": "Aside", + "api.clientConfig.clientRaycast": "Raycast", "api.clientConfig.copy": "Copy config", "api.clientConfig.download": "Download", "api.clientConfig.loading": "Building client config…", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index ec171e627c..8b9cfe4b7b 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1558,6 +1558,7 @@ export const fr: Record = { "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", "integrations.tab.aside": "Aside", + "integrations.tab.raycast": "Raycast", "integrations.aside.profilesTitle": "Profils Aside", "integrations.aside.profilesHint": "Choisissez les profils qui recevront les modèles sélectionnés. Le profil actif dans Aside reste inchangé.", "integrations.aside.all": "Synchroniser tous les profils", @@ -1717,6 +1718,10 @@ export const fr: Record = { "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.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.", + "integrations.raycast.proRequired": "Custom Providers est une fonctionnalité Raycast Pro. Le fichier sera écrit, mais Raycast l'ignore tant qu'un abonnement Pro n'est pas actif.", + "integrations.raycast.planUnknown": "Impossible de lire l'état de l'abonnement Raycast ; Custom Providers nécessite Raycast Pro.", + "integrations.raycast.revealConfig": "Ouvrez Raycast → Réglages → AI et cliquez une fois sur « Reveal Providers Config » pour que le dossier des fournisseurs existe.", "codexAuth.mainAccount": "Compte principal", "codexAuth.logLabel": "Libellé du journal", "codexAuth.codexApp": "Application Codex", @@ -2043,6 +2048,7 @@ export const fr: Record = { "api.clientConfig.clientZcode": "ZCode", "api.clientConfig.clientPrime": "Prime Agent", "api.clientConfig.clientAside": "Aside", + "api.clientConfig.clientRaycast": "Raycast", "api.clientConfig.copy": "Copier la configuration", "api.clientConfig.download": "Télécharger", "api.clientConfig.loading": "Génération de la configuration du client…", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index c71bd7a045..36a9ee2403 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1499,6 +1499,7 @@ export const ja: Record = { "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", "integrations.tab.aside": "Aside", + "integrations.tab.raycast": "Raycast", "integrations.aside.profilesTitle": "Asideのプロファイル", "integrations.aside.profilesHint": "選択したモデルを同期するプロファイルを選んでください。Asideで使用中のプロファイルは変わりません。", "integrations.aside.all": "すべてのプロファイルを同期", @@ -1658,6 +1659,10 @@ export const ja: Record = { "integrations.semantics.zcode": "~/.zcode/v2/config.json の provider.opencodex のみを管理します。Z.ai ログインと他のプロバイダーは変更しません。変更後は ZCode を再起動してください。", "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 が必要です。", + "integrations.raycast.proRequired": "Custom Providers は Raycast Pro の機能です。ファイルは書き込まれますが、Pro サブスクリプションが有効になるまで Raycast はこれを無視します。", + "integrations.raycast.planUnknown": "Raycast のサブスクリプション状態を読み取れませんでした。Custom Providers には Raycast Pro が必要です。", + "integrations.raycast.revealConfig": "Raycast → 設定 → AI を開き、「Reveal Providers Config」を一度クリックして providers フォルダを作成してください。", "codexAuth.mainAccount": "メインアカウント", "codexAuth.logLabel": "ログラベル", "codexAuth.codexApp": "Codex App", @@ -1991,6 +1996,7 @@ export const ja: Record = { "api.clientConfig.clientZcode": "ZCode", "api.clientConfig.clientPrime": "Prime Agent", "api.clientConfig.clientAside": "Aside", + "api.clientConfig.clientRaycast": "Raycast", "api.clientConfig.copy": "設定をコピー", "api.clientConfig.download": "ダウンロード", "api.clientConfig.loading": "クライアント設定を生成中…", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 63ac304426..885c255ef5 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1103,6 +1103,7 @@ export const ko: Record = { "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", "integrations.tab.aside": "Aside", + "integrations.tab.raycast": "Raycast", "integrations.aside.profilesTitle": "Aside 프로필", "integrations.aside.profilesHint": "선택한 모델을 동기화할 프로필을 고르세요. Aside에서 사용 중인 프로필은 바뀌지 않습니다.", "integrations.aside.all": "모든 프로필 동기화", @@ -1262,6 +1263,10 @@ export const ko: Record = { "integrations.semantics.zcode": "~/.zcode/v2/config.json의 provider.opencodex만 관리하며 Z.ai 로그인과 다른 프로바이더는 변경하지 않습니다. 변경 후 ZCode를 재시작하세요.", "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가 필요합니다.", + "integrations.raycast.proRequired": "Custom Providers는 Raycast Pro 기능입니다. 파일은 기록되지만 Pro 구독이 활성화될 때까지 Raycast는 이를 무시합니다.", + "integrations.raycast.planUnknown": "Raycast 구독 상태를 읽을 수 없습니다. Custom Providers에는 Raycast Pro가 필요합니다.", + "integrations.raycast.revealConfig": "Raycast → 설정 → AI를 열고 「Reveal Providers Config」를 한 번 클릭해 providers 폴더를 만드세요.", "codexAuth.mainAccount": "메인 계정", "codexAuth.logLabel": "로그 라벨", "codexAuth.codexApp": "Codex App", @@ -1593,6 +1598,7 @@ export const ko: Record = { "api.clientConfig.clientZcode": "ZCode", "api.clientConfig.clientPrime": "Prime Agent", "api.clientConfig.clientAside": "Aside", + "api.clientConfig.clientRaycast": "Raycast", "api.clientConfig.copy": "설정 복사", "api.clientConfig.download": "다운로드", "api.clientConfig.loading": "클라이언트 설정 생성 중…", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 9f220ba2b1..d99aefea9c 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1569,6 +1569,7 @@ export const ru: Record = { "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", "integrations.tab.aside": "Aside", + "integrations.tab.raycast": "Raycast", "integrations.aside.profilesTitle": "Профили Aside", "integrations.aside.profilesHint": "Выберите профили, в которые будут добавлены выбранные модели. Активный профиль Aside не изменится.", "integrations.aside.all": "Синхронизировать все профили", @@ -1728,6 +1729,10 @@ export const ru: Record = { "integrations.semantics.zcode": "Управляет только provider.opencodex в ~/.zcode/v2/config.json. Вход Z.ai и другие провайдеры не меняются. Перезапустите ZCode после изменений.", "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.", + "integrations.raycast.proRequired": "Custom Providers — функция Raycast Pro. Файл будет записан, но Raycast игнорирует его, пока не активна подписка Pro.", + "integrations.raycast.planUnknown": "Не удалось прочитать состояние подписки Raycast; для Custom Providers требуется Raycast Pro.", + "integrations.raycast.revealConfig": "Откройте Raycast → Настройки → AI и один раз нажмите «Reveal Providers Config», чтобы папка провайдеров появилась.", "codexAuth.mainAccount": "Основной аккаунт", "codexAuth.logLabel": "Метка журнала", "codexAuth.codexApp": "Codex App", @@ -2061,6 +2066,7 @@ export const ru: Record = { "api.clientConfig.clientZcode": "ZCode", "api.clientConfig.clientPrime": "Prime Agent", "api.clientConfig.clientAside": "Aside", + "api.clientConfig.clientRaycast": "Raycast", "api.clientConfig.copy": "Копировать конфигурацию", "api.clientConfig.download": "Скачать", "api.clientConfig.loading": "Формируется конфигурация клиента…", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index aee152cd39..0f36e3e206 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1576,6 +1576,7 @@ export const tr: Record = { "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", "integrations.tab.aside": "Aside", + "integrations.tab.raycast": "Raycast", "integrations.aside.profilesTitle": "Aside profilleri", "integrations.aside.profilesHint": "Seçili modellerin hangi profillere aktarılacağını seçin. Aside’ın etkin profili değişmez.", "integrations.aside.all": "Tüm profilleri eşitle", @@ -1734,6 +1735,10 @@ export const tr: Record = { "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.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.", + "integrations.raycast.proRequired": "Custom Providers bir Raycast Pro özelliğidir. Dosya yazılır, ancak bir Pro aboneliği etkin olana kadar Raycast bunu yok sayar.", + "integrations.raycast.planUnknown": "Raycast abonelik durumu okunamadı; Custom Providers için Raycast Pro gerekir.", + "integrations.raycast.revealConfig": "Raycast → Ayarlar → AI bölümünü açıp sağlayıcı klasörünün oluşması için „Reveal Providers Config“ seçeneğine bir kez tıklayın.", "integrations.semantics.omp": "Kataloğu yüklemek için OMP'yi yeniden başlatın.", "codexAuth.mainAccount": "Ana Hesap", "codexAuth.logLabel": "Günlük etiketi", @@ -2068,6 +2073,7 @@ export const tr: Record = { "api.clientConfig.clientZcode": "ZCode", "api.clientConfig.clientPrime": "Prime Agent", "api.clientConfig.clientAside": "Aside", + "api.clientConfig.clientRaycast": "Raycast", "api.clientConfig.copy": "JSON Kopyala", "api.clientConfig.download": "İndir", "api.clientConfig.loading": "İstemci konfigürasyonu oluşturuluyor…", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 39c9e2f0b3..92478c88ed 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2164,6 +2164,7 @@ export const zhTW: Record = { "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", "integrations.tab.aside": "Aside", + "integrations.tab.raycast": "Raycast", "integrations.aside.profilesTitle": "Aside 設定檔", "integrations.aside.profilesHint": "選擇要接收所選模型的設定檔。Aside 目前使用的設定檔不會改變。", "integrations.aside.all": "同步所有設定檔", @@ -2323,6 +2324,10 @@ export const zhTW: Record = { "integrations.semantics.zcode": "僅管理 ~/.zcode/v2/config.json 中的 provider.opencodex,不會變更 Z.ai 登入狀態或其他供應商。變更後請重新啟動 ZCode。", "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。", + "integrations.raycast.proRequired": "Custom Providers 是 Raycast Pro 功能。檔案會被寫入,但在 Pro 訂閱生效之前 Raycast 會忽略它。", + "integrations.raycast.planUnknown": "無法讀取 Raycast 訂閱狀態;Custom Providers 需要 Raycast Pro。", + "integrations.raycast.revealConfig": "開啟 Raycast → 設定 → AI,點一次「Reveal Providers Config」,以便建立 providers 資料夾。", "codexAuth.pinned": "已固定", "codexAuth.pinnedHint": "你手動選取了此帳號,因此較高的選擇順序不會越過它。此固定會持續到該帳號用盡、你改選其他帳號,或你變更任一選擇順序為止。", "codexAuth.requestUserInput": "在 Default 模式中要求輸入", @@ -2364,6 +2369,7 @@ export const zhTW: Record = { "api.clientConfig.clientZcode": "ZCode", "api.clientConfig.clientPrime": "Prime Agent", "api.clientConfig.clientAside": "Aside", + "api.clientConfig.clientRaycast": "Raycast", "cws.tabsLabel": "Combo 詳細區段", "cws.field.nativeAlias": "原生 OpenAI 別名", "cws.field.nativeAliasHint": "讓此 combo 擁有受支援的未限定原生 OpenAI 模型 ID。帶有帳號或供應商限定的 OpenAI 路由仍保持獨立。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 1ba4cabfa8..511d71c1eb 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1096,6 +1096,7 @@ export const zh: Record = { "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", "integrations.tab.aside": "Aside", + "integrations.tab.raycast": "Raycast", "integrations.aside.profilesTitle": "Aside 配置文件", "integrations.aside.profilesHint": "选择要接收所选模型的配置文件。Aside 当前使用的配置文件不会改变。", "integrations.aside.all": "同步所有配置文件", @@ -1255,6 +1256,10 @@ export const zh: Record = { "integrations.semantics.zcode": "仅管理 ~/.zcode/v2/config.json 中的 provider.opencodex,不会更改 Z.ai 登录状态或其他提供商。更改后请重启 ZCode。", "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。", + "integrations.raycast.proRequired": "Custom Providers 是 Raycast Pro 功能。文件会被写入,但在 Pro 订阅生效之前 Raycast 会忽略它。", + "integrations.raycast.planUnknown": "无法读取 Raycast 订阅状态;Custom Providers 需要 Raycast Pro。", + "integrations.raycast.revealConfig": "打开 Raycast → 设置 → AI,点击一次“Reveal Providers Config”,以便创建 providers 文件夹。", "codexAuth.mainAccount": "主账号", "codexAuth.logLabel": "日志标签", "codexAuth.codexApp": "Codex App", @@ -1586,6 +1591,7 @@ export const zh: Record = { "api.clientConfig.clientZcode": "ZCode", "api.clientConfig.clientPrime": "Prime Agent", "api.clientConfig.clientAside": "Aside", + "api.clientConfig.clientRaycast": "Raycast", "api.clientConfig.copy": "复制配置", "api.clientConfig.download": "下载", "api.clientConfig.loading": "正在生成客户端配置…", diff --git a/gui/src/pages/integrations/FileIntegrationPage.tsx b/gui/src/pages/integrations/FileIntegrationPage.tsx index 51db75bd9f..2eef2c5cf0 100644 --- a/gui/src/pages/integrations/FileIntegrationPage.tsx +++ b/gui/src/pages/integrations/FileIntegrationPage.tsx @@ -8,6 +8,7 @@ import { markFor } from "../../components/integration-marks"; import IntegrationStateBadge from "./IntegrationStateBadge"; import ConsequenceDialog, { type ConsequenceCopy } from "./ConsequenceDialog"; import RestoreDialog from "./RestoreDialog"; +import RaycastPlanNotice from "./RaycastPlanNotice"; import { RollbackHistory } from "./RollbackHistory"; import { describeRefusal } from "./refusal-copy"; import { @@ -57,6 +58,7 @@ const SEMANTICS_KEY: Record = { zcode: "integrations.semantics.zcode", prime: "integrations.semantics.prime", aside: "integrations.semantics.aside", + raycast: "integrations.semantics.raycast", }; const TAB_LABEL_KEY: Record = { @@ -72,6 +74,7 @@ const TAB_LABEL_KEY: Record = { zcode: "integrations.tab.zcode", prime: "integrations.tab.prime", aside: "integrations.tab.aside", + raycast: "integrations.tab.raycast", }; export default function FileIntegrationPage({ @@ -261,6 +264,8 @@ export default function FileIntegrationPage({

{t(SEMANTICS_KEY[client])}

{status.configPath}

+ {/* Only the raycast envelope carries this; the guard is the field, not the id. */} + {status.raycast && } {status.appliedAt && (

diff --git a/gui/src/pages/integrations/RaycastPlanNotice.tsx b/gui/src/pages/integrations/RaycastPlanNotice.tsx new file mode 100644 index 0000000000..9f08446751 --- /dev/null +++ b/gui/src/pages/integrations/RaycastPlanNotice.tsx @@ -0,0 +1,32 @@ +import { useT } from "../../i18n/shared"; +import { Notice } from "../../ui"; +import type { RaycastInstall } from "./integration-api"; + +/* + * Raycast is the one file client whose `current` state can still mean + * "ignored": Custom Providers is a Pro feature, and the file is read from a + * folder Raycast only creates after a click in its own settings. Neither fact + * is a reason to refuse the write -- the user may be about to subscribe, or + * has already clicked and the folder is seconds old -- so the page writes and + * says so here instead of showing a green badge that overstates the result. + * + * `free` is a warning because it is a known blocker; `unknown` stays muted + * because on Linux and Windows there is no subscription signal to read, and a + * Pro user there must not be told they are not one. + */ +export default function RaycastPlanNotice({ install }: { install: RaycastInstall }) { + const t = useT(); + return ( + <> + {install.plan === "free" && ( + {t("integrations.raycast.proRequired")} + )} + {install.plan === "unknown" && ( +

{t("integrations.raycast.planUnknown")}

+ )} + {!install.aiDirPresent && ( +

{t("integrations.raycast.revealConfig")}

+ )} + + ); +} diff --git a/gui/src/pages/integrations/integration-api.ts b/gui/src/pages/integrations/integration-api.ts index 7a9139f436..38b0e0fe89 100644 --- a/gui/src/pages/integrations/integration-api.ts +++ b/gui/src/pages/integrations/integration-api.ts @@ -14,6 +14,7 @@ export const FILE_INTEGRATION_CLIENTS = [ "zcode", "prime", "aside", + "raycast", ] as const; export type FileIntegrationClientId = (typeof FILE_INTEGRATION_CLIENTS)[number]; @@ -36,6 +37,19 @@ export type IntegrationRefusalReason = | "snapshot_expired" | "write_failed"; +export type RaycastPlan = "pro" | "free" | "unknown"; + +/** + * Raycast's app-side facts, sent only on `/api/client-integrations/raycast`. + * Custom Providers is a Pro feature, so a `current` file can still be one + * Raycast ignores — this is what lets the page say so instead of showing green. + */ +export interface RaycastInstall { + plan: RaycastPlan; + appPath: string | null; + aiDirPresent: boolean; +} + export interface IntegrationStatus { clientId: FileIntegrationClientId; state: IntegrationState; @@ -49,6 +63,7 @@ export interface IntegrationStatus { /** Aside's explicit account-backed profile scope and desired sync state. */ profileId?: number; enabled?: boolean; + raycast?: RaycastInstall; } export interface IntegrationStateListEnvelope { diff --git a/gui/src/pages/integrations/integration-tabs.ts b/gui/src/pages/integrations/integration-tabs.ts index 33c4f04358..99502bde87 100644 --- a/gui/src/pages/integrations/integration-tabs.ts +++ b/gui/src/pages/integrations/integration-tabs.ts @@ -46,6 +46,7 @@ export const TABS: readonly TabDefinition[] = [ { id: "zcode", hash: "integrations/zcode", labelKey: "integrations.tab.zcode" }, { 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" }, ] as const; export const FILE_CLIENTS = new Set([ @@ -61,4 +62,5 @@ export const FILE_CLIENTS = new Set([ "zcode", "prime", "aside", + "raycast", ]); diff --git a/gui/src/pages/integrations/overview-clients.ts b/gui/src/pages/integrations/overview-clients.ts index 4dd347b90c..7932cf5648 100644 --- a/gui/src/pages/integrations/overview-clients.ts +++ b/gui/src/pages/integrations/overview-clients.ts @@ -152,6 +152,7 @@ const FILE_LABEL_KEY: Record = { zcode: "integrations.tab.zcode", prime: "integrations.tab.prime", aside: "integrations.tab.aside", + raycast: "integrations.tab.raycast", }; /** A file client's block is in the file for both `current` and `stale`. */ diff --git a/gui/tests/client-config-panel.test.tsx b/gui/tests/client-config-panel.test.tsx index 8acc44e9ee..ea8210e7e4 100644 --- a/gui/tests/client-config-panel.test.tsx +++ b/gui/tests/client-config-panel.test.tsx @@ -170,8 +170,8 @@ function rowButton(container: HTMLElement, name: string, label: string): HTMLBut .find(el => el.textContent?.trim() === label)!; } -test("the API download surface includes DSH, MiniMax Code and Aside as clients", () => { - expect(CLIENTS).toEqual(["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside"]); +test("the API download surface includes DSH, MiniMax Code, Aside and Raycast as clients", () => { + expect(CLIENTS).toEqual(["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast"]); 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"); diff --git a/gui/tests/fr-localization.test.ts b/gui/tests/fr-localization.test.ts index 221de55d00..8c1ca29ada 100644 --- a/gui/tests/fr-localization.test.ts +++ b/gui/tests/fr-localization.test.ts @@ -118,6 +118,8 @@ const INTENTIONAL_ENGLISH = new Set([ "api.clientConfig.clientPrime", "integrations.tab.aside", "api.clientConfig.clientAside", + "integrations.tab.raycast", + "api.clientConfig.clientRaycast", "models.reasoningEffort.minimal", "models.reasoningEffort.max", "pws.pacingRpmUnit", diff --git a/gui/tests/integration-marks.test.ts b/gui/tests/integration-marks.test.ts index b964bc4ce1..b13387d1b6 100644 --- a/gui/tests/integration-marks.test.ts +++ b/gui/tests/integration-marks.test.ts @@ -61,15 +61,16 @@ test("no multi-color asset is masked", () => { /* * The inverse rule, and the one that cannot be derived from the file: a mark may * be a single ink and still not be a masking candidate, because that ink is the - * brand. openai.svg is #10A37F and deepseek-harness.svg is #4d6bfe; masking - * either repaints a trademark in the theme's text color. Pinned with their inks - * so a vendor changing its asset shows up here rather than silently satisfying - * the assertion. + * brand. openai.svg is #10A37F, deepseek-harness.svg is #4d6bfe and raycast.svg + * is #FF6363; masking any of them repaints a trademark in the theme's text + * color. Pinned with their inks so a vendor changing its asset shows up here + * rather than silently satisfying the assertion. */ test("a single-ink asset whose ink is a brand color is not masked", () => { for (const [src, ink] of [ ["/provider-icons/openai.svg", "#10a37f"], ["/provider-icons/deepseek-harness.svg", "#4d6bfe"], + ["/provider-icons/raycast.svg", "#ff6363"], ] as const) { expect(MASKED_MARKS.has(src), `${src} must not be masked`).toBe(false); expect([...inksOf(bodyOf(src))], `${src} ink changed upstream`).toEqual([ink]); diff --git a/gui/tests/integrations-api.test.ts b/gui/tests/integrations-api.test.ts index 4338d9ead8..eea7dcfa0c 100644 --- a/gui/tests/integrations-api.test.ts +++ b/gui/tests/integrations-api.test.ts @@ -16,9 +16,9 @@ import { const originalFetch = globalThis.fetch; -test("DSH and Aside are file integration clients", () => { +test("DSH, Aside and Raycast are file integration clients", () => { expect(FILE_INTEGRATION_CLIENTS).toEqual([ - "opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", + "opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast", ]); }); diff --git a/gui/tests/integrations-overview-rows.test.ts b/gui/tests/integrations-overview-rows.test.ts index 5bd673f849..54809a4422 100644 --- a/gui/tests/integrations-overview-rows.test.ts +++ b/gui/tests/integrations-overview-rows.test.ts @@ -290,12 +290,17 @@ test("every client counts toward the summary, not just the file clients", () => test("an unsettled file list renders unknown rows instead of dropping them", () => { const built = buildOverviewRows(sources({ clients: [], clientsSettled: false })); - expect(built.rows).toHaveLength(17); + expect(built.rows).toHaveLength(18); expect(rowById(built, "omp").state).toBe("unknown"); expect(rowById(built, "mcode").state).toBe("unknown"); expect(rowById(built, "zcode").state).toBe("unknown"); expect(rowById(built, "prime").state).toBe("unknown"); expect(rowById(built, "aside").state).toBe("unknown"); + expect(rowById(built, "raycast")).toMatchObject({ + hash: "integrations/raycast", + labelKey: "integrations.tab.raycast", + state: "unknown", + }); expect(rowById(built, "kimi").state).toBe("unknown"); expect(rowById(built, "dsh")).toMatchObject({ hash: "integrations/dsh", diff --git a/gui/tests/locale-parity.test.ts b/gui/tests/locale-parity.test.ts index 11976154c9..5ea6785493 100644 --- a/gui/tests/locale-parity.test.ts +++ b/gui/tests/locale-parity.test.ts @@ -130,6 +130,8 @@ const ZH_TW_KEEP_ENGLISH: ReadonlySet = new Set([ "api.clientConfig.clientPrime", "integrations.tab.aside", "api.clientConfig.clientAside", + "integrations.tab.raycast", + "api.clientConfig.clientRaycast", "integrations.codex.title", // Provider proper nouns kept in English "provider.name.commandCodeAuth", diff --git a/gui/tests/raycast-plan-notice.test.tsx b/gui/tests/raycast-plan-notice.test.tsx new file mode 100644 index 0000000000..4a8d44a77d --- /dev/null +++ b/gui/tests/raycast-plan-notice.test.tsx @@ -0,0 +1,51 @@ +import { expect, test } from "bun:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { I18nContext, type TFn } from "../src/i18n/shared"; +import RaycastPlanNotice from "../src/pages/integrations/RaycastPlanNotice"; +import type { RaycastInstall } from "../src/pages/integrations/integration-api"; + +/* + * Raycast reads providers.yaml only on a Pro plan and only from a folder it + * creates itself, so a `current` badge can be a lie. The notice is the one place + * that lie is corrected, and each of its three lines answers a different + * question; a regression that drops one leaves the page green and silent. + */ + +const echoT: TFn = key => key; + +function render(install: RaycastInstall): string { + return renderToStaticMarkup( + createElement( + I18nContext.Provider, + { value: { locale: "en", setLocale: () => {}, t: echoT } }, + createElement(RaycastPlanNotice, { install }), + ), + ); +} + +test("a Pro install with the ai folder renders nothing", () => { + expect(render({ plan: "pro", appPath: "/Applications/Raycast.app", aiDirPresent: true })).toBe(""); +}); + +test("a free plan is a warning notice, never a refusal", () => { + const markup = render({ plan: "free", appPath: "/Applications/Raycast.app", aiDirPresent: true }); + expect(markup).toContain("notice-warn"); + expect(markup).toContain("integrations.raycast.proRequired"); + expect(markup).not.toContain("notice-err"); + expect(markup).not.toContain("integrations.raycast.planUnknown"); +}); + +test("an unreadable plan stays muted, because non-macOS hosts have no signal", () => { + const markup = render({ plan: "unknown", appPath: null, aiDirPresent: true }); + expect(markup).toContain('data-raycast-plan="unknown"'); + expect(markup).toContain("integrations.raycast.planUnknown"); + expect(markup).not.toContain("notice-warn"); +}); + +test("a missing ai folder adds the reveal hint independently of the plan", () => { + const markup = render({ plan: "free", appPath: "/Applications/Raycast.app", aiDirPresent: false }); + expect(markup).toContain("integrations.raycast.proRequired"); + expect(markup).toContain('data-raycast-ai-dir="absent"'); + expect(markup).toContain("integrations.raycast.revealConfig"); +}); diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 0fbe7cf746..eea9df0679 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -686,6 +686,7 @@ "install-scripts.test.ts": "ci-workflows", "integrations-invariants.test.ts": "gui", "integrations-journal.test.ts": "clients", + "integrations-merge.test.ts": "clients", "integrations-serialize.test.ts": "clients", "integrations-state.test.ts": "clients", "integrations-writer.test.ts": "clients", @@ -994,6 +995,8 @@ "reserve-quota-scope.test.ts": "codex-integration", "rate-limit-reset-credits.test.ts": "gui", "rate-limit-retry.test.ts": "providers", + "raycast-client.test.ts": "clients", + "raycast-detect.test.ts": "clients", "reasoning-effort.test.ts": "codex-integration", "reasoning-replay-identity.test.ts": "adapters", "reasoning-replay-robustness.test.ts": "adapters", diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 6d018536c7..c884bb2e5d 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -403,7 +403,7 @@ const commandRunners: Record = { }, config, port: live.port, - }, ["mcode", "pi"])); + }, ["mcode", "pi", "raycast"])); } catch (error) { console.warn(`Client integrations were not refreshed: ${error instanceof Error ? error.message : String(error)}`); } diff --git a/src/cli/help.ts b/src/cli/help.ts index 0b3652ab59..0cd6bec4dc 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -77,7 +77,7 @@ Usage: ocx memory [--json] Alias of ocx observe memory ocx api-key Alias of ocx access key ocx access External API keys and endpoint information - ocx export --client Print a client config wired to the running proxy (12 clients) + ocx export --client Print a client config wired to the running proxy (13 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/index.ts b/src/cli/index.ts index 7b863630b9..9d9f08951c 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -92,6 +92,15 @@ import { grokSyncFailureMessage, reconcileEnsureDesiredIntegrations, } from "./ensure-desired-integrations"; +import { refreshOwnedCatalogIntegrations } from "../integrations/catalog-refresh"; +import { loadExportModels } from "../server/management/model-rows"; + +import { removeOwnedConfigAfterDesktopCleanup } from "./uninstall-client-state"; +import { withProcessRuntimeProvenance } from "../lib/bun-runtime"; +import { selfLaunchArgv } from "../lib/self-launch-argv"; +import { initializeNodeLauncherContext } from "./launcher-context"; +import { createLocalAttestationSecret } from "../lib/local-management-attestation"; +import { MEMORY_DRAIN_RESTART_MS, REPLACEMENT_READY_TIMEOUT_MS } from "../lib/system-restart-contract"; /** * A failed shell-hook reconcile is not cosmetic: a stale hook keeps sourcing @@ -105,13 +114,25 @@ function reportShellHookFailure(result: { state: "installed" | "absent" | "faile console.warn(" Check ~/.zshrc for the '# opencodex claude-env hook' block."); } - -import { removeOwnedConfigAfterDesktopCleanup } from "./uninstall-client-state"; -import { withProcessRuntimeProvenance } from "../lib/bun-runtime"; -import { selfLaunchArgv } from "../lib/self-launch-argv"; -import { initializeNodeLauncherContext } from "./launcher-context"; -import { createLocalAttestationSecret } from "../lib/local-management-attestation"; -import { MEMORY_DRAIN_RESTART_MS, REPLACEMENT_READY_TIMEOUT_MS } from "../lib/system-restart-contract"; +async function refreshOwnedRaycastCatalog( + config: ReturnType, + port: number, +): Promise { + try { + const outcomes = await refreshOwnedCatalogIntegrations({ + models: () => loadExportModels(config), + config, + port, + }, ["raycast"]); + for (const outcome of outcomes) { + if (!outcome.ok) { + console.error(`⚠️ Raycast integration was not refreshed: ${outcome.reason}`); + } + } + } catch (error) { + console.error(`⚠️ Raycast integration was not refreshed: ${error instanceof Error ? error.message : String(error)}`); + } +} initializeNodeLauncherContext(); @@ -493,6 +514,7 @@ async function handleStart(options: { block?: boolean } = {}) { }, ); if (!startupSync.ran) console.log(" Codex integration OFF; startup left Codex native."); + await refreshOwnedRaycastCatalog(config, port); // #1046: one warning per startup, after BOTH writes. The server's cache // invalidation happens first and the catalog sync second, so the mtime is only // final here — and neither write site warns on its own, or a boot that hits @@ -558,6 +580,7 @@ async function handleEnsure(options: { existingIsSuccess?: boolean } = {}): Prom return null; }); if (synced?.status === "skipped") console.log(" Codex integration OFF; startup left Codex native."); + await refreshOwnedRaycastCatalog(config, live.port); // Ensure env file exists for already-running proxy (may have been deleted or pre-dates this feature). const systemEnv = await injectSystemEnv(live.port, config).catch(() => ({ injected: false })); reportShellHookFailure(reconcileShellHook(systemEnv.injected)); @@ -602,6 +625,7 @@ async function handleEnsure(options: { existingIsSuccess?: boolean } = {}): Prom return null; }); if (synced?.status === "skipped") console.log(" Codex integration OFF; startup left Codex native."); + await refreshOwnedRaycastCatalog(config, port); // The child opens /healthz before its best-effort roster reconcile. Await the same idempotent // operation in the parent so `ocx ensure` cannot report success while stale ocx-*.md files are // still observable. Always use the live port, including fallback-port starts. diff --git a/src/cli/integrations.ts b/src/cli/integrations.ts index bcb87d5d18..89ab3ee046 100644 --- a/src/cli/integrations.ts +++ b/src/cli/integrations.ts @@ -161,6 +161,39 @@ export async function handleGrokCommand(argv: string[], deps: RuntimeApiDeps = { }); } +/** The Raycast-only block the single-client route adds; see IntegrationStateEnvelope. */ +interface RaycastStatusBlock { + plan: string; + aiDirPresent: boolean; +} + +function raycastBlock(result: unknown): RaycastStatusBlock | null { + if (!result || typeof result !== "object") return null; + const block = (result as { raycast?: unknown }).raycast; + if (!block || typeof block !== "object") return null; + const { plan, aiDirPresent } = block as Partial; + return typeof plan === "string" && typeof aiDirPresent === "boolean" ? { plan, aiDirPresent } : null; +} + +/** + * Text view of one client's status. + * + * Raycast carries an extra block, and the generic summary would print it as + * three dotted keys. A `current` file that Raycast ignores for want of a Pro + * subscription is the one fact this view must not bury, so `plan` gets its own + * line and a missing `ai` folder gets the instruction that creates it. + */ +function singleClientStatusLines(result: unknown): string[] { + const raycast = raycastBlock(result); + if (!raycast) return summaryLines(result); + const rest = Object.fromEntries(Object.entries(result as Record).filter(([key]) => key !== "raycast")); + const lines = [...summaryLines(rest), `plan: ${raycast.plan}`]; + if (!raycast.aiDirPresent) { + lines.push('Open Raycast → Settings → AI → "Reveal Providers Config" once so the ai folder exists.'); + } + return lines; +} + /** * The headless half of the client-integration toggle. * @@ -197,7 +230,7 @@ export async function handleClientIntegrationCommand( : [String((result as { error?: string }).error ?? "No Aside profiles found.")] : rows ? rows.map(row => `${String(row.clientId)}: ${String(row.state)}${row.installed ? "" : " (not installed)"}`) - : summaryLines(result)); + : singleClientStatusLines(result)); return; } diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 00ff25ed52..467a0f7971 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -287,8 +287,8 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ { name: "api-key", usage: "ocx api-key ...", summary: "Alias of ocx access key." }, { name: "export", - usage: "ocx export --client [--json] [--out ] [--force]", - summary: "Print a client config (OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent, Aside) wired to the running proxy.", + usage: "ocx export --client [--json] [--out ] [--force]", + summary: "Print a client config (OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent, Aside, Raycast) wired to the running proxy.", details: [ "--json prints the generated document as JSON on stdout; use --out for the client's native format.", "--out writes the native config there and refuses to replace an existing file without --force.", diff --git a/src/clients/config-export.ts b/src/clients/config-export.ts index 372abcc00e..8fb42f311d 100644 --- a/src/clients/config-export.ts +++ b/src/clients/config-export.ts @@ -37,6 +37,8 @@ export type { OmpModelEntry, OmpProviderBlock, OmpGeneratedConfig } from "./conf export type { ZcodeModelEntry, ZcodeProviderBlock, ZcodeGeneratedConfig } from "./config-export/zcode"; export type { DshReasoningEffort, DshWireReasoningEffort, DshModelEntry, DshProviderBlock, DshGeneratedConfig } from "./config-export/dsh"; export type { McodeProviderBlock, McodeModelEntry, McodeGeneratedConfig } from "./config-export/mcode"; +export type { RaycastAbility, RaycastAbilityName, RaycastModelEntry, RaycastProviderEntry, RaycastGeneratedConfig } from "./config-export/raycast"; +export { buildRaycastClientConfig, summarizeRaycast, buildRaycastContribution } from "./config-export/raycast"; import type { OpencodeLaunchEnv, OpencodeCatalogModel, ExportContext, PiModelEntry, ManagedContribution, ManagedFragment, ExportClientId, ExportClientSpec } from "./config-export/contracts"; import { OPENCODE_API_KEY_ENV_REF, OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG, OPENCODE_CONFIG_SCHEMA, OPENCODE_PROVIDER_ID, PI_API_DIALECT, LOOPBACK_API_KEY_PLACEHOLDER, HERMES_API_KEY_ENV_REF, OPENCLAW_API_KEY_ENV_REF, GAJAE_API_KEY_ENV, OPENCODE_API_KEY_ENV, HERMES_API_KEY_ENV, OPENCLAW_API_KEY_ENV } from "./config-export/constants"; @@ -45,6 +47,7 @@ import { buildOmpClientConfig, summarizeOmp, buildOmpContribution } from "./conf import { buildDshClientConfig, summarizeDsh, buildDshContribution } from "./config-export/dsh"; import { buildMcodeClientConfig, summarizeMcode, buildMcodeContribution } from "./config-export/mcode"; import { buildZcodeClientConfig, summarizeZcode, buildZcodeContribution } from "./config-export/zcode"; +import { buildRaycastClientConfig, summarizeRaycast, buildRaycastContribution } from "./config-export/raycast"; @@ -533,6 +536,22 @@ export function asideConfigPath(env: OpencodeLaunchEnv = process.env, home: stri return join(asideAccountDir(env, home), "models.json"); } +/** + * Raycast's Custom Providers directory. Raycast hard-codes + * `~/.config/raycast/ai` on macOS AND Windows: it neither honors + * `XDG_CONFIG_HOME` nor ships a variable of its own that relocates the file, so + * unlike `opencodeGlobalConfigPath` there is no override to mirror and the env + * parameter exists only to keep the resolver signature uniform with the rest. + */ +export function raycastAiDir(_env: OpencodeLaunchEnv = process.env, home: string = homedir()): string { + return join(home, ".config", "raycast", "ai"); +} + +/** The providers file Raycast watches (manual.raycast.com/ai/custom-providers). */ +export function raycastConfigPath(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string { + return join(raycastAiDir(env, home), "providers.yaml"); +} + /** Endpoint plus admission, identical for the V1 `options` and V2 `settings` field. */ function opencodeProviderConnection(baseURL: string, config: OcxConfig): OpencodeProviderConnection { const options: OpencodeProviderConnection = { baseURL }; @@ -1259,6 +1278,23 @@ export const EXPORT_CLIENTS: Record = { // bind would generate a config that 401s. loopbackOnly: true, }, + raycast: { + id: "raycast", + // Not a bare `providers.yaml`: same Downloads-folder collision argument as + // `aside-models.json`. + filename: "raycast-providers.yaml", + destination: env => raycastConfigPath(env), + apiKeyEnv: "", + exportHint: "Raycast reads providers.yaml with no api_keys entry; loopback needs no key.", + build: buildRaycastClientConfig, + format: "yaml", + summarize: summarizeRaycast, + buildContribution: buildRaycastContribution, + // Raycast's provider entry has no header field, and its `api_keys` value + // is read literally (no env interpolation), so the only way to admit a + // remote bind would be a plaintext secret on disk. Refuse instead. + 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 039d7eaaf0..c888a4c257 100644 --- a/src/clients/config-export/contracts.ts +++ b/src/clients/config-export/contracts.ts @@ -93,7 +93,8 @@ export type ExportClientId = | "mcode" | "zcode" | "prime" - | "aside"; + | "aside" + | "raycast"; export interface ExportClientSpec { id: ExportClientId; diff --git a/src/clients/config-export/raycast.ts b/src/clients/config-export/raycast.ts new file mode 100644 index 0000000000..2cf9396ed5 --- /dev/null +++ b/src/clients/config-export/raycast.ts @@ -0,0 +1,86 @@ +import { exportPresentationLabel } from "../model-presentation"; +import { OPENCODE_PROVIDER_ID } from "./constants"; +import type { ExportContext, ManagedContribution } from "./contracts"; +import { authoritativeContextWindow, normalizeExportModels, singleFragment } from "./model-metadata"; + +export interface RaycastAbility { + supported: boolean; +} + +export type RaycastAbilityName = + | "temperature" + | "vision" + | "system_message" + | "tools" + | "reasoning_effort"; + +export interface RaycastModelEntry { + id: string; + name: string; + context?: number; + abilities: Record; +} + +export interface RaycastProviderEntry { + id: string; + name: string; + base_url: string; + models: RaycastModelEntry[]; +} + +export interface RaycastGeneratedConfig { + providers: RaycastProviderEntry[]; +} + +/** + * Raycast appends `/chat/completions` to `base_url`, so the proxy's `/v1` + * root is passed through unchanged. The format has no safe credential + * interpolation, which is why the registry exposes it only on loopback. + */ +export function buildRaycastClientConfig(ctx: ExportContext): RaycastGeneratedConfig { + const models: RaycastModelEntry[] = normalizeExportModels(ctx.models).map(model => { + const hasLadder = (model.reasoningEfforts?.length ?? 0) > 0; + const context = authoritativeContextWindow(model.contextWindow); + return { + id: model.namespaced, + name: exportPresentationLabel(model), + ...(context !== undefined ? { context } : {}), + abilities: { + temperature: { supported: !hasLadder }, + vision: { supported: model.inputModalities?.includes("image") ?? false }, + system_message: { supported: true }, + tools: { supported: true }, + reasoning_effort: { supported: hasLadder }, + }, + }; + }); + return { + providers: [ + { id: OPENCODE_PROVIDER_ID, name: "OpenCodex", base_url: ctx.baseUrl, models }, + ], + }; +} + +export function summarizeRaycast( + document: unknown, +): { modelCount: number; modelsWithoutLimits: number } { + const providers = (document as RaycastGeneratedConfig | undefined)?.providers ?? []; + const models = providers.find(provider => provider.id === OPENCODE_PROVIDER_ID)?.models ?? []; + return { + modelCount: models.length, + modelsWithoutLimits: models.filter(model => model.context === undefined).length, + }; +} + +/** + * Raycast stores providers in a sequence. The stable id selector owns only + * OpenCodex's element, preserving user-defined providers around it. + */ +export function buildRaycastContribution(ctx: ExportContext): ManagedContribution { + const doc = buildRaycastClientConfig(ctx); + return singleFragment( + "raycast", + ["providers", `[id=${OPENCODE_PROVIDER_ID}]`], + doc.providers[0]!, + ); +} diff --git a/src/clients/model-presentation.ts b/src/clients/model-presentation.ts new file mode 100644 index 0000000000..9a5f9ae3c3 --- /dev/null +++ b/src/clients/model-presentation.ts @@ -0,0 +1,61 @@ +import { CURSOR_CAPABILITIES } from "../adapters/cursor/catalog"; +import { nativeOpenAiCapabilityDisplayName } from "../codex/catalog/metadata"; +import type { ExportModel } from "./config-export/contracts"; + +const KNOWN_ACRONYMS = new Set(["gpt", "glm", "grok"]); + +function titleWord(word: string): string { + const lower = word.toLowerCase(); + if (KNOWN_ACRONYMS.has(lower)) return lower.toUpperCase(); + if (/^\d+\.\d+$/.test(word)) return word; + return lower.charAt(0).toUpperCase() + lower.slice(1); +} + +/** + * Last-resort label when no catalog or operator name exists. Joins dotted version + * tails (`5-1` → `5.1`, `2-5` → `2.5`) so Raycast reads like a product name + * instead of a slug. + */ +function humanizeModelSlug(modelId: string): string { + const parts = modelId.split("-"); + const words: string[] = []; + for (let index = 0; index < parts.length; index += 1) { + const part = parts[index]!; + const next = parts[index + 1]; + if (/^\d+$/.test(part) && next !== undefined && /^\d+$/.test(next)) { + words.push(`${part}.${next}`); + index += 1; + continue; + } + words.push(part); + } + return words.map(titleWord).join(" "); +} + +function wireModelId(model: ExportModel): string { + if (model.id?.trim()) return model.id.trim(); + const slash = model.namespaced.lastIndexOf("/"); + return slash >= 0 ? model.namespaced.slice(slash + 1) : model.namespaced; +} + +/** + * Human-facing model label for clients whose picker shows `name` verbatim. + * + * Raycast has no second column for provider, so the shared `exportModelLabel` + * suffix `(anthropic)` would be noise — and its fallback is the raw wire id + * because management slugs are deliberately withheld from ExportModel. Resolve + * operator labels first, then the canonical capability tables, then a slug + * humanizer. + */ +export function exportPresentationLabel(model: ExportModel): string { + const configured = model.displayName?.trim(); + if (configured) return configured; + const wireId = wireModelId(model); + const fromCursor = CURSOR_CAPABILITIES[wireId]?.displayName; + if (fromCursor) return fromCursor; + if (model.native) { + const native = nativeOpenAiCapabilityDisplayName(wireId); + if (native) return native; + } + return humanizeModelSlug(wireId); +} diff --git a/src/integrations/catalog-refresh.ts b/src/integrations/catalog-refresh.ts index 8efb990002..8b89762f30 100644 --- a/src/integrations/catalog-refresh.ts +++ b/src/integrations/catalog-refresh.ts @@ -10,7 +10,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"], + clientIds: readonly IntegrationClientId[] = ["pi", "aside", "raycast"], ): Promise { let models: Promise | undefined; const loadModels = () => models ??= Promise.resolve().then(() => diff --git a/src/integrations/merge.ts b/src/integrations/merge.ts index 4dccd48e50..768ddc755f 100644 --- a/src/integrations/merge.ts +++ b/src/integrations/merge.ts @@ -20,18 +20,103 @@ function clone(value: T): T { return value === undefined ? value : (JSON.parse(JSON.stringify(value)) as T); } -/** Write `value` at `path`, creating intermediate objects. Returns a new document. */ +/** + * `[field=value]` addresses the ONE element of a sequence whose `field` equals + * `value`. Raycast keeps its providers as a YAML list, so the element is the + * smallest thing we can own there; an index would move under us the moment + * the user reordered their own entries. Any other segment is a plain key. + */ +const ARRAY_SELECTOR = /^\[([A-Za-z_][A-Za-z0-9_]*)=([^\]]+)\]$/u; + +export type PathSegment = + | { kind: "key"; key: string } + | { kind: "select"; field: string; value: string }; + +export function parseSegment(raw: string): PathSegment { + const match = ARRAY_SELECTOR.exec(raw); + if (!match) return { kind: "key", key: raw }; + return { kind: "select", field: match[1]!, value: match[2]! }; +} + +/** + * Thrown when a selector matches more than one element. Picking either one + * would silently rewrite an entry the user may have written; the writer maps + * this to an `unsafe` refusal instead. + */ +export class AmbiguousSelectorError extends Error { + constructor(field: string, value: string) { + super(`more than one entry has ${field}=${value}`); + this.name = "AmbiguousSelectorError"; + } +} + +/** The index of the element a selector names, -1 when none matches. */ +function selectIndex(items: readonly unknown[], field: string, value: string): number { + const matches: number[] = []; + items.forEach((item, index) => { + if (isPlainRecord(item) && item[field] === value) matches.push(index); + }); + if (matches.length > 1) throw new AmbiguousSelectorError(field, value); + return matches[0] ?? -1; +} + +function assertNever(segment: never): never { + throw new Error(`unknown path segment ${JSON.stringify(segment)}`); +} + +/** + * Write `value` at `path`, creating intermediate containers. Returns a new document. + * + * A `key` segment descends through a record, creating `{}` where the slot is + * absent or holds something else. A `select` segment descends through an + * array the same way, creating `[]`; a missing element is pushed, a matching + * one is replaced in place so the user's ordering survives. + */ export function setPath(doc: unknown, path: readonly string[], value: unknown): unknown { if (path.length === 0) throw new Error("setPath needs a non-empty path"); - const root: Record = isPlainRecord(doc) ? clone(doc) : {}; - let cursor = root; - for (const key of path.slice(0, -1)) { - const next = cursor[key]; - if (!isPlainRecord(next)) cursor[key] = {}; - cursor = cursor[key] as Record; + /* + * `parent[slot]` is the position the segment just consumed addresses. The + * root sits in a one-key holder so the first segment needs no special case: + * a non-record document is replaced by `{}` exactly as before. + */ + const holder: Record = { root: isPlainRecord(doc) ? clone(doc) : {} }; + let parent: Record | unknown[] = holder; + let slot: string | number = "root"; + const read = (): unknown => (Array.isArray(parent) ? parent[slot as number] : parent[slot as string]); + const write = (next: unknown): void => { + if (Array.isArray(parent)) parent[slot as number] = next; + else parent[slot as string] = next; + }; + for (const raw of path) { + const segment = parseSegment(raw); + switch (segment.kind) { + case "key": { + if (!isPlainRecord(read())) write({}); + parent = read() as Record; + slot = segment.key; + break; + } + case "select": { + if (!Array.isArray(read())) write([]); + const items = read() as unknown[]; + const found = selectIndex(items, segment.field, segment.value); + parent = items; + if (found >= 0) { + slot = found; + } else { + // Seed the element so the selector stays true for whatever a deeper + // segment writes into it; a last-position select replaces it whole. + slot = items.length; + items.push({ [segment.field]: segment.value }); + } + break; + } + default: + return assertNever(segment); + } } - cursor[path[path.length - 1]!] = clone(value); - return root; + write(clone(value)); + return holder.root; } /** @@ -54,27 +139,53 @@ export function deletePath( ): { doc: unknown; removed: boolean } { if (!isPlainRecord(doc) || path.length === 0) return { doc, removed: false }; const root = clone(doc) as Record; - const chain: Record[] = [root]; - let cursor: Record = root; - for (const key of path.slice(0, -1)) { - const next = cursor[key]; - if (!isPlainRecord(next)) return { doc: root, removed: false }; - cursor = next; - chain.push(cursor); + // `chain[i]` is the container segment `i` is resolved against; `slots[i]` is + // the key or index it resolved to, so the prune walk can delete by position. + const chain: (Record | unknown[])[] = [root]; + const slots: (string | number)[] = []; + for (let depth = 0; depth < path.length; depth += 1) { + const container = chain[depth]!; + const segment = parseSegment(path[depth]!); + switch (segment.kind) { + case "key": { + if (Array.isArray(container) || !(segment.key in container)) return { doc: root, removed: false }; + slots.push(segment.key); + chain.push(container[segment.key] as Record | unknown[]); + break; + } + case "select": { + if (!Array.isArray(container)) return { doc: root, removed: false }; + const found = selectIndex(container, segment.field, segment.value); + if (found < 0) return { doc: root, removed: false }; + slots.push(found); + chain.push(container[found] as Record | unknown[]); + break; + } + default: + return assertNever(segment); + } + // Only the leaf may be a scalar; walking into one means the path is absent. + if (depth < path.length - 1) { + const next = chain[depth + 1]; + if (!isPlainRecord(next) && !Array.isArray(next)) return { doc: root, removed: false }; + } } - const leaf = path[path.length - 1]!; - if (!(leaf in cursor)) return { doc: root, removed: false }; - delete cursor[leaf]; + const remove = (container: Record | unknown[], slot: string | number): void => { + if (Array.isArray(container)) container.splice(slot as number, 1); + else delete container[slot as string]; + }; + remove(chain[path.length - 1]!, slots[path.length - 1]!); /* * Walk back up, pruning only containers this deletion emptied AND that we * created. The root is never pruned. */ - for (let index = chain.length - 1; index >= 1; index -= 1) { + for (let index = path.length - 1; index >= 1; index -= 1) { const container = chain[index]!; - if (Object.keys(container).length > 0) break; + const empty = Array.isArray(container) ? container.length === 0 : Object.keys(container).length === 0; + if (!empty) break; const containerPath = path.slice(0, index).join("\u0000"); if (!createdContainers.has(containerPath)) break; - delete chain[index - 1]![path[index - 1]!]; + remove(chain[index - 1]!, slots[index - 1]!); } return { doc: root, removed: true }; } @@ -121,9 +232,31 @@ export function createdContainerPaths( for (const fragment of contribution.fragments) { let cursor: unknown = doc; for (let depth = 0; depth < fragment.path.length - 1; depth += 1) { - const key = fragment.path[depth]!; - const next = isPlainRecord(cursor) ? cursor[key] : undefined; - if (!isPlainRecord(next)) { + const segment = parseSegment(fragment.path[depth]!); + let next: unknown; + switch (segment.kind) { + case "key": { + /* + * The container this key must hold is whatever the NEXT segment + * descends into: an array when that is a selector, a record + * otherwise. Either one is ours to create when absent. + */ + const nextSegment = parseSegment(fragment.path[depth + 1]!); + next = isPlainRecord(cursor) ? cursor[segment.key] : undefined; + if (nextSegment.kind === "select" ? !Array.isArray(next) : !isPlainRecord(next)) next = undefined; + break; + } + case "select": { + // A selector that matches nothing means setPath will push the element. + next = Array.isArray(cursor) + ? cursor.find(item => isPlainRecord(item) && item[segment.field] === segment.value) + : undefined; + break; + } + default: + return assertNever(segment); + } + if (next === undefined) { created.add(fragment.path.slice(0, depth + 1).join("\u0000")); cursor = undefined; continue; diff --git a/src/integrations/raycast-detect.ts b/src/integrations/raycast-detect.ts new file mode 100644 index 0000000000..5e79578f46 --- /dev/null +++ b/src/integrations/raycast-detect.ts @@ -0,0 +1,110 @@ +/** + * Detect a Raycast install and whether Custom Providers can take effect. + * + * Custom Providers is a Raycast Pro feature: Raycast reads + * `~/.config/raycast/ai/providers.yaml` only while a subscription is active, and + * the `ai` directory itself only exists once the user has clicked "Reveal + * Providers Config" in Settings > AI. Neither fact stops the writer — the plan + * (devlog/_plan/260904_raycast_integration/000_plan.md) makes a free plan a + * WARNING, never a refusal — so this module only answers what status and the + * GUI need to explain a file that is written but ignored. + * + * Detection is read-only and injectable, like cursor-detect.ts: nothing here + * touches the Raycast install or its preferences, and the tests run against + * stubbed deps rather than the machine they execute on. + */ +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { posix, win32 } from "node:path"; + +export type RaycastPlan = "pro" | "free" | "unknown"; + +export interface RaycastInstall { + /** The app bundle or install directory, or null when none of the well-known locations exist. */ + appPath: string | null; + /** `~/.config/raycast/ai` exists — the install signal the registry uses. */ + aiDirPresent: boolean; + plan: RaycastPlan; +} + +export interface RaycastDetectDeps { + platform: string; + homedir: string; + env: Record; + exists(path: string): boolean; + /** stdout of `defaults read ` trimmed, or null when the command fails / is unavailable. */ + readDefault(domain: string, key: string): string | null; +} + +/** + * The preference Raycast writes for its subscription state. Read through + * `defaults` rather than by parsing the plist: cfprefsd caches writes, so the + * file on disk can lag what the running app believes. + */ +const RAYCAST_DEFAULTS_DOMAIN = "com.raycast.macos.v1"; +const RAYCAST_SUBSCRIPTION_KEY = "subscriptions_active"; + +export function realRaycastDetectDeps(): RaycastDetectDeps { + return { + platform: process.platform, + homedir: homedir(), + env: process.env, + exists: path => { + try { + return existsSync(path); + } catch { + return false; + } + }, + readDefault: (domain, key) => { + // `defaults` is macOS-only; elsewhere the plan is simply unknown. + if (process.platform !== "darwin") return null; + try { + const result = Bun.spawnSync(["defaults", "read", domain, key], { stdout: "pipe", stderr: "pipe" }); + if (result.exitCode !== 0) return null; + return result.stdout.toString().trim(); + } catch { + return null; + } + }, + }; +} + +function appPathFor(deps: RaycastDetectDeps): string | null { + // Join with the target platform's separator so a test describing another OS + // gets that OS's paths, not the host's. + const { join } = deps.platform === "win32" ? win32 : posix; + if (deps.platform === "darwin") { + for (const candidate of ["/Applications/Raycast.app", join(deps.homedir, "Applications", "Raycast.app")]) { + if (deps.exists(candidate)) return candidate; + } + return null; + } + if (deps.platform === "win32") { + const local = deps.env.LOCALAPPDATA; + if (!local) return null; + const candidate = join(local, "Programs", "Raycast"); + return deps.exists(candidate) ? candidate : null; + } + return null; +} + +function planFor(deps: RaycastDetectDeps): RaycastPlan { + if (deps.platform !== "darwin") return "unknown"; + // Read once: `defaults` spawns a process, and the answer cannot change + // between two reads inside one detection. + const value = deps.readDefault(RAYCAST_DEFAULTS_DOMAIN, RAYCAST_SUBSCRIPTION_KEY); + if (value === "1") return "pro"; + if (value === "0") return "free"; + return "unknown"; +} + +export function detectRaycast(deps: RaycastDetectDeps = realRaycastDetectDeps()): RaycastInstall { + const { join } = deps.platform === "win32" ? win32 : posix; + return { + appPath: appPathFor(deps), + // Raycast ignores XDG and uses this path on every platform it ships on. + aiDirPresent: deps.exists(join(deps.homedir, ".config", "raycast", "ai")), + plan: planFor(deps), + }; +} diff --git a/src/integrations/registry.ts b/src/integrations/registry.ts index 13662d52d5..f5780f4f98 100644 --- a/src/integrations/registry.ts +++ b/src/integrations/registry.ts @@ -35,6 +35,8 @@ import { piConfigPath, primeAgentDir, primeConfigPath, + raycastAiDir, + raycastConfigPath, zcodeConfigPath, zcodeHomeDir, type ExportClientId, @@ -261,6 +263,22 @@ export const INTEGRATION_CLIENTS: Record join(asideHomeDir(env, home), "u"), }, + raycast: { + id: "raycast", + configPath: (env = process.env, home = homedir()) => raycastConfigPath(env, home), + /* + * The `ai` directory, not `Raycast.app`. Raycast creates it only when the + * user clicks "Reveal Providers Config" in Settings > AI, which is exactly + * the signal that Custom Providers is reachable on this install; an app + * bundle alone says nothing about the plan or the feature. + * + * No `sourcePreservingYaml`: that patcher handles block-map leaves only, + * and our entry is a SEQUENCE item, so the file is re-rendered through + * `renderYaml` (block style). The `[id=opencodex]` selector keeps the user's + * other providers in place across that re-render. + */ + detectDir: (env = process.env, home = homedir()) => raycastAiDir(env, home), + }, }; export const INTEGRATION_CLIENT_IDS: readonly IntegrationClientId[] = diff --git a/src/integrations/state.ts b/src/integrations/state.ts index 008f46fbf1..bb0e5b3567 100644 --- a/src/integrations/state.ts +++ b/src/integrations/state.ts @@ -12,6 +12,7 @@ import { ClientPathError, EXPORT_CLIENTS, opencodeProxyBaseUrl, type ExportModel import type { OcxConfig } from "../types"; import { PARSE_FAILED, loadTarget, parseConfig, type IntegrationIO } from "./config-io"; import { SNAPSHOT_RETENTION } from "./journal"; +import { parseSegment, type PathSegment } from "./merge"; import { canonicalContribution, fingerprint, semanticContribution, type OwnershipRecord } from "./ownership"; import { protectedContributionFingerprint, @@ -52,11 +53,41 @@ export interface IntegrationStatus { retentionDegraded: boolean; } +function isPlainRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function assertNever(segment: never): never { + throw new Error(`unknown path segment ${JSON.stringify(segment)}`); +} + +/** The element a selector names, or `undefined` when none matches. */ +function selectElement(items: readonly unknown[], segment: PathSegment & { kind: "select" }): unknown { + return items.find(item => isPlainRecord(item) && item[segment.field] === segment.value); +} + +/** + * Same segment grammar as `setPath`: a plain key reads through a record, a + * `[field=value]` selector reads through an array. Because the classifier and + * the writer share this one function, status and mutation cannot disagree + * about which element is ours. + */ export function readPath(doc: unknown, path: readonly string[]): unknown { let cursor: unknown = doc; - for (const key of path) { - if (typeof cursor !== "object" || cursor === null || Array.isArray(cursor)) return undefined; - cursor = (cursor as Record)[key]; + for (const raw of path) { + const segment = parseSegment(raw); + switch (segment.kind) { + case "key": + if (!isPlainRecord(cursor)) return undefined; + cursor = cursor[segment.key]; + break; + case "select": + if (!Array.isArray(cursor)) return undefined; + cursor = selectElement(cursor, segment); + break; + default: + return assertNever(segment); + } if (cursor === undefined) return undefined; } return cursor; @@ -82,10 +113,35 @@ export function blockedContainerPath( doc: unknown, contribution: ManagedContribution, ): readonly string[] | null { + /* + * What a segment needs the value it walks through to BE: a record for a key, + * an array for a selector. `typeof null === "object"`, so null is excluded + * by both checks rather than walking straight into the dereference below. + */ + const holds = (segment: PathSegment, value: unknown): boolean => { + switch (segment.kind) { + case "key": + return isPlainRecord(value); + case "select": + return Array.isArray(value); + default: + return assertNever(segment); + } + }; + const step = (segment: PathSegment, value: unknown): unknown => { + switch (segment.kind) { + case "key": + return (value as Record)[segment.key]; + case "select": + return selectElement(value as readonly unknown[], segment); + default: + return assertNever(segment); + } + }; for (const fragment of contribution.fragments) { let cursor: unknown = doc; for (let depth = 0; depth < fragment.path.length - 1; depth += 1) { - const key = fragment.path[depth]!; + const segment = parseSegment(fragment.path[depth]!); /* * ONLY `undefined` means absent. A missing file parses as `{}`, so an * absent prefix reads `undefined` — but a parsed `null` is a value the @@ -94,14 +150,10 @@ export function blockedContainerPath( * "successful" apply. */ if (cursor === undefined) break; - // `typeof null === "object"`, so null has to be named explicitly or it - // walks straight into the dereference below. - if (cursor === null || typeof cursor !== "object" || Array.isArray(cursor)) { - return fragment.path.slice(0, depth); - } - const next = (cursor as Record)[key]; + if (!holds(segment, cursor)) return fragment.path.slice(0, depth); + const next = step(segment, cursor); if (next === undefined) break; - if (typeof next !== "object" || next === null || Array.isArray(next)) { + if (!holds(parseSegment(fragment.path[depth + 1]!), next)) { return fragment.path.slice(0, depth + 1); } cursor = next; diff --git a/src/integrations/writer.ts b/src/integrations/writer.ts index 23b3eaaad4..7ec543715a 100644 --- a/src/integrations/writer.ts +++ b/src/integrations/writer.ts @@ -27,7 +27,7 @@ import { refreshablePathsOf, semanticProtectedContributionFingerprint, } from "./ownership-policy"; -import { createdContainerPaths, mergeContribution, removeFragments } from "./merge"; +import { AmbiguousSelectorError, createdContainerPaths, mergeContribution, removeFragments } from "./merge"; import { INTEGRATION_CLIENTS, isLoopbackOnly, resolveIntegrationPaths, type IntegrationClientId } from "./registry"; import { classifyIntegration, exportContextOf } from "./state"; import type { IntegrationState } from "./state"; @@ -352,36 +352,39 @@ function applyOrRefreshIntegration( * concludes the user owns it, and the replacement record forgets we made it * — so a later disable strands it forever. */ - const base = classified.state === "stale" && record - ? removeFragments(parsed, record.fragmentPaths, new Set(record.createdContainers ?? [])).doc - : classified.state === "conflict" && record - /* - * A forced overwrite of a `foreign-edit` conflict drops what the previous - * record owned for the same reason a stale refresh does: the replacement - * record covers the paths we are about to write, so a path the old record - * owned and the new one does not would be stranded forever, unremovable by - * any later disable. - * - * With NO record -- an `unowned-key` conflict -- there is nothing to drop and - * the merge runs against the user's document directly. That is correct: - * createdContainerPaths then attributes every container they already had to - * them, so a later disable removes our leaves and leaves their structure - * standing. - */ - ? removeFragments(parsed, record.fragmentPaths, new Set(record.createdContainers ?? [])).doc - : parsed; - // Computed against the document as it stands BEFORE the merge: afterwards - // every container exists and "did we create this?" is unanswerable. - const created = createdContainerPaths(base, contribution); /* * A document can hold a value its own format cannot round-trip through our * renderers. That used to throw straight out of the writer and reach the * user as a 500 with no path and no advice; it is a refusal like any other, - * and the file is untouched because this happens before any write. + * and the file is untouched because this happens before any write. The + * removal and merge sit inside the same guard: a sequence holding two + * entries our selector matches is equally unwritable, and equally untouched. */ - const nextDocument = mergeContribution(base, contribution); + let created: string[]; let text: string; try { + const base = classified.state === "stale" && record + ? removeFragments(parsed, record.fragmentPaths, new Set(record.createdContainers ?? [])).doc + : classified.state === "conflict" && record + /* + * A forced overwrite of a `foreign-edit` conflict drops what the previous + * record owned for the same reason a stale refresh does: the replacement + * record covers the paths we are about to write, so a path the old record + * owned and the new one does not would be stranded forever, unremovable by + * any later disable. + * + * With NO record -- an `unowned-key` conflict -- there is nothing to drop and + * the merge runs against the user's document directly. That is correct: + * createdContainerPaths then attributes every container they already had to + * them, so a later disable removes our leaves and leaves their structure + * standing. + */ + ? removeFragments(parsed, record.fragmentPaths, new Set(record.createdContainers ?? [])).doc + : parsed; + // Computed against the document as it stands BEFORE the merge: afterwards + // every container exists and "did we create this?" is unanswerable. + created = createdContainerPaths(base, contribution); + const nextDocument = mergeContribution(base, contribution); if (spec.sourcePreservingYaml && before !== null) { const value = sourcePreservingFragmentValue(contribution, spec.sourcePreservingYaml.path); const patched = value === undefined @@ -401,6 +404,10 @@ function applyOrRefreshIntegration( text = serializeDocument(nextDocument, exportSpec.format); } } catch (error) { + if (error instanceof AmbiguousSelectorError) { + return refuse(clientId, "unsafe", "unsafe", + `${configPath} holds more than one entry matching ours, so it was left alone`); + } if (!(error instanceof UnserializableValueError)) throw error; return refuse(clientId, "unsafe", "unsafe", `${configPath} contains something opencodex cannot rewrite safely (${error.message}), so it was left alone`); @@ -527,11 +534,15 @@ export function disableIntegration(input: IntegrationWriteInput): WriteOutcome { return refuse(clientId, "unsafe", "unsafe", `${configPath} uses YAML source opencodex cannot patch without risking unrelated comments or formatting, so nothing was removed`); } - const { doc, removed } = removeFragments( - parsed, - record!.fragmentPaths, - new Set(prunableCreated), - ); + let doc: unknown; + let removed: boolean; + try { + ({ doc, removed } = removeFragments(parsed, record!.fragmentPaths, new Set(prunableCreated))); + } catch (error) { + if (!(error instanceof AmbiguousSelectorError)) throw error; + return refuse(clientId, "unsafe", "unsafe", + `${configPath} holds more than one entry matching ours, so nothing was removed`); + } if (!removed) { return { ok: true, changed: false, state: "absent", clientId, message: "nothing to remove" }; } diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index ac6929c968..4d551a886d 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -161,7 +161,7 @@ interface ClientIntegrationSyncOutcome { } /** - * Re-inject native clients that are switched ON and file integrations whose + * Re-inject native clients that are switched ON and every file integration whose * OpenCodex ownership record is the operator's durable opt-in. * * Only Codex used to run here, so a catalog change reached Codex and nothing else: a Grok @@ -169,6 +169,10 @@ interface ClientIntegrationSyncOutcome { * next `ocx start`. The startup path already gates each client on its own toggle * (`src/cli/index.ts`), and this is that same fan-out for the on-demand command. * + * File integrations use the catalog-refresh coordinator so owned blocks are + * updated without claiming unowned files. Aside remains on its multi-profile + * server-owned path inside that coordinator. + * * A client that is OFF or never connected is omitted from the result rather than reported as skipped — the * caller has to be able to tell "not touched" from "tried and failed". A client that fails * does not fail the sync: Codex is the one that matters for routing, and a broken Grok file @@ -233,7 +237,7 @@ export async function syncEnabledClientIntegrations( }, config, port, - }, ["mcode", "pi", "aside"])); + }, ["mcode", "pi", "aside", "raycast"])); return out; } diff --git a/src/server/management/integration-routes.ts b/src/server/management/integration-routes.ts index b332718e07..43c0e6a98c 100644 --- a/src/server/management/integration-routes.ts +++ b/src/server/management/integration-routes.ts @@ -22,6 +22,7 @@ import { isIntegrationClientId, type IntegrationClientId, } from "../../integrations/registry"; +import { detectRaycast, type RaycastInstall } from "../../integrations/raycast-detect"; import { readIntegrationState } from "../../integrations/state"; import { createIntegrationStateStore, type IntegrationStateStore } from "../../integrations/store"; import { @@ -58,6 +59,13 @@ type RestoreResult = Awaited>; export type IntegrationStateEnvelope = { clientId: IntegrationClientId; + /** + * Raycast only, and only on the single-client read. Custom Providers is a + * Pro feature, so a file that is `current` can still be one Raycast ignores; + * this is the fact that lets status and the GUI say so. It is not part of + * the shared `IntegrationStatus`, which describes the file, not the app. + */ + raycast?: RaycastInstall; } & IntegrationStateRecord; export interface IntegrationStateListEnvelope { @@ -141,6 +149,17 @@ export function setIntegrationPathTestHooks(hooks: { env?: NodeJS.ProcessEnv; ho integrationPathTestHooks = hooks; } +/** + * Raycast detection override for tests. The real detector spawns `defaults` and + * reads the developer's own subscription state, which is exactly the kind of + * host fact a route test must not depend on. + */ +let raycastDetectTestHook: (() => RaycastInstall) | null = null; + +export function setRaycastDetectTestHook(hook: (() => RaycastInstall) | null): void { + raycastDetectTestHook = hook; +} + /** The `env`/`home` overrides, spread into every registry-resolving call. */ function pathOverrides(): { env?: NodeJS.ProcessEnv; home?: string } { return { @@ -177,7 +196,10 @@ export function setIntegrationMutationFlightTestHooks( setIntegrationMutationFlightTestHook(hooks?.run ?? null); // Path overrides are part of the same isolation contract: clearing flights // while leaving a temp home bound would let the next suite write real files. - if (hooks === null) integrationPathTestHooks = null; + if (hooks === null) { + integrationPathTestHooks = null; + raycastDetectTestHook = null; + } } /** @@ -633,7 +655,12 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise { + test("a selector splits into field and value; anything else is a key", () => { + expect(parseSegment("[id=opencodex]")).toEqual({ kind: "select", field: "id", value: "opencodex" }); + expect(parseSegment("[model_id=anthropic/claude-opus-5]")) + .toEqual({ kind: "select", field: "model_id", value: "anthropic/claude-opus-5" }); + expect(parseSegment("providers")).toEqual({ kind: "key", key: "providers" }); + // Near misses stay keys: a client whose map literally has such a key keeps working. + expect(parseSegment("[id=]")).toEqual({ kind: "key", key: "[id=]" }); + expect(parseSegment("[=x]")).toEqual({ kind: "key", key: "[=x]" }); + expect(parseSegment("[id=x")).toEqual({ kind: "key", key: "[id=x" }); + }); +}); + +describe("setPath with a selector", () => { + test("replaces the matching element in place and keeps siblings and order", () => { + const doc = { providers: [THEIRS, { id: "opencodex", name: "old" }, { id: "other" }], keep: true }; + const next = setPath(doc, SELECT, OURS) as typeof doc; + expect(next.providers).toEqual([THEIRS, OURS, { id: "other" }]); + expect(next.keep).toBe(true); + // The input is not mutated. + expect(doc.providers[1]).toEqual({ id: "opencodex", name: "old" }); + }); + + test("pushes when no element matches", () => { + const next = setPath({ providers: [THEIRS] }, SELECT, OURS) as { providers: unknown[] }; + expect(next.providers).toEqual([THEIRS, OURS]); + }); + + test("creates the array when absent, and createdContainerPaths reports it", () => { + expect(createdContainerPaths({}, contribution(SELECT))).toEqual(["providers"]); + expect(createdContainerPaths({ providers: {} }, contribution(SELECT))).toEqual(["providers"]); + expect(createdContainerPaths({ providers: [THEIRS] }, contribution(SELECT))).toEqual([]); + expect(setPath({}, SELECT, OURS)).toEqual({ providers: [OURS] }); + // A record where the array belongs is replaced, exactly as a scalar under a key is. + expect(setPath({ providers: {} }, SELECT, OURS)).toEqual({ providers: [OURS] }); + }); + + test("descends into a matched element, seeding one when absent", () => { + const path = ["providers", "[id=opencodex]", "name"]; + expect(setPath({ providers: [THEIRS] }, path, "X")) + .toEqual({ providers: [THEIRS, { id: "opencodex", name: "X" }] }); + expect(setPath({ providers: [OURS, THEIRS] }, path, "X")) + .toEqual({ providers: [{ id: "opencodex", name: "X" }, THEIRS] }); + // The element the selector would create is recorded, the existing array is not. + expect(createdContainerPaths({ providers: [THEIRS] }, contribution(path, "X"))) + .toEqual(["providers\u0000[id=opencodex]"]); + expect(createdContainerPaths({ providers: [OURS] }, contribution(path, "X"))).toEqual([]); + }); + + test("throws AmbiguousSelectorError when two elements match", () => { + const doc = { providers: [OURS, THEIRS, { id: "opencodex", name: "dupe" }] }; + expect(() => setPath(doc, SELECT, OURS)).toThrow(AmbiguousSelectorError); + expect(() => deletePath(doc, SELECT)).toThrow(AmbiguousSelectorError); + }); +}); + +describe("deletePath with a selector", () => { + test("removes only the matching element and leaves siblings", () => { + const { doc, removed } = deletePath({ providers: [THEIRS, OURS, { id: "other" }], keep: 1 }, SELECT); + expect(removed).toBe(true); + expect(doc).toEqual({ providers: [THEIRS, { id: "other" }], keep: 1 }); + }); + + test("reports nothing removed when no element matches or the slot is not an array", () => { + expect(deletePath({ providers: [THEIRS] }, SELECT)).toEqual({ doc: { providers: [THEIRS] }, removed: false }); + expect(deletePath({ providers: {} }, SELECT)).toEqual({ doc: { providers: {} }, removed: false }); + expect(deletePath({}, SELECT)).toEqual({ doc: {}, removed: false }); + }); + + test("prunes an emptied array we created and keeps one we did not", () => { + const created = new Set(["providers"]); + expect(deletePath({ providers: [OURS], keep: 1 }, SELECT, created).doc).toEqual({ keep: 1 }); + expect(deletePath({ providers: [OURS], keep: 1 }, SELECT).doc).toEqual({ providers: [], keep: 1 }); + // A sibling keeps the array alive even when we created it. + expect(deletePath({ providers: [OURS, THEIRS] }, SELECT, created).doc).toEqual({ providers: [THEIRS] }); + }); + + test("a leaf inside a selected element is removed without touching the element", () => { + const path = ["providers", "[id=opencodex]", "name"]; + const created = new Set(["providers", "providers\u0000[id=opencodex]"]); + // The seeded element keeps its selector field, so it is never empty and the prune walk + // stops at it. No client owns a leaf inside a selected element today; when one does, it + // decides whether a `{ id }` husk is residue worth a dedicated rule. + expect(deletePath({ providers: [{ id: "opencodex", name: "X" }] }, path, created).doc) + .toEqual({ providers: [{ id: "opencodex" }] }); + expect(deletePath({ providers: [{ id: "opencodex", name: "X", extra: 1 }] }, path, created).doc) + .toEqual({ providers: [{ id: "opencodex", extra: 1 }] }); + }); +}); + +describe("readPath and blockedContainerPath with a selector", () => { + test("readPath finds the element through a selector", () => { + const doc = { providers: [THEIRS, OURS] }; + expect(readPath(doc, SELECT)).toEqual(OURS); + expect(readPath(doc, ["providers", "[id=opencodex]", "name"])).toBe("OpenCodex"); + expect(readPath(doc, ["providers", "[id=missing]"])).toBeUndefined(); + expect(readPath({ providers: {} }, SELECT)).toBeUndefined(); + expect(readPath({ providers: "x" }, SELECT)).toBeUndefined(); + }); + + test("blockedContainerPath blocks a non-array where the selector expects one", () => { + expect(blockedContainerPath({ providers: {} }, contribution(SELECT))).toEqual(["providers"]); + expect(blockedContainerPath({ providers: "x" }, contribution(SELECT))).toEqual(["providers"]); + expect(blockedContainerPath({ providers: null }, contribution(SELECT))).toEqual(["providers"]); + expect(blockedContainerPath({ providers: [THEIRS] }, contribution(SELECT))).toBeNull(); + expect(blockedContainerPath({}, contribution(SELECT))).toBeNull(); + // Reading through a matched element continues the walk: a scalar element is blocked, + // a record one is fine, an absent one is simply not there yet. + const deep = ["providers", "[id=opencodex]", "name"]; + expect(blockedContainerPath({ providers: [OURS] }, contribution(deep, "X"))).toBeNull(); + expect(blockedContainerPath({ providers: [THEIRS] }, contribution(deep, "X"))).toBeNull(); + expect(blockedContainerPath({ providers: [{ id: "opencodex", name: 1 }] }, contribution(["providers", "[id=opencodex]", "name", "leaf"], "X"))) + .toEqual(["providers", "[id=opencodex]", "name"]); + }); +}); + +describe("plain-key paths are unchanged", () => { + test("setPath, deletePath, readPath, createdContainerPaths and blockedContainerPath behave as before", () => { + const path = ["providers", "opencodex", "api_key"]; + expect(setPath({}, path, "k")).toEqual({ providers: { opencodex: { api_key: "k" } } }); + expect(setPath({ providers: "x" }, path, "k")).toEqual({ providers: { opencodex: { api_key: "k" } } }); + expect(setPath({ providers: [1] }, path, "k")).toEqual({ providers: { opencodex: { api_key: "k" } } }); + expect(setPath({ providers: { other: 1 } }, path, "k")) + .toEqual({ providers: { other: 1, opencodex: { api_key: "k" } } }); + expect(createdContainerPaths({}, contribution(path, "k"))).toEqual(["providers", "providers\u0000opencodex"]); + expect(createdContainerPaths({ providers: { other: 1 } }, contribution(path, "k"))).toEqual(["providers\u0000opencodex"]); + + const created = new Set(["providers", "providers\u0000opencodex"]); + expect(deletePath({ providers: { opencodex: { api_key: "k" } } }, path, created)).toEqual({ doc: {}, removed: true }); + expect(deletePath({ providers: { opencodex: { api_key: "k" } } }, path)).toEqual({ doc: { providers: { opencodex: {} } }, removed: true }); + expect(deletePath({ providers: { opencodex: { api_key: "k", other: 1 } }, x: 1 }, path, created)) + .toEqual({ doc: { providers: { opencodex: { other: 1 } }, x: 1 }, removed: true }); + expect(deletePath({ providers: {} }, path)).toEqual({ doc: { providers: {} }, removed: false }); + expect(deletePath({ providers: [] }, path)).toEqual({ doc: { providers: [] }, removed: false }); + expect(deletePath({ providers: { opencodex: "x" } }, path)).toEqual({ doc: { providers: { opencodex: "x" } }, removed: false }); + expect(deletePath({ providers: { opencodex: { api_key: null } } }, path, created)).toEqual({ doc: {}, removed: true }); + + expect(readPath({ providers: { opencodex: { api_key: "k" } } }, path)).toBe("k"); + expect(readPath({ providers: [OURS] }, ["providers", "0"])).toBeUndefined(); + expect(readPath({ providers: null }, path)).toBeUndefined(); + + expect(blockedContainerPath({ providers: ["x"] }, contribution(path, "k"))).toEqual(["providers"]); + expect(blockedContainerPath({ providers: { opencodex: null } }, contribution(path, "k"))).toEqual(["providers", "opencodex"]); + expect(blockedContainerPath(null, contribution(path, "k"))).toEqual([]); + expect(blockedContainerPath({ providers: { opencodex: {} } }, contribution(path, "k"))).toBeNull(); + expect(blockedContainerPath(undefined, contribution(path, "k"))).toBeNull(); + }); +}); + +/** + * End to end through the real writer: Raycast is the first client whose + * fragment path carries a selector, so this is where status and mutation are + * shown agreeing on which sequence element is ours. + */ +describe("raycast writer round trip", () => { + const TEST_ENV = {} as NodeJS.ProcessEnv; + const MODELS: ExportModel[] = [ + { namespaced: "anthropic/claude-opus-4-8", provider: "anthropic", id: "claude-opus-4-8", contextWindow: 200_000 }, + ]; + const CONFIG: OcxConfig = { + port: 10100, + hostname: "127.0.0.1", + defaultProvider: "mock", + providers: { mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1/v1" } }, + } as unknown as OcxConfig; + let home: string; + let store: IntegrationStateStore; + + beforeEach(() => { + const base = mkdtempSync(join(tmpdir(), "ocx-integrations-merge-")); + home = join(base, "home"); + mkdirSync(home, { recursive: true }); + store = createIntegrationStateStore(join(base, "store", "integrations")); + }); + + afterEach(() => { + removeTreeWithRetry(dirname(home)); + }); + + function installRaycast(): string { + const spec = INTEGRATION_CLIENTS.raycast; + mkdirSync(spec.detectDir(TEST_ENV, home), { recursive: true }); + const configPath = spec.configPath(TEST_ENV, home); + mkdirSync(dirname(configPath), { recursive: true }); + return configPath; + } + + function input(): IntegrationWriteInput { + return { clientId: "raycast", models: MODELS, config: CONFIG, port: 10100, env: TEST_ENV, home, store }; + } + + test("apply appends beside the user's provider, disable removes only ours", () => { + const configPath = installRaycast(); + writeFileSync(configPath, Bun.YAML.stringify({ providers: [THEIRS] })); + + expect(readIntegrationState(input())).toMatchObject({ state: "absent" }); + expect(applyIntegration(input())).toMatchObject({ ok: true, changed: true }); + const applied = Bun.YAML.parse(readFileSync(configPath, "utf8")) as { providers: Array<{ id: string }> }; + expect(applied.providers.map(item => item.id)).toEqual(["lmstudio", "opencodex"]); + expect(readIntegrationState(input())).toMatchObject({ state: "current" }); + + expect(disableIntegration(input())).toMatchObject({ ok: true, changed: true }); + // The user's array was there before us, so it survives with their entry intact. + expect(Bun.YAML.parse(readFileSync(configPath, "utf8"))).toEqual({ providers: [THEIRS] }); + expect(readIntegrationState(input())).toMatchObject({ state: "absent" }); + }); + + test("a providers map instead of a sequence is unsafe for status and writer alike", () => { + const configPath = installRaycast(); + writeFileSync(configPath, Bun.YAML.stringify({ providers: { opencodex: {} } })); + expect(readIntegrationState(input())).toMatchObject({ state: "unsafe", reason: "blocked-container" }); + expect(applyIntegration(input())).toMatchObject({ ok: false, reason: "unsafe" }); + expect(Bun.YAML.parse(readFileSync(configPath, "utf8"))).toEqual({ providers: { opencodex: {} } }); + }); + + test("two entries with our id refuse as unsafe and leave the file alone", () => { + const configPath = installRaycast(); + const text = Bun.YAML.stringify({ providers: [{ id: "opencodex", name: "a" }, { id: "opencodex", name: "b" }] }); + writeFileSync(configPath, text); + // Neither entry is ours on record, so status reads conflict and a plain apply refuses + // there. The explicit overwrite reaches the merge, which is where the ambiguity is + // detected: it must surface as an `unsafe` refusal, never as a thrown error. + expect(readIntegrationState(input())).toMatchObject({ state: "conflict" }); + expect(applyIntegration(input())).toMatchObject({ ok: false, reason: "conflict" }); + const result = overwriteIntegration(input()); + expect(result).toMatchObject({ ok: false, reason: "unsafe", state: "unsafe" }); + if (!result.ok) expect(result.message).toContain("more than one entry"); + expect(readFileSync(configPath, "utf8")).toBe(text); + expect(store.listOperations("raycast")).toHaveLength(0); + }); +}); diff --git a/tests/clients/integrations-state.test.ts b/tests/clients/integrations-state.test.ts index 872e9b3824..56093b3dd6 100644 --- a/tests/clients/integrations-state.test.ts +++ b/tests/clients/integrations-state.test.ts @@ -775,9 +775,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 and aside are loopback-only and nobody else is", () => { + test("omp, pi, kimi, gajae, dsh, mcode, zcode, prime, aside and raycast 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"]); + expect(loopbackOnly).toEqual(["pi", "omp", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast"]); }); test("the registry restates nothing — it reads the export spec", () => { diff --git a/tests/clients/raycast-client.test.ts b/tests/clients/raycast-client.test.ts new file mode 100644 index 0000000000..148f84babb --- /dev/null +++ b/tests/clients/raycast-client.test.ts @@ -0,0 +1,271 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + EXPORT_CLIENTS, + OPENCODE_PROVIDER_ID, + buildClientConfig, + buildClientConfigText, + buildClientContribution, + raycastAiDir, + raycastConfigPath, + type ExportContext, + type ExportModel, + type RaycastGeneratedConfig, +} from "../../src/clients/config-export"; +import { exportPresentationLabel } from "../../src/clients/model-presentation"; +import { refreshOwnedCatalogIntegrations } from "../../src/integrations/catalog-refresh"; +import { INTEGRATION_CLIENTS } from "../../src/integrations/registry"; +import { createIntegrationStateStore, type IntegrationStateStore } from "../../src/integrations/store"; +import { applyIntegration, disableIntegration, refreshIntegration } from "../../src/integrations/writer"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +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; + +// One model per cell of the vision x reasoning matrix, so every ability +// branch is exercised by a row that differs from its neighbours in one axis. +const MODELS: ExportModel[] = [ + { 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: "mystery/model", provider: "mystery", id: "model" }, + { namespaced: "google/gemini-3-pro", provider: "google", id: "gemini-3-pro", contextWindow: 1_048_576, inputModalities: ["text", "image"], reasoningEfforts: ["low", "high"] }, +]; + +function context(models: readonly ExportModel[] = MODELS): ExportContext { + return { baseUrl: "http://127.0.0.1:10100/v1", config: CONFIG, models }; +} + +// A provider the user wrote by hand: the merge must carry it through every +// apply, refresh and disable untouched. +const LMSTUDIO = { id: "lmstudio", name: "LM Studio", base_url: "http://localhost:1234/v1", models: [] }; +const USER_SEED = [ + "providers:", + " - id: lmstudio", + " name: LM Studio", + " base_url: http://localhost:1234/v1", + " models: []", + "", +].join(String.fromCharCode(10)); + +function ourProvider(document: RaycastGeneratedConfig) { + return document.providers.find(provider => provider.id === OPENCODE_PROVIDER_ID)!; +} + +function abilitiesOf(document: RaycastGeneratedConfig, id: string): Record { + const model = ourProvider(document).models.find(entry => entry.id === id)!; + return Object.fromEntries(Object.entries(model.abilities).map(([name, ability]) => [name, ability.supported])); +} + +let home: string; +let store: IntegrationStateStore; + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-raycast-")); + store = createIntegrationStateStore(mkdtempSync(join(tmpdir(), "ocx-raycast-store-"))); +}); + +afterEach(() => { + removeTreeWithRetry(home); +}); + +/** Raycast "installed" for our purposes: the `ai` directory exists. */ +function installRaycast(seed?: string): string { + const spec = INTEGRATION_CLIENTS.raycast; + mkdirSync(spec.detectDir({}, home), { recursive: true }); + const configPath = spec.configPath({}, home); + if (seed !== undefined) writeFileSync(configPath, seed); + return configPath; +} + +function readProviders(configPath: string): RaycastGeneratedConfig { + return Bun.YAML.parse(readFileSync(configPath, "utf8")) as RaycastGeneratedConfig; +} + +function request(models: readonly ExportModel[] = MODELS) { + return { clientId: "raycast" as const, models, config: CONFIG, port: 10100, env: {}, home, store }; +} + +describe("Raycast client config", () => { + /* + * The shape is Raycast's, not ours: `providers` is a SEQUENCE, `base_url` + * ends in `/v1` without `/chat/completions`, and there is no `api_keys` at + * all because a loopback bind is unauthenticated. Every model carries all + * five abilities so Raycast never has to guess at a missing one. + */ + test("emits one provider element with the documented field vocabulary", () => { + const document = buildClientConfig("raycast", context()) as RaycastGeneratedConfig; + expect(Object.keys(document)).toEqual(["providers"]); + expect(document.providers.map(provider => provider.id)).toEqual([OPENCODE_PROVIDER_ID]); + + const provider = ourProvider(document); + expect(Object.keys(provider)).toEqual(["id", "name", "base_url", "models"]); + expect(provider.name).toBe("OpenCodex"); + expect(provider.base_url).toBe("http://127.0.0.1:10100/v1"); + expect(Object.keys(provider)).not.toContain("api_keys"); + + for (const model of provider.models) { + expect(Object.keys(model.abilities)).toEqual(["temperature", "vision", "system_message", "tools", "reasoning_effort"]); + } + const claude = provider.models.find(model => model.id === "anthropic/claude-opus-5")!; + // Raycast shows `name` verbatim with no provider suffix; capability tables + // supply the product label when ExportModel has no operator override. + expect(claude.name).toBe("Claude Opus 5"); + expect(claude.context).toBe(200_000); + // No authoritative window means the key is absent, not zero or null. + const unknown = provider.models.find(model => model.id === "mystery/model")!; + expect("context" in unknown).toBe(false); + }); + + test("uses product labels instead of raw slugs or provider suffixes", () => { + expect(exportPresentationLabel({ + namespaced: "anthropic/claude-fable-5-1", provider: "anthropic", id: "claude-fable-5-1", + })).toBe("Claude Fable 5.1"); + expect(exportPresentationLabel({ + namespaced: "cursor/composer-2.5", provider: "cursor", id: "composer-2.5", + })).toBe("Composer 2.5"); + expect(exportPresentationLabel({ + namespaced: "mystery/model", provider: "mystery", id: "model", displayName: "Custom Name", + })).toBe("Custom Name"); + }); + + /* + * Abilities follow the catalog row, not the vendor name. Temperature and + * reasoning_effort are the same bit inverted: Raycast's own template notes + * that reasoning models commonly reject temperature. system_message and + * tools are always on, the same stance as Hermes. + */ + test("maps vision and reasoning ladders onto abilities per model", () => { + const document = buildClientConfig("raycast", context()) as RaycastGeneratedConfig; + expect(abilitiesOf(document, "anthropic/claude-opus-5")).toEqual({ + temperature: true, vision: true, system_message: true, tools: true, reasoning_effort: false, + }); + expect(abilitiesOf(document, "openai/gpt-5.6-sol")).toEqual({ + temperature: false, vision: false, system_message: true, tools: true, reasoning_effort: true, + }); + expect(abilitiesOf(document, "mystery/model")).toEqual({ + temperature: true, vision: false, system_message: true, tools: true, reasoning_effort: false, + }); + expect(abilitiesOf(document, "google/gemini-3-pro")).toEqual({ + temperature: false, vision: true, system_message: true, tools: true, reasoning_effort: true, + }); + }); + + test("native YAML round-trips, leads with our element, and never carries a credential", () => { + const sentinel = ["sk", "live", "raycast", "sentinel"].join("-"); + const withKey = { ...CONFIG, apiKeys: [{ key: sentinel }] } as OcxConfig; + const built = buildClientConfigText("raycast", { ...context(), config: withKey }); + expect(built.format).toBe("yaml"); + expect(built.text.startsWith(["providers:", " - id: opencodex"].join(String.fromCharCode(10)))).toBe(true); + expect(Bun.YAML.parse(built.text)).toEqual(built.document as never); + expect(built.text).not.toContain(sentinel); + expect(built.text).not.toContain("api_keys"); + }); + + test("the contribution owns the providers element selected by our id", () => { + const contribution = buildClientContribution("raycast", context()); + expect(contribution.clientId).toBe("raycast"); + expect(contribution.fragments.map(fragment => fragment.path)).toEqual([["providers", `[id=${OPENCODE_PROVIDER_ID}]`]]); + expect((contribution.fragments[0]!.value as { id: string }).id).toBe(OPENCODE_PROVIDER_ID); + }); + + test("resolves under the home directory and ignores XDG_CONFIG_HOME", () => { + // Raycast hardcodes ~/.config/raycast on macOS and Windows alike; honoring + // XDG here would name a file Raycast never reads. + const env = { XDG_CONFIG_HOME: join(home, "elsewhere") }; + expect(raycastAiDir(env, home)).toBe(join(home, ".config", "raycast", "ai")); + expect(raycastConfigPath(env, home)).toBe(join(home, ".config", "raycast", "ai", "providers.yaml")); + expect(INTEGRATION_CLIENTS.raycast.configPath(env, home)).toBe(raycastConfigPath(env, home)); + expect(INTEGRATION_CLIENTS.raycast.detectDir(env, home)).toBe(raycastAiDir(env, home)); + }); + + test("ships as a loopback-only integration with no env var to export", () => { + const spec = EXPORT_CLIENTS.raycast; + // `api_keys` is read literally, so a remote bind would need a plaintext + // secret on disk; the spec refuses instead. + expect(spec.loopbackOnly).toBe(true); + expect(spec.apiKeyEnv).toBe(""); + expect(spec.format).toBe("yaml"); + // Not a bare providers.yaml: a download would collide with other clients'. + expect(spec.filename).toBe("raycast-providers.yaml"); + }); + + /* + * The whole point of the `[id=opencodex]` selector: the user's own element + * survives every operation, we replace only ours, and a disable leaves the + * sequence exactly as the user wrote it. + */ + test("apply, refresh and disable touch only our element of the sequence", () => { + const configPath = installRaycast(USER_SEED); + + const applied = applyIntegration(request()); + expect(applied.ok).toBe(true); + const afterApply = readProviders(configPath); + expect(new Set(afterApply.providers.map(provider => provider.id))).toEqual(new Set(["lmstudio", OPENCODE_PROVIDER_ID])); + expect(afterApply.providers.find(provider => provider.id === "lmstudio")).toEqual(LMSTUDIO); + expect(ourProvider(afterApply).models.map(model => model.id)).toEqual(MODELS.map(model => model.namespaced).sort()); + + // A smaller catalog rewrites our element in place and nothing else. + const fewer = MODELS.filter(model => model.namespaced !== "mystery/model"); + const refreshed = refreshIntegration(request(fewer)); + expect(refreshed.ok).toBe(true); + const afterRefresh = readProviders(configPath); + expect(afterRefresh.providers.map(provider => provider.id)).toEqual(afterApply.providers.map(provider => provider.id)); + expect(afterRefresh.providers.find(provider => provider.id === "lmstudio")).toEqual(LMSTUDIO); + expect(ourProvider(afterRefresh).models.map(model => model.id)).toEqual(fewer.map(model => model.namespaced).sort()); + + const disabled = disableIntegration(request(fewer)); + expect(disabled.ok).toBe(true); + const afterDisable = readProviders(configPath); + expect(afterDisable.providers).toEqual([LMSTUDIO]); + }); + + test("the default catalog refresh updates an owned Raycast provider", async () => { + const configPath = installRaycast(USER_SEED); + expect(applyIntegration(request()).ok).toBe(true); + const fewer = MODELS.filter(model => model.namespaced !== "mystery/model"); + let loads = 0; + + const outcomes = await refreshOwnedCatalogIntegrations({ + models: async () => { + loads += 1; + return fewer; + }, + config: CONFIG, + port: 10100, + env: {}, + home, + store, + }); + + expect(outcomes).toEqual([{ client: "raycast", ok: true, changed: true }]); + expect(loads).toBe(1); + expect(readProviders(configPath).providers.find(provider => provider.id === "lmstudio")).toEqual(LMSTUDIO); + expect(ourProvider(readProviders(configPath)).models.map(model => model.id)) + .toEqual(fewer.map(model => model.namespaced).sort()); + }); + + test("refuses a file whose providers is a map rather than a sequence", () => { + // `providers: {}` is a container we would have to REPLACE with `[]` to + // write our element, and replacing a user's container is never a success. + const configPath = installRaycast("providers: {}" + String.fromCharCode(10)); + const result = applyIntegration(request()); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe("unsafe"); + expect(readFileSync(configPath, "utf8")).toBe("providers: {}" + String.fromCharCode(10)); + }); + + test("refuses when the ai directory does not exist yet", () => { + // The directory appears only after "Reveal Providers Config" in Raycast's + // AI settings, which is the signal that Custom Providers is reachable. + const result = applyIntegration(request()); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe("not_installed"); + }); +}); diff --git a/tests/clients/raycast-detect.test.ts b/tests/clients/raycast-detect.test.ts new file mode 100644 index 0000000000..4e4268b29b --- /dev/null +++ b/tests/clients/raycast-detect.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test } from "bun:test"; +import { detectRaycast, type RaycastDetectDeps } from "../../src/integrations/raycast-detect"; + +/** + * Stubbed deps only. The real detector spawns `defaults` and reads the + * developer's subscription state, and this suite must pass identically on a + * machine with Raycast Pro, with the free tier, and with no Raycast at all. + */ +function fakeDeps( + platform: string, + existing: readonly string[], + options: { env?: Record; defaultValue?: string | null; homedir?: string } = {}, +): RaycastDetectDeps & { defaultsReads: number } { + const present = new Set(existing); + const deps = { + platform, + homedir: options.homedir ?? (platform === "win32" ? "C:\\Users\\u" : "/home/u"), + env: options.env ?? {}, + defaultsReads: 0, + exists: (path: string) => present.has(path), + readDefault: (domain: string, key: string) => { + deps.defaultsReads += 1; + expect(domain).toBe("com.raycast.macos.v1"); + expect(key).toBe("subscriptions_active"); + return options.defaultValue ?? null; + }, + }; + return deps; +} + +describe("detectRaycast", () => { + test("darwin: a Pro subscription, the app bundle and the revealed ai folder", () => { + const deps = fakeDeps("darwin", ["/Applications/Raycast.app", "/home/u/.config/raycast/ai"], { defaultValue: "1" }); + expect(detectRaycast(deps)).toEqual({ + appPath: "/Applications/Raycast.app", + aiDirPresent: true, + plan: "pro", + }); + // One process spawn per detection, not one per field. + expect(deps.defaultsReads).toBe(1); + }); + + test("darwin: the free tier is reported, not refused, and the user-local bundle is found", () => { + const deps = fakeDeps("darwin", ["/home/u/Applications/Raycast.app"], { defaultValue: "0" }); + expect(detectRaycast(deps)).toEqual({ + appPath: "/home/u/Applications/Raycast.app", + aiDirPresent: false, + plan: "free", + }); + }); + + test("darwin: a failed or unexpected defaults read is unknown, never free", () => { + expect(detectRaycast(fakeDeps("darwin", [], { defaultValue: null })).plan).toBe("unknown"); + expect(detectRaycast(fakeDeps("darwin", [], { defaultValue: "(null)" })).plan).toBe("unknown"); + expect(detectRaycast(fakeDeps("darwin", [], { defaultValue: "" })).plan).toBe("unknown"); + }); + + test("win32: LOCALAPPDATA\\Programs\\Raycast is the install path and the plan is unknown", () => { + const local = "C:\\Users\\u\\AppData\\Local"; + const deps = fakeDeps("win32", [`${local}\\Programs\\Raycast`, "C:\\Users\\u\\.config\\raycast\\ai"], { + env: { LOCALAPPDATA: local }, + defaultValue: "1", + }); + expect(detectRaycast(deps)).toEqual({ + appPath: `${local}\\Programs\\Raycast`, + aiDirPresent: true, + plan: "unknown", + }); + // `defaults` does not exist off macOS, so it is never asked. + expect(deps.defaultsReads).toBe(0); + }); + + test("win32: no LOCALAPPDATA means no app path rather than a guessed one", () => { + expect(detectRaycast(fakeDeps("win32", [])).appPath).toBeNull(); + }); + + test("linux: nothing is detected and nothing is spawned", () => { + const deps = fakeDeps("linux", [], { defaultValue: "1" }); + expect(detectRaycast(deps)).toEqual({ appPath: null, aiDirPresent: false, plan: "unknown" }); + expect(deps.defaultsReads).toBe(0); + }); +}); diff --git a/tests/clients/sync-client-integrations.test.ts b/tests/clients/sync-client-integrations.test.ts index 5661373642..9050eecd3b 100644 --- a/tests/clients/sync-client-integrations.test.ts +++ b/tests/clients/sync-client-integrations.test.ts @@ -65,7 +65,7 @@ describe("ocx sync fans out to enabled native clients and owned file integration expect(fn).toContain("grokIntegrationEnabled(config)"); expect(fn).toContain("claudeDesktopIntegrationEnabled(config)"); - expect(fn).toContain('["mcode", "pi", "aside"]'); + expect(fn).toContain('["mcode", "pi", "aside", "raycast"]'); expect(fn).toContain("refreshOwnedCatalogIntegrations"); // Native clients keep their catches; the owned catalog helper isolates file clients. expect(fn.match(/catch \(error\)/g)?.length).toBe(2); @@ -651,17 +651,29 @@ describe("owned Pi/Aside catalogs follow filtered model selections", () => { }); }); -test("the direct ocx sync command refreshes MCode, Pi and Aside instead of relying on /api/sync", async () => { +test("the direct ocx sync command refreshes MCode, Pi, Raycast and server-owned Aside", async () => { const src = await Bun.file(new URL("../../src/cli/dispatch.ts", import.meta.url)).text(); 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"]'); + expect(command).toContain('["mcode", "pi", "raycast"]'); expect(command).toContain("refreshAsideProfilesThroughServer"); expect(command.indexOf("syncModelsToCodex")).toBeLessThan(command.indexOf("refreshOwnedCatalogIntegrations")); expect(command).toContain('synced.status !== "refused"'); }); +test("startup and ensure refresh owned Raycast through the catalog coordinator", async () => { + const src = await Bun.file(new URL("../../src/cli/index.ts", import.meta.url)).text(); + const start = src.slice(src.indexOf("async function handleStart"), src.indexOf("function detachedStartEnvironment")); + const ensure = src.slice(src.indexOf("async function handleEnsure"), src.indexOf("async function handleTrayProxyStart")); + expect(src).toContain("refreshOwnedCatalogIntegrations"); + expect(src).toContain('}, ["raycast"]);'); + expect(start).toContain("await refreshOwnedRaycastCatalog(config, port)"); + expect(ensure).toContain("await refreshOwnedRaycastCatalog(config, live.port)"); + expect(ensure).toContain("await refreshOwnedRaycastCatalog(config, port)"); + expect(src).not.toContain("refreshAllOwnedIntegrations"); +}); + test("identical explicit mutation keys join but cannot swallow a different apply or disable", async () => { let release!: () => void; const gate = new Promise(resolve => { release = resolve; }); diff --git a/tests/config/client-config-export-new-clients.test.ts b/tests/config/client-config-export-new-clients.test.ts index 6b6b4c4e80..5a381d6237 100644 --- a/tests/config/client-config-export-new-clients.test.ts +++ b/tests/config/client-config-export-new-clients.test.ts @@ -58,12 +58,14 @@ function ctx(config: OcxConfig = LOOPBACK): ExportContext { describe("no secret reaches a client config", () => { test("the generated client support policy identifies every loopback-only integration", () => { - // Pi, Kimi, Gajae and Aside cannot emit the dedicated admission header -- - // Aside's observed provider block has four keys and none is `headers`. OMP - // and Prime can carry provider headers, but remote credential wiring is - // deliberately deferred from those initial generated integrations. + // Pi, Kimi, Gajae, Aside and Raycast cannot emit the dedicated admission + // header -- Aside's observed provider block has four keys and none is + // `headers`; Raycast's `api_keys` is read literally with no env + // interpolation. OMP and Prime can carry provider headers, but remote + // credential wiring is deliberately deferred from those initial generated + // integrations. const loopbackOnly = EXPORT_CLIENT_IDS.filter(id => EXPORT_CLIENTS[id].loopbackOnly); - expect(loopbackOnly).toEqual(["pi", "omp", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside"]); + expect(loopbackOnly).toEqual(["pi", "omp", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast"]); }); 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 707d6dd62c..6a71813e9f 100644 --- a/tests/config/client-config-export.test.ts +++ b/tests/config/client-config-export.test.ts @@ -32,6 +32,7 @@ import { normalizeExportModels as leafNormalizeExportModels } from "../../src/cl import * as omp from "../../src/clients/config-export/omp"; import * as dsh from "../../src/clients/config-export/dsh"; import * as mcode from "../../src/clients/config-export/mcode"; +import * as raycast from "../../src/clients/config-export/raycast"; import * as zcode from "../../src/clients/config-export/zcode"; /** @@ -100,6 +101,7 @@ describe("split config-export public facade", () => { ["dsh", dsh.buildDshClientConfig, dsh.summarizeDsh, dsh.buildDshContribution], ["mcode", mcode.buildMcodeClientConfig, mcode.summarizeMcode, mcode.buildMcodeContribution], ["zcode", zcode.buildZcodeClientConfig, zcode.summarizeZcode, zcode.buildZcodeContribution], + ["raycast", raycast.buildRaycastClientConfig, raycast.summarizeRaycast, raycast.buildRaycastContribution], ] as const; for (const [id, build, summarize, contribute] of leaves) { expect(EXPORT_CLIENTS[id].build).toBe(build); @@ -803,8 +805,8 @@ describe("hub-resolved Fast exports", () => { }); describe("EXPORT_CLIENTS registry", () => { - test("covers exactly the twelve file-toggle clients", () => { - expect(EXPORT_CLIENT_IDS).toEqual(["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside"]); + test("covers exactly the thirteen file-toggle clients", () => { + expect(EXPORT_CLIENT_IDS).toEqual(["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast"]); 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/config/client-config-new-clients.test.ts b/tests/config/client-config-new-clients.test.ts index 65b52727c9..7deb7fdb36 100644 --- a/tests/config/client-config-new-clients.test.ts +++ b/tests/config/client-config-new-clients.test.ts @@ -17,6 +17,7 @@ import { type OpenclawGeneratedConfig, } from "../../src/clients/config-export"; import { serializeDocument } from "../../src/integrations/serialize"; +import { readPath } from "../../src/integrations/state"; import type { OcxConfig } from "../../src/types"; /** @@ -159,14 +160,11 @@ describe("contributions describe what a writer would own", () => { test("every client's fragments point at real entries in its own document", () => { for (const clientId of EXPORT_CLIENT_IDS) { - const document = buildClientConfig(clientId, ctx()) as Record; + const document = buildClientConfig(clientId, ctx()); for (const fragment of EXPORT_CLIENTS[clientId].buildContribution(ctx()).fragments) { - let cursor: unknown = document; - for (const key of fragment.path) { - expect(cursor && typeof cursor === "object").toBe(true); - cursor = (cursor as Record)[key]; - } - expect(cursor).toEqual(fragment.value); + // Read through the writer's own segment grammar: Raycast's path holds + // a `[id=opencodex]` selector into a sequence, not a map key. + expect(readPath(document, fragment.path)).toEqual(fragment.value); } } }); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index db2583b00b..74baefe92c 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -521,6 +521,7 @@ "install-scripts.test.ts": "ci-workflows", "integrations-invariants.test.ts": "gui", "integrations-journal.test.ts": "clients", + "integrations-merge.test.ts": "clients", "integrations-serialize.test.ts": "clients", "integrations-state.test.ts": "clients", "integrations-writer.test.ts": "clients", @@ -829,6 +830,8 @@ "reserve-quota-scope.test.ts": "codex-integration", "rate-limit-reset-credits.test.ts": "gui", "rate-limit-retry.test.ts": "providers", + "raycast-client.test.ts": "clients", + "raycast-detect.test.ts": "clients", "reasoning-effort.test.ts": "codex-integration", "reasoning-replay-identity.test.ts": "adapters", "reasoning-replay-robustness.test.ts": "adapters", diff --git a/tests/gui/integrations-invariants.test.ts b/tests/gui/integrations-invariants.test.ts index 33e7480f86..2353104311 100644 --- a/tests/gui/integrations-invariants.test.ts +++ b/tests/gui/integrations-invariants.test.ts @@ -6,7 +6,7 @@ import { EXPORT_CLIENTS, EXPORT_CLIENT_IDS, type ExportModel } from "../../src/c import { parseConfig } from "../../src/integrations/config-io"; import { INTEGRATION_CLIENTS, INTEGRATION_CLIENT_IDS, type IntegrationClientId } from "../../src/integrations/registry"; import { createIntegrationStateStore, type IntegrationStateStore } from "../../src/integrations/store"; -import { readIntegrationState } from "../../src/integrations/state"; +import { readIntegrationState, readPath } from "../../src/integrations/state"; import { applyIntegration, disableIntegration, restoreIntegration } from "../../src/integrations/writer"; import { printSubcommandUsage, printUsage } from "../../src/cli/help"; import type { OcxConfig } from "../../src/types"; @@ -78,9 +78,9 @@ afterEach(() => { }); describe("the client registries cannot drift apart", () => { - test("every list of clients holds exactly the same twelve ids", async () => { + test("every list of clients holds exactly the same thirteen ids", async () => { /* - * Five lists name the same twelve clients, and two of them are maintained by + * Five lists name the same thirteen clients, and two of them are maintained by * hand: the GUI cannot import the backend registry, because that would * pull node:os and node:path into the browser bundle. A client added * server-side renders no row until someone remembers the tuple, and the @@ -91,7 +91,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(12); + expect(expected).toHaveLength(13); expect([...INTEGRATION_CLIENT_IDS].sort()).toEqual(expected); expect([...gui.CLIENTS].sort()).toEqual(expected); @@ -170,6 +170,13 @@ describe("every client survives a full lifecycle", () => { prime: '{\n "providers": {\n "mine": { "api": "http://keep-me" }\n }\n}\n', // Aside reads the same models.json contract as Pi and Prime. aside: '{\n "providers": {\n "mine": { "api": "http://keep-me" }\n }\n}\n', + // Raycast's `providers` is a SEQUENCE keyed by `id`, so the user's entry is + // a sibling element rather than a sibling map key. + raycast: "providers:\n - id: lmstudio\n name: LM Studio\n base_url: http://localhost:1234/v1\n models: []\n", + }; + /** Where the seed's user-owned entry lives when the seed is a sequence. */ + const USER_ELEMENT: Partial> = { + raycast: ["providers", "[id=lmstudio]"], }; for (const clientId of INTEGRATION_CLIENT_IDS) { @@ -190,18 +197,22 @@ describe("every client survives a full lifecycle", () => { const afterApply = parseConfig(readFileSync(configPath, "utf8"), format); const record = store.readRecords()[clientId]!; expect(record.fragmentPaths.length).toBeGreaterThan(0); + // Read through the writer's own segment grammar: Raycast's path holds a + // `[id=opencodex]` selector into a sequence, not a map key. for (const path of record.fragmentPaths) { - let cursor: unknown = afterApply; - for (const segment of path) { - expect(cursor && typeof cursor === "object").toBe(true); - cursor = (cursor as Record)[segment]; - } - expect(cursor).toBeDefined(); + expect(readPath(afterApply, path)).toBeDefined(); + } + // …and the user's own entry is untouched. `toMatchObject` treats an + // array as exact-length, so a sequence-shaped seed is checked by the + // same selector the writer uses to find its own element. + const userElement = USER_ELEMENT[clientId]; + if (userElement) { + expect(readPath(afterApply, userElement)).toEqual(readPath(original, userElement)); + } else { + expect((afterApply as Record)).toMatchObject( + original as Record, + ); } - // …and the user's own entry is untouched. - expect((afterApply as Record)).toMatchObject( - original as Record, - ); const disabled = disableIntegration({ clientId, models: MODELS, config: CONFIG, port: 10100, diff --git a/tests/server/management-integration-routes.test.ts b/tests/server/management-integration-routes.test.ts index 1f8cba92a2..e0d6563aea 100644 --- a/tests/server/management-integration-routes.test.ts +++ b/tests/server/management-integration-routes.test.ts @@ -14,6 +14,7 @@ import { handleManagementAPI } from "../../src/server/management-api"; import { setIntegrationMutationFlightTestHooks, setIntegrationPathTestHooks, + setRaycastDetectTestHook, } from "../../src/server/management/integration-routes"; import type { OcxConfig } from "../../src/types"; import { catalogConvergenceFactory } from "../helpers/catalog-convergence"; @@ -274,6 +275,31 @@ describe("GET /api/client-integrations", () => { // A read is a read: it appends nothing. expect(store.listOperations()).toHaveLength(before); }); + + test("the raycast envelope carries the plan block; every other client's does not", async () => { + // Stubbed: the real detector spawns `defaults` and would report the + // developer's own subscription. + setRaycastDetectTestHook(() => ({ appPath: "/Applications/Raycast.app", aiDirPresent: false, plan: "free" })); + try { + const raycast = await api("/api/client-integrations/raycast"); + expect(raycast.status).toBe(200); + const body = await raycast.json() as { clientId: string; raycast?: { plan: string; appPath: string | null; aiDirPresent: boolean } }; + expect(body.clientId).toBe("raycast"); + expect(body.raycast).toEqual({ appPath: "/Applications/Raycast.app", aiDirPresent: false, plan: "free" }); + + installHermes(); + const hermes = await api("/api/client-integrations/hermes"); + expect(hermes.status).toBe(200); + expect("raycast" in (await hermes.json() as Record)).toBe(false); + + // The collection read describes files, not apps: no client gets the block there. + const list = await api("/api/client-integrations"); + const { clients } = await list.json() as { clients: Array> }; + expect(clients.some(client => "raycast" in client)).toBe(false); + } finally { + setRaycastDetectTestHook(null); + } + }); }); /** The models the route itself derives, so expectations cannot drift from it. */