From 898ea15d349e2a908e6e6d0ed1b4820763a66876 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:24:11 +0800 Subject: [PATCH 1/2] feat(adopt): connect Pi and oh-my-pi; list the shared skills folder Pi (`~/.pi/agent/models.json`) and its fork oh-my-pi (`~/.omp/agent/models.yml`) can now be pointed at the gateway in one step. Both get a provider of their own (`thinkwatch`), never a built-in one, so `/login` credentials are not sent to the gateway. The provider carries the gateway's model list for the client's key; each model uses its family's API (Claude: anthropic-messages, Gemini: google-generative-ai, GPT/o-series/Codex: openai-responses, others: openai-completions) with the base URL that API expects. The default model is left alone, like opencode. oh-my-pi always gets `auth: apiKey`, otherwise its custom anthropic-messages models are shaped as Claude Code. Pi's key is escaped so `$` and a leading `!` are taken literally. When HTTP(S)_PROXY or Pi's httpProxy setting applies and NO_PROXY does not list the gateway host, the plan says Pi will send gateway requests through the proxy. Both MCP files are listed read-only and scanned along with skills, prompts/commands and instruction files. `~/.agents/skills` (and a project's `.agents/skills`) is attributed to a shared folder instead of DeepSeek Harness / Antigravity CLI. The DeepSeek Harness desktop app counts as an installed DSH, and the DSH plan notes that models used through a DeepSeek account in the desktop app go straight to api.deepseek.com. Codex no longer lists OPENAI_BASE_URL, which it stopped reading in openai/codex 4b8bab6. YAML writing can now replace a whole value (via tw_yaml::put) and write a nested key into an empty file, which oh-my-pi's model list needs. Co-Authored-By: Claude Opus 5.5 --- README.md | 10 +- README.zh-CN.md | 4 +- scripts/shots/mock/clients.ts | 71 +- src-tauri/crates/tw-adopt/src/clients.rs | 167 ++++- src-tauri/crates/tw-adopt/src/cloud.rs | 7 + src-tauri/crates/tw-adopt/src/detect.rs | 36 +- src-tauri/crates/tw-adopt/src/lib.rs | 1 + src-tauri/crates/tw-adopt/src/locations.rs | 14 + src-tauri/crates/tw-adopt/src/mcp.rs | 43 +- src-tauri/crates/tw-adopt/src/paths.rs | 58 ++ src-tauri/crates/tw-adopt/src/pi.rs | 671 +++++++++++++++++++ src-tauri/crates/tw-adopt/src/plan.rs | 28 +- src-tauri/crates/tw-adopt/src/yaml.rs | 227 ++++++- src-tauri/crates/tw-adopt/tests/roundtrip.rs | 244 +++++++ src-tauri/crates/tw-scan/src/report.rs | 12 + src-tauri/crates/tw-scan/src/sources.rs | 238 ++++++- src-tauri/crates/tw-scan/tests/scan.rs | 84 +++ src-tauri/msg-codes.txt | 7 + src-tauri/src/clients/mod.rs | 6 +- src-tauri/src/clients/ops.rs | 54 +- src-tauri/src/supervisor/user_env.rs | 50 +- src-tauri/src/wire.rs | 2 +- src/generated/lite-api.ts | 2 +- src/i18n/core.zh.json | 7 + src/logos.test.ts | 5 + src/mcp/McpPage.i18n.tsx | 3 + src/mcp/McpPage.tsx | 6 +- src/ui/logo-data.ts | 5 + src/ui/logos.tsx | 2 + 29 files changed, 1975 insertions(+), 89 deletions(-) create mode 100644 src-tauri/crates/tw-adopt/src/pi.rs diff --git a/README.md b/README.md index 4d83a17..a158bb2 100644 --- a/README.md +++ b/README.md @@ -33,10 +33,10 @@ before the client runs them. ## Highlights - **Connect once, switch freely.** Claude Code, Claude Desktop, Codex, - opencode, Zed, Aider and DeepSeek Harness are pointed at the gateway in one - step, with the change previewed, the original file backed up and a restore - always available; Cursor, Continue and Antigravity CLI come with - instructions. Switching upstreams then happens in the gateway alone. + opencode, Pi, oh-my-pi, Zed, Aider and DeepSeek Harness are pointed at the + gateway in one step, with the change previewed, the original file backed up + and a restore always available; Cursor, Continue and Antigravity CLI come + with instructions. Switching upstreams then happens in the gateway alone. - **Protection against relays.** A relay sees every request in full and can rewrite every answer. Outbound redaction swaps API keys, private keys, JWTs and connection-string passwords for placeholders before a request leaves, so @@ -46,7 +46,7 @@ before the client runs them. tool-call inspection cuts the answer off before the client can run it. Hidden characters and prompt injection can be refused as well. The protections start in Observe and switch to Enforce one by one. -- **MCP servers, skills and hooks, scanned.** The MCP servers of eight clients +- **MCP servers, skills and hooks, scanned.** The MCP servers of ten clients side by side, with third-party servers marked, and a scan of client configuration, skills, hooks and project instructions for hidden characters, prompt injection, dangerous commands and overly broad permissions. diff --git a/README.zh-CN.md b/README.zh-CN.md index 892c252..dcfcd31 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -27,9 +27,9 @@ Claude Code、Codex 等 AI 客户端的本地网关,支持 macOS、Windows 与 ## 要点 -- **一次接入,随时切换。** Claude Code、Claude Desktop、Codex、opencode、Zed、Aider 与 DeepSeek Harness 可一键指向网关,写入前预览改动、备份原文件,随时可以还原;Cursor、Continue 与 Antigravity CLI 提供配置说明。此后切换上游只在网关中完成。 +- **一次接入,随时切换。** Claude Code、Claude Desktop、Codex、opencode、Pi、oh-my-pi、Zed、Aider 与 DeepSeek Harness 可一键指向网关,写入前预览改动、备份原文件,随时可以还原;Cursor、Continue 与 Antigravity CLI 提供配置说明。此后切换上游只在网关中完成。 - **防范中转站。** 中转站能看到请求的全部内容,也能改写每一次回答。出站脱敏在请求发出前把 API 密钥、私钥、JWT 与连接串口令换成占位符,中转站拿不到原值。回答中若出现下载即执行、外发环境变量或凭据文件、读取私钥、写入开机启动项或定时任务之类的工具调用,工具调用审查会在客户端执行之前切断回答;隐藏字符与提示注入也可以直接拒绝。各项防护出厂只记录,逐项切换到拦截即可生效。 -- **扫描 MCP、技能与钩子。** 八款客户端的 MCP 服务器并列显示并标出第三方服务器;客户端配置、技能、钩子与项目指令中的隐藏字符、提示注入、危险命令与过宽权限会被找出。 +- **扫描 MCP、技能与钩子。** 十款客户端的 MCP 服务器并列显示并标出第三方服务器;客户端配置、技能、钩子与项目指令中的隐藏字符、提示注入、危险命令与过宽权限会被找出。 - **每个请求都可追溯。** 命中的规则、尝试过的每个上游、API 格式转换与费用的计算依据都在请求详情中;已结束的请求可以重放到另一个上游,并排对比。 - **按规则分流,失败自动换。** 按模型、工具、图片、扩展思考等条件分流。回答开始前上游出错时换用下一个,同一会话固定使用同一上游,提示缓存保持有效。标题生成等辅助请求可在本地应答。 - **多种上游,接口互转。** API 密钥、Amazon Bedrock、ChatGPT 与 Z.ai 账号、OpenRouter 等中转服务与本机模型均可作为上游,Anthropic、OpenAI、Gemini 接口之间自动转换。 diff --git a/scripts/shots/mock/clients.ts b/scripts/shots/mock/clients.ts index 40c7dc0..37c0f9f 100644 --- a/scripts/shots/mock/clients.ts +++ b/scripts/shots/mock/clients.ts @@ -119,6 +119,23 @@ function setup(id: string, path: string): ManualSetup { secret("refs.THINKWATCH_API_KEY"), ], }; + case "pi": + case "omp": + return { + steps: [file(path)], + endpoint: v1(), + fields: [ + ...(id === "pi" ? [set("providers.thinkwatch.name", "ThinkWatch")] : []), + set("providers.thinkwatch.baseUrl", v1()), + set("providers.thinkwatch.api", "openai-completions"), + secret("providers.thinkwatch.apiKey"), + ...(id === "omp" ? [set("providers.thinkwatch.auth", "apiKey")] : []), + set( + "providers.thinkwatch.models", + `[{id: claude-sonnet-5, api: anthropic-messages, baseUrl: ${base()}}, {id: gpt-5.5, api: openai-responses}, {id: deepseek-chat}]`, + ), + ], + }; default: return { steps: [file(path)], endpoint: v1(), fields: [set("openai-api-base", v1()), secret("openai-api-key")] }; } @@ -257,6 +274,10 @@ function clientsNow(): DetectedClient[] { "adopt.cost.dsh.every_entry", "The web app, the desktop app and headless runs all go through the gateway, without a restart.", ), + msg( + "adopt.cost.dsh.account_direct", + "Models used through a DeepSeek account signed in to the desktop app go straight to api.deepseek.com and do not pass through the gateway.", + ), msg("adopt.cost.dsh.web_search", "Web search still goes straight to DeepSeek rather than through the gateway."), msg( "adopt.cost.dsh.settings_page", @@ -268,6 +289,32 @@ function clientsNow(): DetectedClient[] { ), ], }), + detected({ + id: "pi", + name: "Pi", + path: "~/.pi/agent/models.json", + installed: false, + has_config: false, + costs: [ + msg( + "adopt.cost.pi.default_model", + "The default model stays as it is; ThinkWatch models are chosen in /model, where Ctrl+S makes one the default.", + ), + ], + }), + detected({ + id: "omp", + name: "oh-my-pi", + path: "~/.omp/agent/models.yml", + installed: false, + has_config: false, + costs: [ + msg( + "adopt.cost.omp.default_model", + "The default model stays as it is; ThinkWatch models are chosen in /model, where assigning the default role makes one the default.", + ), + ], + }), ]; } @@ -367,7 +414,7 @@ export function plan(id: string, restore: boolean): PlanView { // ───────────────────────────────────────── MCP 与扫描 -/** MCP 能写进哪几个客户端(tw-adopt mcp.rs 的 `targets`,一个不少)。Zed、Antigravity CLI 与 DeepSeek Harness 这台机器上没有,画在矩阵下方 */ +/** MCP 能写进哪几个客户端(tw-adopt mcp.rs 的 `targets`,一个不少)。Zed、Antigravity CLI、Pi、oh-my-pi 与 DeepSeek Harness 这台机器上没有,画在矩阵下方 */ export function mcpTargets(): McpTargetView[] { return [ { client: "claude-code", name: "Claude Code", path: "~/.claude.json", copyable: true, why_not: null, movable: true, present: true }, @@ -404,6 +451,28 @@ export function mcpTargets(): McpTargetView[] { why_not: msg("adopt.mcp.zed_structure", "Zed's context servers use a different structure and do not take the command/args form"), movable: true, present: false, }, + { + client: "pi", + name: "Pi", + path: "~/.pi/agent/mcp.json", + copyable: false, + why_not: msg( + "adopt.mcp.unverified_format", + "this client's MCP configuration format is not verified yet, and writing to it could leave the client unable to read its own configuration", + ), + movable: true, present: false, + }, + { + client: "omp", + name: "oh-my-pi", + path: "~/.omp/agent/mcp.json", + copyable: false, + why_not: msg( + "adopt.mcp.unverified_format", + "this client's MCP configuration format is not verified yet, and writing to it could leave the client unable to read its own configuration", + ), + movable: true, present: false, + }, { client: "dsh", name: "DeepSeek Harness", diff --git a/src-tauri/crates/tw-adopt/src/clients.rs b/src-tauri/crates/tw-adopt/src/clients.rs index e02de93..095fe37 100644 --- a/src-tauri/crates/tw-adopt/src/clients.rs +++ b/src-tauri/crates/tw-adopt/src/clients.rs @@ -160,8 +160,8 @@ pub struct Client { /// 密钥不在配置文件里、要在客户端自己的界面里填时,手动配置多出来的那一步 /// (「码,英文原句」)。只有 Zed 是这样:它的密钥走自己的凭据存储。 pub key_elsewhere: Option<(&'static str, &'static str)>, - /// 模型清单要写进它的配置(opencode):接管时写一份网关对这把密钥答的清单, - /// 上游或路由变了之后客户端页提示更新。别的客户端自己去问网关。 + /// 模型清单要写进它的配置(opencode、Pi、oh-my-pi):接管时写一份网关对这把密钥答的 + /// 清单,上游或路由变了之后客户端页提示更新。别的客户端自己去问网关。 pub writes_models: bool, /// 它自己会重读配置,改完不用重启。**装着的版本说了算**,见 [`Client::here`] pub reloads: bool, @@ -349,7 +349,10 @@ pub fn adoptable() -> Vec { verified: Verified::Measured, marker: &[Loc::Home(".codex")], process: &["codex"], - env_vars: &["OPENAI_API_KEY", "OPENAI_BASE_URL", "CODEX_HOME"], + // **没有 `OPENAI_BASE_URL`**:Codex 2026-04 起不再读它(openai/codex 4b8bab6, + // 换成了 config.toml 里的 `openai_base_url`)。shell 里还 export 着它的用户, + // 诊断再说「这一行盖住了接管写的值」就是误报 + env_vars: &["OPENAI_API_KEY", "CODEX_HOME"], key_elsewhere: None, writes_models: false, reloads: false, @@ -462,6 +465,12 @@ pub fn adoptable() -> Vec { code!("adopt.cost.dsh.every_entry"), "The web app, the desktop app and headless runs all go through the gateway, without a restart.", ), + // 桌面版能用 DeepSeek 账号登录:那条线路是另一行插件(`llm-deepseek-account`), + // 令牌只交给 DeepSeek 自己的地址(`resolveToken` 认目的地),改不到网关上来 + ( + code!("adopt.cost.dsh.account_direct"), + "Models used through a DeepSeek account signed in to the desktop app go straight to api.deepseek.com and do not pass through the gateway.", + ), ( code!("adopt.cost.dsh.web_search"), "Web search still goes straight to DeepSeek rather than through the gateway.", @@ -476,6 +485,8 @@ pub fn adoptable() -> Vec { ), ], verified: Verified::FieldsOnly, + // 桌面版装好、还没打开过时家目录还不在:那时认它的应用本身,见 + // [`Client::installed`] marker: &[crate::paths::DSH_DIR], process: &["dsh"], // 继承下来的环境变量压过凭据文件:shell 里 export 了同名的, @@ -487,6 +498,73 @@ pub fn adoptable() -> Vec { config_beats_env: false, custom_config: None, }, + // Pi(earendil-works/pi,原来的 badlogic/pi-mono)。写的是 `models.json` 里一个 + // 自己的 provider 和模型清单,见 `crate::pi`。 + // + // 字段名照 Pi 0.99.2 的源码(`model-config.ts` 的 schema、`provider-composer.ts` + // 的 `modelFromJson`),没有在本机实跑过 + Client { + id: "pi", + name: "Pi", + config: &[crate::paths::PI_MODELS], + format: Format::Json, + // 打开 /model 就重读 models.json(`model-runtime.ts` 的 `refresh`),跑着的 Pi + // 不用重启 + takes_effect: TakesEffect::Immediately, + shadowed_by: &[], + // **默认模型不动**(见 `crate::pi`):接管之后它还用原来那个,要说清去哪儿选 + costs: &[( + code!("adopt.cost.pi.default_model"), + "The default model stays as it is; ThinkWatch models are chosen in /model, where Ctrl+S makes one the default.", + )], + verified: Verified::FieldsOnly, + marker: &[crate::paths::PI_DIR], + // npm 装的 Pi 进程名就是 node,认不出它;它自己重读配置(`reloads`),诊断用不着 + // 这一项 + process: &[], + // 它把整个配置目录挪走:shell 里 export 了它,Pi 读的就不是这里写的那一份 + env_vars: &["PI_CODING_AGENT_DIR"], + key_elsewhere: None, + writes_models: true, + reloads: true, + config_beats_env: false, + custom_config: None, + }, + // oh-my-pi(`omp`,can1357/oh-my-pi),Pi 的分支:同样的 provider 写法,换成了 + // YAML 的 `models.yml`,而且必须写明 `auth: apiKey`(见 `crate::pi::edits`)。 + // + // 字段名照它的源码(`models-config-schema-bundle.ts`、`custom-models.ts`)和 + // docs/models.md,没有在本机实跑过 + Client { + id: "omp", + name: "oh-my-pi", + config: crate::paths::OMP_MODELS, + format: Format::Yaml, + // 模型选择器打开时,models.yml 改过就重读(`model-picker.ts` 的 + // `refreshIfStale`) + takes_effect: TakesEffect::Immediately, + shadowed_by: &[], + costs: &[( + code!("adopt.cost.omp.default_model"), + "The default model stays as it is; ThinkWatch models are chosen in /model, where assigning the default role makes one the default.", + )], + verified: Verified::FieldsOnly, + marker: &[crate::paths::OMP_DIR], + process: &["omp"], + // 这几个都换它读的目录:前两个挪整个配置目录,后两个选一个具名 profile + // (`~/.omp/profiles/<名>/agent`) + env_vars: &[ + "PI_CODING_AGENT_DIR", + "PI_CONFIG_DIR", + "OMP_PROFILE", + "PI_PROFILE", + ], + key_elsewhere: None, + writes_models: true, + reloads: true, + config_beats_env: false, + custom_config: None, + }, // 官方的「第三方推理」模式。**一次改四个文件**,写哪几个、为什么,见 // `crate::desktop`;这里的 `config` 是其中的主文件,我们在它配置库里的那一份。 Client { @@ -888,6 +966,9 @@ pub fn edits(client: &Client, gw: &Gateway) -> Vec { Val::s(DSH_KEY_REF), ), ], + // 一个自己的 provider 加上模型清单,每个模型挑它本家的 API。见 `crate::pi` + "pi" => crate::pi::edits(gw, crate::pi::Flavor::Pi), + "omp" => crate::pi::edits(gw, crate::pi::Flavor::Omp), _ => Vec::new(), } } @@ -968,6 +1049,32 @@ impl Client { self } + /// 这台机器上装了它:痕迹在([`Client::marker`]),或者找得到它的桌面应用。 + /// + /// 后一种只有 DeepSeek Harness:桌面版装好、还没打开过的时候,它的家目录还不在, + /// 可它确实装了([`crate::paths::dsh_desktop_app`])。 + pub fn installed(&self, home: &std::path::Path) -> bool { + self.marker.iter().any(|m| m.resolve(home).exists()) + || (self.id == "dsh" && crate::paths::dsh_desktop_app(home).is_some()) + } + + /// 配置里此刻写着的模型(只有 [`Client::writes_models`] 的客户端有)。没有那一条 + /// provider 就是 `None`。 + pub fn models_in(&self, text: &str) -> Option> { + match self.id { + "opencode" => crate::opencode::models_in(text), + id => crate::pi::models_in(text, crate::pi::Flavor::of(id)?), + } + } + + /// 几个候选文件([`Client::config`])里它只读在的第一个,不合并。 + /// + /// oh-my-pi 是这样:`models.yml` 一在,`models.yaml` 就整个没人读 —— 排在前面的那一份 + /// 盖住的不是同名的几项,是这里写的全部。opencode 是逐层合并的,不一样。 + pub fn reads_first_only(&self) -> bool { + self.id == "omp" + } + /// 手动配置的几步:打开哪个文件、写下面那几项(就是接管时写的那几项, /// 见 [`edits`]),密钥要另外填的再加一步。 /// @@ -1280,10 +1387,11 @@ mod tests { models: Vec::new(), }; let by = |id: &str| adoptable().into_iter().find(|c| c.id == id).unwrap(); - // 各要各的写法:Claude Code 和 Claude Desktop 不带 /v1,其余带 + // 各要各的写法:Claude Code 和 Claude Desktop 不带 /v1,其余带(Pi 和 oh-my-pi 的 + // provider 带 /v1,Claude 那几个模型自己另写不带的) assert_eq!(by("claude-code").endpoint(&gw), "http://127.0.0.1:18790"); assert_eq!(by("claude-desktop").endpoint(&gw), "http://127.0.0.1:18790"); - for id in ["codex", "opencode", "zed", "aider", "dsh"] { + for id in ["codex", "opencode", "zed", "aider", "dsh", "pi", "omp"] { assert_eq!(by(id).endpoint(&gw), "http://127.0.0.1:18790/v1", "{id}"); } for c in adoptable() { @@ -1371,6 +1479,55 @@ mod tests { } } + /// Codex 2026-04 起不读 `OPENAI_BASE_URL` 了(openai/codex 4b8bab6):shell 里 export 着 + /// 它不该再被说成盖住了接管写的值 + #[test] + fn codex_no_longer_watches_openai_base_url() { + let codex = adoptable().into_iter().find(|c| c.id == "codex").unwrap(); + assert!(!codex.env_vars.contains(&"OPENAI_BASE_URL")); + assert!(codex.env_vars.contains(&"CODEX_HOME")); + } + + /// Pi 和 oh-my-pi 只写自己那一个 provider:改内置的,`/login` 的凭据就跟着发到网关去了 + #[test] + fn pi_and_omp_write_a_provider_of_their_own() { + let gw = Gateway::keyed("http://127.0.0.1:8788", "tw-k", vec!["claude-x".into()]); + for id in ["pi", "omp"] { + let c = adoptable().into_iter().find(|c| c.id == id).unwrap(); + for e in edits(&c, &gw) { + assert_eq!( + e.path[..2], + ["providers".to_string(), PROVIDER_ID.to_string()], + "{id}:{:?}", + e.path + ); + } + assert!(c.writes_models && c.reloads, "{id}"); + assert_eq!(c.verified, Verified::FieldsOnly, "{id}"); + } + } + + /// DeepSeek Harness 桌面版装好还没打开过:家目录还不在,那一行照样算装了 + #[test] + fn the_dsh_desktop_app_counts_as_dsh_installed() { + let d = tempfile::tempdir().unwrap(); + let dsh = adoptable().into_iter().find(|c| c.id == "dsh").unwrap(); + assert!(!dsh.installed(d.path())); + let app = if cfg!(target_os = "macos") { + "Applications/DeepSeek Harness.app" + } else if cfg!(windows) { + "AppData/Local/Programs/DeepSeek Harness/DeepSeek Harness.exe" + } else { + // Linux 版没有发布,没有可认的位置 + return; + }; + let p = crate::paths::under(d.path(), app); + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(&p, "").unwrap(); + assert!(dsh.installed(d.path())); + assert_eq!(crate::paths::dsh_desktop_app(d.path()), Some(p)); + } + /// 码重了等于两句不同的话共用一条译文 —— 改其中一句,另一句会跟着 /// 变,而没有任何东西会说出来。 #[test] diff --git a/src-tauri/crates/tw-adopt/src/cloud.rs b/src-tauri/crates/tw-adopt/src/cloud.rs index 7ecd608..00db6ab 100644 --- a/src-tauri/crates/tw-adopt/src/cloud.rs +++ b/src-tauri/crates/tw-adopt/src/cloud.rs @@ -93,6 +93,10 @@ pub struct Around { /// core 解析 `${变量名}` 用的环境:本机的 core 起的时候拿到的那一份。**`None` = 不知道** /// (连着远程 core,变量在服务器上解析)—— 那时不说哪个变量网关看不见 pub core_env: Option>, + /// 用户环境里的代理变量(`http_proxy`、`NO_PROXY` 这些,大小写照原样)。`env` 里没有 + /// 它们:带给 core 的那一份特意滤掉了代理,见桌面端的 `user_env`。Pi 访问网关会不会经过 + /// 代理要看它们([`crate::pi::proxy_notes`]) + pub proxy: BTreeMap, } impl Around { @@ -106,6 +110,8 @@ impl Around { core_env: (!remote).then(|| env.clone()), env, managed, + // 代理变量另外给(桌面端的 `user_env` 从同一份用户环境里取出来) + proxy: BTreeMap::new(), } } @@ -121,6 +127,7 @@ impl Around { env: BTreeMap::new(), managed, core_env, + proxy: BTreeMap::new(), } } } diff --git a/src-tauri/crates/tw-adopt/src/detect.rs b/src-tauri/crates/tw-adopt/src/detect.rs index 027f65b..cd83426 100644 --- a/src-tauri/crates/tw-adopt/src/detect.rs +++ b/src-tauri/crates/tw-adopt/src/detect.rs @@ -63,6 +63,8 @@ fn endpoint_of(c: &Client, text: &str) -> Option { ], "aider" => vec!["openai-api-base"], "dsh" => vec!["llm-deepseek", "config", "baseURL"], + // provider 本身的地址。Claude 那几个模型自己另写的不带 /v1 的地址不算在这里 + "pi" | "omp" => vec!["providers", crate::clients::PROVIDER_ID, "baseUrl"], "claude-desktop" => vec!["inferenceGatewayBaseUrl"], _ => return None, }; @@ -103,7 +105,7 @@ pub fn detect_one(c: &Client, home: &Path) -> Detected { Detected { id: c.id, name: c.name, - installed: c.marker.iter().any(|m| m.resolve(home).exists()) || text.is_some(), + installed: c.installed(home) || text.is_some(), has_config: text.is_some(), adopted_at_ms: rec .filter(|r: &SidecarRecord| r.client == c.id) @@ -112,7 +114,7 @@ pub fn detect_one(c: &Client, home: &Path) -> Detected { models: text .as_deref() .filter(|_| c.writes_models) - .and_then(crate::opencode::models_in), + .and_then(|t| c.models_in(t)), shadows: c.live_shadows(home), takes_effect: c.takes_effect, verified: c.verified, @@ -1005,8 +1007,10 @@ fn diagnose_in( for s in &d.shadows { let text = std::fs::read_to_string(s).unwrap_or_default(); let hits = overriding_fields(c, &text); + // oh-my-pi 只读在的第一个文件:它在,这里写的就整个没人读,不看里面写了什么 + let whole = c.reads_first_only(); out.push(Finding { - level: if hits.is_empty() { + level: if hits.is_empty() && !whole { Level::Suspect } else { Level::Blocking @@ -1016,7 +1020,12 @@ fn diagnose_in( path = s.display() => "{path} takes precedence over what was written here" ), - detail: if hits.is_empty() { + detail: if whole { + msg!( + "adopt.diag.shadowed.whole_file", client = c.name + => "{client} reads only this file when it exists, so nothing written here is used." + ) + } else if hits.is_empty() { msg!("adopt.diag.shadowed.no_fields" => "The file exists, but carries none of the fields in question.") } else { msg!( @@ -1263,7 +1272,7 @@ mod tests { std::fs::create_dir_all(home.join(".codex")).unwrap(); std::fs::write( home.join(".zshrc"), - "export ANTHROPIC_BASE_URL=https://old\nexport OPENAI_BASE_URL=https://old\n", + "export ANTHROPIC_BASE_URL=https://old\nexport OPENAI_API_KEY=sk-old\nexport OPENAI_BASE_URL=https://old\n", ) .unwrap(); @@ -1283,9 +1292,14 @@ mod tests { let cx = diagnose(&c("codex"), home, None, &crate::cloud::Around::default()); let f = cx .iter() - .find(|f| f.title.text.contains("OPENAI_BASE_URL")) + .find(|f| f.title.text.contains("OPENAI_API_KEY")) .unwrap(); assert_eq!(f.level, Level::Blocking, "{:?}", f); + // Codex 2026-04 起不读 OPENAI_BASE_URL:那一行和它无关 + assert!( + !cx.iter().any(|f| f.title.text.contains("OPENAI_BASE_URL")), + "{cx:?}" + ); } #[test] @@ -1323,11 +1337,7 @@ mod tests { fn the_fix_is_a_command_we_hand_over_not_one_we_run() { // 报告是我们的职责,修改是他的权利。 let d = tempfile::tempdir().unwrap(); - std::fs::write( - d.path().join(".zshrc"), - "export OPENAI_BASE_URL=https://old\n", - ) - .unwrap(); + std::fs::write(d.path().join(".zshrc"), "export OPENAI_API_KEY=sk-old\n").unwrap(); let out = diagnose( &c("codex"), d.path(), @@ -1336,7 +1346,7 @@ mod tests { ); let f = out .iter() - .find(|f| f.title.text.contains("OPENAI_BASE_URL")) + .find(|f| f.title.text.contains("OPENAI_API_KEY")) .unwrap(); let fix = &f.fix.as_ref().unwrap().text; // 各平台自己那个 sed 的写法,见 `delete_line` @@ -1626,7 +1636,7 @@ mod tests { std::fs::create_dir_all(root.join("home/u/.codex")).unwrap(); std::fs::write( root.join("home/u/.bashrc"), - "# x\nexport OPENAI_BASE_URL=http://x\n", + "# x\nexport OPENAI_API_KEY=sk-x\n", ) .unwrap(); let w = crate::wsl::WslHome::read( diff --git a/src-tauri/crates/tw-adopt/src/lib.rs b/src-tauri/crates/tw-adopt/src/lib.rs index 649ee7b..0538cdb 100644 --- a/src-tauri/crates/tw-adopt/src/lib.rs +++ b/src-tauri/crates/tw-adopt/src/lib.rs @@ -20,6 +20,7 @@ pub mod locations; pub mod mcp; pub mod opencode; pub mod paths; +pub mod pi; pub mod plan; pub mod rows; pub mod sentinel; diff --git a/src-tauri/crates/tw-adopt/src/locations.rs b/src-tauri/crates/tw-adopt/src/locations.rs index fc8a69d..4f73ca2 100644 --- a/src-tauri/crates/tw-adopt/src/locations.rs +++ b/src-tauri/crates/tw-adopt/src/locations.rs @@ -130,6 +130,20 @@ pub fn layouts() -> Vec { shared: false, scan_dir: true, }, + // 接管写 `models.json` / `models.yml`,MCP 在同一目录的 `mcp.json`,skills 和指令文件 + // 也在这个目录下:`PI_CODING_AGENT_DIR` 挪的是整个目录 + Layout { + client: "pi", + dir: crate::paths::PI_DIR, + shared: false, + scan_dir: true, + }, + Layout { + client: "omp", + dir: crate::paths::OMP_DIR, + shared: false, + scan_dir: true, + }, ] } diff --git a/src-tauri/crates/tw-adopt/src/mcp.rs b/src-tauri/crates/tw-adopt/src/mcp.rs index 5353bee..32a3ff2 100644 --- a/src-tauri/crates/tw-adopt/src/mcp.rs +++ b/src-tauri/crates/tw-adopt/src/mcp.rs @@ -189,6 +189,34 @@ pub fn targets() -> Vec { )), custom_path: None, }, + // Pi 0.99 起的 MCP 和 oh-my-pi 自己的那一份:顶层 `mcpServers`,写法照 Claude 的 + // (两边的文档都说可以原样抄过来)。只从源码和文档里查证过、本机没有样本 —— 先只读 + Target { + client: "pi", + name: "Pi", + config: &[crate::paths::PI_MCP], + format: Format::Json, + key: &["mcpServers"], + copyable: false, + why_not: Some(( + code!("adopt.mcp.unverified_format"), + "this client's MCP configuration format is not verified yet, and writing to it could leave the client unable to read its own configuration", + )), + custom_path: None, + }, + Target { + client: "omp", + name: "oh-my-pi", + config: &[crate::paths::OMP_MCP], + format: Format::Json, + key: &["mcpServers"], + copyable: false, + why_not: Some(( + code!("adopt.mcp.unverified_format"), + "this client's MCP configuration format is not verified yet, and writing to it could leave the client unable to read its own configuration", + )), + custom_path: None, + }, // **只读**:dsh 的 MCP server 是补丁里的一行 `@deepseek-ai/dsh-mcp-client`, // 扫描把它们列出来(见 tw-scan)。往里写要按 id 插一行插件,那是另一种结构 Target { @@ -245,16 +273,15 @@ impl Target { if self.custom_path.is_some() || self.path(home).exists() { return true; } - let markers: Vec = match crate::clients::adoptable() + match crate::clients::adoptable() .into_iter() .find(|c| c.id == self.client) { - Some(c) => c.marker.to_vec(), - None => crate::locations::layout(self.client) - .map(|l| vec![l.dir]) - .unwrap_or_default(), - }; - markers.iter().any(|m| m.resolve(home).exists()) + Some(c) => c.installed(home), + None => { + crate::locations::layout(self.client).is_some_and(|l| l.dir.resolve(home).exists()) + } + } } fn check(&self) -> Result<(), McpError> { if self.copyable { @@ -730,7 +757,7 @@ mod tests { // 那比不提供这个功能糟得多。 let (_d, home) = home_with(&[(".claude.json", CLAUDE)]); let v = read_server(&target("claude-code").unwrap(), &home, "filesystem").unwrap(); - for c in ["zed", "opencode", "antigravity-cli"] { + for c in ["zed", "opencode", "antigravity-cli", "pi", "omp"] { let t = target(c).unwrap(); let e = plan_copy(&t, &home, "filesystem", &v).unwrap_err(); assert!(matches!(e, McpError::NotCopyable { .. }), "{e}"); diff --git a/src-tauri/crates/tw-adopt/src/paths.rs b/src-tauri/crates/tw-adopt/src/paths.rs index 09c3917..0b17db8 100644 --- a/src-tauri/crates/tw-adopt/src/paths.rs +++ b/src-tauri/crates/tw-adopt/src/paths.rs @@ -217,6 +217,64 @@ pub const DSH_CREDENTIALS: Loc = Loc::DshHome(".credentials.yaml"); /// 第一次启动时把它导入 profile、改名成 `settings.yaml.imported`。 pub const DSH_SETTINGS: Loc = Loc::DshHome("settings.yaml"); +/// DeepSeek Harness 桌面版装在哪,找得到就是那个路径。 +/// +/// 照它的打包配置(electron-builder,`productName: 'DeepSeek Harness'`):macOS 上是从 +/// DMG 拖进「应用程序」的 `DeepSeek Harness.app`,用户自己的 `~/Applications` 也算; +/// Windows 上是只给当前用户装的 NSIS 安装包,默认装在 +/// `%LOCALAPPDATA%\Programs\DeepSeek Harness\`(安装时改过目录的找不到)。Linux 版没有发布。 +/// +/// **它也读 dsh 的家目录**:桌面版用的是 `$DSH_HOME/profiles/desktop`,家目录那一层补丁 +/// 照样压在上面,所以它不是另一个客户端,只是让 dsh 那一行在家目录还没建出来时(装好 +/// 还没打开过)也算装了。系统的「应用程序」只在问的就是这个进程自己的 home 时才看 +/// (同 `opencode::candidates`)。 +pub fn dsh_desktop_app(home: &Path) -> Option { + let mut at = Vec::new(); + if cfg!(target_os = "macos") { + at.push(under(home, "Applications/DeepSeek Harness.app")); + if env_home().as_deref() == Some(home) { + at.push(PathBuf::from("/Applications/DeepSeek Harness.app")); + } + } else if cfg!(windows) { + at.push(under( + home, + "AppData/Local/Programs/DeepSeek Harness/DeepSeek Harness.exe", + )); + } + at.into_iter().find(|p| p.exists()) +} + +/// Pi(`pi`)的配置目录。 +/// +/// `PI_CODING_AGENT_DIR` 能把它整个挪走:挪过的在客户端页指定位置,和 Claude Code 的 +/// `CLAUDE_CONFIG_DIR` 一样 —— 从访达打开的应用本来就看不见 shell 里 export 的变量。 +pub const PI_DIR: Loc = Loc::Home(".pi/agent"); + +/// Pi 的自定义 provider 和模型:`providers..{baseUrl, api, apiKey, models[]}`。 +pub const PI_MODELS: Loc = Loc::Home(".pi/agent/models.json"); + +/// Pi 的全局设置。只看里面的 `httpProxy`(见 [`crate::pi::proxy_notes`])。 +pub const PI_SETTINGS: Loc = Loc::Home(".pi/agent/settings.json"); + +/// Pi 0.99 起的 MCP 配置,顶层 `mcpServers`。 +pub const PI_MCP: Loc = Loc::Home(".pi/agent/mcp.json"); + +/// oh-my-pi(`omp`)的配置目录。它是 Pi 的分支,挪目录的变量是 `PI_CODING_AGENT_DIR`、 +/// `PI_CONFIG_DIR`,具名 profile(`OMP_PROFILE`)另有自己的目录 —— 同 [`PI_DIR`],挪过的 +/// 在客户端页指定位置。 +pub const OMP_DIR: Loc = Loc::Home(".omp/agent"); + +/// oh-my-pi 的自定义 provider 和模型。**两个文件、它只读一个**:`models.yml` 在就读它, +/// 不在才读 `models.yaml`(`ConfigFile#resolveReadPath`),不合并。所以写进在的那一个, +/// 都不在就新建 `models.yml` —— 照它自己的挑法,见 [`first_existing`]。 +pub const OMP_MODELS: &[Loc] = &[ + Loc::Home(".omp/agent/models.yml"), + Loc::Home(".omp/agent/models.yaml"), +]; + +/// oh-my-pi 自己的 MCP 配置,顶层 `mcpServers`。 +pub const OMP_MCP: Loc = Loc::Home(".omp/agent/mcp.json"); + /// 按优先级从高到低排好的几个位置里,第一个存在的是第几个;都不在就是 /// 第一个(该新建的那一个)。 pub fn first_existing(locs: &[Loc], home: &Path) -> usize { diff --git a/src-tauri/crates/tw-adopt/src/pi.rs b/src-tauri/crates/tw-adopt/src/pi.rs new file mode 100644 index 0000000..1e14464 --- /dev/null +++ b/src-tauri/crates/tw-adopt/src/pi.rs @@ -0,0 +1,671 @@ +//! Pi 和它的分支 oh-my-pi(omp):同一种 provider 写法,Pi 写 JSON,omp 写 YAML。 +//! +//! ```yaml +//! providers: +//! thinkwatch: +//! baseUrl: http://127.0.0.1:8788/v1 +//! api: openai-completions +//! apiKey: tw-… +//! auth: apiKey # 只有 omp +//! models: +//! - id: claude-sonnet-5 +//! api: anthropic-messages +//! baseUrl: http://127.0.0.1:8788 +//! - id: deepseek-chat +//! ``` +//! +//! **另起一个 provider,不改内置的那几个。**两边的凭据都按 provider 记:用户 `/login` +//! 登录过的是 `anthropic`、`openai-codex` 这类内置 provider,改写它们的 `baseUrl`,登录拿到 +//! 的令牌就跟着请求发到网关去了。 +//! +//! **模型要写进去。**Pi 不问 provider 有哪些模型,`models.json` 里没列的就选不到。omp 倒是 +//! 能自己问 `/v1/models`(`discovery`),可问到的模型一律按 provider 那一种 API 发 —— 而这里 +//! 每个模型挑它本家的 API(见 [`api_for`]),所以照样写清单。网关上的清单变了,客户端页提示 +//! 更新,和 opencode 一样。 +//! +//! **默认模型不动。**和 opencode 一样只加一个 provider:Pi 的 `settings.json`、omp 的 +//! `config.yml` 一个字节都不改,ThinkWatch 的模型由用户在 `/model` 里选。 + +use std::collections::HashSet; +use std::path::Path; + +use tw_types::{Msg, msg}; + +use crate::clients::{Edit, Gateway, PROVIDER_ID}; +use crate::cloud::Around; +use crate::json::Val; +use crate::plan::lookup; + +/// 两个里的哪一个。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Flavor { + /// `~/.pi/agent/models.json` + Pi, + /// `~/.omp/agent/models.yml` + Omp, +} + +impl Flavor { + pub fn of(client: &str) -> Option { + match client { + "pi" => Some(Flavor::Pi), + "omp" => Some(Flavor::Omp), + _ => None, + } + } +} + +/// 一个模型用哪种 API 发。两边认的名字一样。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Api { + Anthropic, + Responses, + Google, + Chat, +} + +impl Api { + pub fn slug(self) -> &'static str { + match self { + Api::Anthropic => "anthropic-messages", + Api::Responses => "openai-responses", + Api::Google => "google-generative-ai", + Api::Chat => "openai-completions", + } + } + + /// 这一种要的地址写法。Anthropic 的客户端自己拼 `/v1/messages`,地址不带 `/v1`;Google + /// 的那一种在地址后面直接接 `/models/<模型>:streamGenerateContent`,版本要写在地址里; + /// OpenAI 的两种带 `/v1`。 + pub fn base(self, gw_base: &str) -> String { + let b = gw_base.trim_end_matches('/'); + match self { + Api::Anthropic => b.to_string(), + Api::Google => format!("{b}/v1beta"), + Api::Responses | Api::Chat => format!("{b}/v1"), + } + } +} + +/// provider 本身那一种:没写 `api` 的模型照它发。Chat Completions 是几乎每一家兼容服务都 +/// 说的那一种。 +pub const DEFAULT_API: Api = Api::Chat; + +/// 一个模型用哪种 API 发。 +/// +/// 网关四种格式互相转换,选哪一种都发得通;**挑它本家的那一种**,上游是同一种格式时 +/// 请求原样直通,思考过程、缓存断点、推理内容不用转一遍:Claude 走 Anthropic Messages, +/// Gemini 走 Google 的那一种,OpenAI 的 GPT、o 系列和 Codex 走 Responses,其余的(DeepSeek、 +/// Qwen、GLM、Kimi……)走 Chat Completions。认的是模型名,`anthropic/claude-…` 这种带前缀 +/// 的看最后一段。 +pub fn api_for(model: &str) -> Api { + let name = model + .rsplit('/') + .next() + .unwrap_or(model) + .to_ascii_lowercase(); + let o_series = { + let mut cs = name.chars(); + cs.next() == Some('o') && cs.next().is_some_and(|c| c.is_ascii_digit()) + }; + if name.contains("claude") { + Api::Anthropic + } else if name.contains("gemini") { + Api::Google + } else if name.starts_with("gpt-") || name.contains("codex") || o_series { + Api::Responses + } else { + Api::Chat + } +} + +/// 模型清单:一个模型一项,不走 provider 那一种的写上自己的 `api`,地址写法不一样的再写上 +/// 自己的 `baseUrl`(两边都是模型上的覆盖 provider 上的)。 +/// +/// 别的元数据不写:Pi 照默认的上下文和输出长度跑,omp 按模型名从它自带的目录里补。同名的 +/// 只写一次,先后照网关答的。 +pub fn models_val(gw: &Gateway) -> Val { + let mut seen = HashSet::new(); + Val::Arr( + gw.models + .iter() + .filter(|m| seen.insert(m.as_str())) + .map(|m| { + let api = api_for(m); + let mut fields = vec![("id".to_string(), Val::s(m))]; + if api != DEFAULT_API { + fields.push(("api".into(), Val::s(api.slug()))); + if api.base(&gw.base) != DEFAULT_API.base(&gw.base) { + fields.push(("baseUrl".into(), Val::s(api.base(&gw.base)))); + } + } + Val::Obj(fields) + }) + .collect(), + ) +} + +/// 写进 `apiKey` 的那一串:读回来要原样是这把密钥。 +/// +/// Pi 会解释两种写法(`resolve-config-value.ts`):`!` 开头的整串是一条 shell 命令, +/// `$NAME` / `${NAME}` 换成环境变量;`$$` 是一个 `$`,`$!` 是一个 `!`。core 发的密钥是 +/// `tw-` 加 24 个小写字母和数字,两样都碰不上 —— 照样转义一遍,手改过 config.yaml 的密钥 +/// 也不会被当成命令去执行。 +/// +/// omp 不认 `$`;`!` 开头的照样是命令,而且没有转义的写法;整串恰好是一个环境变量的名字时 +/// 换成那个变量的值。`tw-` 开头的两样都不会是(变量名里没有 `-`),原样写。 +pub fn literal_key(key: &str, flavor: Flavor) -> String { + match flavor { + Flavor::Pi => { + let escaped = key.replace('$', "$$"); + match escaped.strip_prefix('!') { + Some(rest) => format!("$!{rest}"), + None => escaped, + } + } + Flavor::Omp => key.to_string(), + } +} + +/// 接管要写的那几项。 +pub fn edits(gw: &Gateway, flavor: Flavor) -> Vec { + let field = |k: &str, value: Val, secret: bool| Edit { + path: vec!["providers".into(), PROVIDER_ID.into(), k.into()], + value, + secret, + }; + let mut v = Vec::new(); + // provider 的显示名。omp 的 provider 没有这个字段 + if flavor == Flavor::Pi { + v.push(field("name", Val::s("ThinkWatch"), false)); + } + v.push(field("baseUrl", Val::s(DEFAULT_API.base(&gw.base)), false)); + v.push(field("api", Val::s(DEFAULT_API.slug()), false)); + if let Some(k) = &gw.key { + v.push(field("apiKey", Val::s(literal_key(k, flavor)), true)); + } + // **omp 必须写明 `auth`。**不写的话,`anthropic-messages` 的自定义模型按 OAuth 那一套 + // 发:请求伪装成 Claude Code(`custom-models.ts` 的 `resolveCustomModelIsOAuth`)。客户端 + // 是谁就说是谁。网关不要求鉴权时写 `none`:有模型却既没有 `apiKey` 又不是 `none`,omp + // 认为整份文件不合法,连用户自己的 provider 一起不用了 + if flavor == Flavor::Omp { + let auth = if gw.key.is_some() { "apiKey" } else { "none" }; + v.push(field("auth", Val::s(auth), false)); + } + v.push(field("models", models_val(gw), false)); + v +} + +/// 配置里此刻写着的模型。没有那一条 provider 就是 `None`。 +pub fn models_in(text: &str, flavor: Flavor) -> Option> { + let v = match flavor { + Flavor::Pi => crate::json::value(text).ok()?, + Flavor::Omp => crate::yamlval::value(text).ok()?, + }; + let provider = lookup(&v, &["providers", PROVIDER_ID])?; + match lookup(&provider, &["models"]) { + Some(Val::Arr(ms)) => Some( + ms.iter() + .filter_map(|m| match lookup(m, &["id"]) { + Some(Val::Str(id)) => Some(id), + _ => None, + }) + .collect(), + ), + _ => Some(Vec::new()), + } +} + +// ---------------------------------------------------------------- 代理 + +/// Pi 访问网关会不会经过代理:会的话,接管之前说一声。 +/// +/// Pi 的出站请求都走 undici 的 `EnvHttpProxyAgent`(`http-dispatcher.ts`),它不认得回环 +/// 地址:`NO_PROXY` 空着就一律走代理,**127.0.0.1 也不例外**。代理在别的机器上的话,它连的 +/// 是它自己的 127.0.0.1,请求一个都到不了网关。Pi 设置里的 `httpProxy` 在 `HTTP_PROXY`、 +/// `HTTPS_PROXY` 没设时补上它们(`main.ts` 的 `applyHttpProxySettings`)。 +/// +/// omp 不用说:它访问回环和内网地址一律不走代理(`proxy.ts` 的 `isLocalOrMetadataHost`)。 +pub fn proxy_notes(client: &str, home: &Path, gw_base: &str, around: &Around) -> Vec { + if client != "pi" { + return Vec::new(); + } + let Some(url) = Target::parse(gw_base) else { + return Vec::new(); + }; + let env = Env { home, around }; + // Pi 的 `httpProxy` 设置:大写的那个变量没设时由它补上(`applyHttpProxySettings`) + let setting = || { + let path = crate::paths::PI_SETTINGS.resolve(home); + let text = std::fs::read_to_string(&path).ok()?; + match crate::json::get(&text, &["httpProxy"]).ok()?? { + Val::Str(s) if !s.trim().is_empty() => Some(path), + _ => None, + } + }; + // undici 的挑法:小写的在就用小写的,设成空串也算设了、就是没有代理 + let proxy = |lower: &'static str, upper: &'static str| match env.get(lower) { + Some(v) => (!v.is_empty()).then_some(Source::Env(lower)), + None => match env.get(upper) { + Some(v) => (!v.is_empty()).then_some(Source::Env(upper)), + None => setting().map(Source::Setting), + }, + }; + // https 的地址没有它自己的代理时,退回 http 的那一个 + let source = if url.https { + proxy("https_proxy", "HTTPS_PROXY").or_else(|| proxy("http_proxy", "HTTP_PROXY")) + } else { + proxy("http_proxy", "HTTP_PROXY") + }; + let no_proxy = env + .get("no_proxy") + .or_else(|| env.get("NO_PROXY")) + .unwrap_or_default(); + let host = url.host.clone(); + match source { + Some(_) if !url.proxied(&no_proxy) => Vec::new(), + Some(Source::Env(name)) => vec![msg!( + "adopt.plan.pi.proxy_env", name = name, host = host => + "{name} is set and NO_PROXY does not list {host}, so Pi sends its requests for the \ + gateway through that proxy. Adding {host} to NO_PROXY sends them directly." + )], + Some(Source::Setting(path)) => vec![msg!( + "adopt.plan.pi.proxy_setting", path = crate::paths::shown_path(&path), host = host => + "{path} sets httpProxy and NO_PROXY does not list {host}, so Pi sends its requests \ + for the gateway through that proxy. Adding {host} to NO_PROXY sends them directly." + )], + None => Vec::new(), + } +} + +/// 代理是从哪儿来的。 +enum Source { + /// 这个环境变量 + Env(&'static str), + /// Pi 设置文件里的 `httpProxy` + Setting(std::path::PathBuf), +} + +/// Pi 起来时手里的环境变量,尽量看准。 +struct Env<'a> { + home: &'a Path, + around: &'a Around, +} + +impl Env<'_> { + /// 用户环境(登录 shell 取的那一份)说了算;那一份没取到的时候,看 shell 配置里 export 的 + fn get(&self, name: &str) -> Option { + if !self.around.env.is_empty() { + return self.around.proxy.get(name).cloned(); + } + crate::detect::shell_exports_valued(self.home, &[name]) + .into_iter() + .next() + .map(|e| e.value.unwrap_or_default()) + } +} + +/// 网关地址里决定走不走代理的那几样。 +struct Target { + https: bool, + host: String, + port: u16, +} + +impl Target { + fn parse(base: &str) -> Option { + let (scheme, rest) = base.split_once("://")?; + let https = scheme.eq_ignore_ascii_case("https"); + let authority = rest.split(['/', '?', '#']).next()?; + let (host, port) = match authority.strip_prefix('[') { + Some(v6) => { + let (h, after) = v6.split_once(']')?; + (h.to_string(), after.strip_prefix(':')) + } + None => match authority.rsplit_once(':') { + Some((h, p)) => (h.to_string(), Some(p)), + None => (authority.to_string(), None), + }, + }; + let port = match port { + Some(p) => p.parse().ok()?, + None if https => 443, + None => 80, + }; + Some(Target { + https, + host: host.to_ascii_lowercase(), + port, + }) + } + + /// 照 undici `EnvHttpProxyAgent#shouldProxy` 判:一项都没有就走代理;`*` 一律不走; + /// 带端口的只管那个端口;主机名一样、或者是它的子域名就不走(`*.x` 只管子域名)。 + fn proxied(&self, no_proxy: &str) -> bool { + for entry in no_proxy + .split([',', ' ', '\t', '\n']) + .filter(|e| !e.is_empty()) + { + let (host, port) = match entry.strip_prefix('[').and_then(|e| e.split_once("]:")) { + Some((h, p)) => (h, p.parse::().ok()), + None => { + let bare = entry + .strip_prefix('[') + .and_then(|e| e.strip_suffix(']')) + .unwrap_or(entry); + match bare.split_once(':') { + Some((h, p)) + if !p.contains(':') && p.bytes().all(|b| b.is_ascii_digit()) => + { + (h, p.parse::().ok()) + } + _ => (bare, None), + } + } + }; + let wildcard = entry.starts_with('*'); + let host = host + .trim_start_matches("*.") + .trim_start_matches('.') + .trim_end_matches('.') + .to_ascii_lowercase(); + if port.is_some_and(|p| p != self.port) { + continue; + } + if host == "*" + || (!wildcard && host == self.host) + || self.host.ends_with(&format!(".{host}")) + { + return false; + } + } + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn gw(models: &[&str]) -> Gateway { + Gateway { + base: "http://127.0.0.1:8788".into(), + key: Some("tw-k".into()), + models: models.iter().map(|m| m.to_string()).collect(), + } + } + + #[test] + fn each_model_goes_out_in_its_own_family_api() { + for (m, api) in [ + ("claude-sonnet-5", Api::Anthropic), + ("anthropic/claude-opus-4.5", Api::Anthropic), + ("gemini-3-pro", Api::Google), + ("gpt-5.5", Api::Responses), + ("openai/gpt-5", Api::Responses), + ("gpt-5.3-codex", Api::Responses), + ("o3-mini", Api::Responses), + ("o4-mini", Api::Responses), + ("deepseek-chat", Api::Chat), + ("qwen3-coder-plus", Api::Chat), + ("glm-5", Api::Chat), + ("kimi-k2", Api::Chat), + // o 开头、后面不是数字的不是 o 系列 + ("ollama-mix", Api::Chat), + ] { + assert_eq!(api_for(m), api, "{m}"); + } + assert_eq!(Api::Anthropic.base("http://h:1/"), "http://h:1"); + assert_eq!(Api::Google.base("http://h:1"), "http://h:1/v1beta"); + assert_eq!(Api::Chat.base("http://h:1"), "http://h:1/v1"); + } + + /// provider 那一种(Chat Completions、带 `/v1`)的模型只写 `id`;别的写上 `api`,地址 + /// 写法不一样的再写上 `baseUrl` + #[test] + fn a_model_carries_only_what_differs_from_the_provider() { + let Val::Arr(ms) = models_val(&gw(&[ + "deepseek-chat", + "claude-sonnet-5", + "gpt-5", + "gemini-3-pro", + "deepseek-chat", + ])) else { + unreachable!() + }; + let s = |k: &str, v: &str| (k.to_string(), Val::s(v)); + assert_eq!( + ms, + vec![ + Val::Obj(vec![s("id", "deepseek-chat")]), + Val::Obj(vec![ + s("id", "claude-sonnet-5"), + s("api", "anthropic-messages"), + s("baseUrl", "http://127.0.0.1:8788"), + ]), + Val::Obj(vec![s("id", "gpt-5"), s("api", "openai-responses")]), + Val::Obj(vec![ + s("id", "gemini-3-pro"), + s("api", "google-generative-ai"), + s("baseUrl", "http://127.0.0.1:8788/v1beta"), + ]), + ], + "同名的只写一次" + ); + } + + /// 照 Pi 自己的规则(`resolve-config-value.ts`)读回来,得是原来那把密钥 + #[test] + fn the_key_written_for_pi_reads_back_as_itself() { + // Pi 的解析:`!` 开头是命令;`$$` → `$`,`$!` → `!`,`$NAME` 换成变量 + fn pi_reads(v: &str) -> Option { + if v.starts_with('!') { + return None; + } + let (mut out, mut it) = (String::new(), v.chars().peekable()); + while let Some(c) = it.next() { + if c != '$' { + out.push(c); + continue; + } + match it.peek() { + Some('$') | Some('!') => out.push(it.next().unwrap()), + Some(n) if n.is_ascii_alphabetic() || *n == '_' || *n == '{' => return None, + _ => out.push('$'), + } + } + Some(out) + } + for k in [ + "tw-abcdefghjkmnpqrstuvwxyz2", + "tw-$HOME", + "!rm -rf ~", + "a$$b", + "$!x", + "tw-${X}", + ] { + let w = literal_key(k, Flavor::Pi); + assert_eq!(pi_reads(&w).as_deref(), Some(k), "{k} 写成了 {w}"); + } + // core 发的密钥原样写 + assert_eq!(literal_key("tw-abc23", Flavor::Pi), "tw-abc23"); + assert_eq!(literal_key("tw-$x", Flavor::Omp), "tw-$x"); + } + + /// omp 一定写明 `auth`,Pi 不写;Pi 的 provider 带显示名 + #[test] + fn omp_always_says_how_it_authenticates() { + let paths = |g: &Gateway, f| { + edits(g, f) + .into_iter() + .map(|e| (e.path.join("."), e.value, e.secret)) + .collect::>() + }; + let omp = paths(&gw(&["claude-sonnet-5"]), Flavor::Omp); + assert!(omp.contains(&("providers.thinkwatch.auth".into(), Val::s("apiKey"), false))); + assert!(omp.contains(&("providers.thinkwatch.apiKey".into(), Val::s("tw-k"), true))); + assert!(!omp.iter().any(|(p, _, _)| p == "providers.thinkwatch.name")); + let keyless = Gateway { + key: None, + ..gw(&[]) + }; + let omp = paths(&keyless, Flavor::Omp); + assert!(omp.contains(&("providers.thinkwatch.auth".into(), Val::s("none"), false))); + assert!(!omp.iter().any(|(_, _, s)| *s)); + + let pi = paths(&gw(&[]), Flavor::Pi); + assert!(pi.contains(&( + "providers.thinkwatch.name".into(), + Val::s("ThinkWatch"), + false + ))); + assert!(pi.contains(&( + "providers.thinkwatch.baseUrl".into(), + Val::s("http://127.0.0.1:8788/v1"), + false + ))); + assert!(pi.contains(&( + "providers.thinkwatch.api".into(), + Val::s("openai-completions"), + false + ))); + assert!(!pi.iter().any(|(p, _, _)| p == "providers.thinkwatch.auth")); + } + + #[test] + fn the_models_are_read_back_from_either_file() { + let json = r#"{"providers": {"thinkwatch": {"models": [{"id": "a"}, {"id": "b", "api": "anthropic-messages"}]}}}"#; + assert_eq!( + models_in(json, Flavor::Pi), + Some(vec!["a".to_string(), "b".to_string()]) + ); + assert_eq!(models_in(r#"{"providers": {}}"#, Flavor::Pi), None); + assert_eq!( + models_in(r#"{"providers": {"thinkwatch": {}}}"#, Flavor::Pi), + Some(Vec::new()) + ); + let yaml = "providers:\n thinkwatch:\n models:\n - id: a\n - id: b\n"; + assert_eq!( + models_in(yaml, Flavor::Omp), + Some(vec!["a".to_string(), "b".to_string()]) + ); + } + + fn around(vars: &[(&str, &str)]) -> Around { + Around { + // 取到过用户环境:只认里面的代理变量 + env: [("HOME".to_string(), "/h".to_string())].into(), + proxy: vars + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(), + ..Around::default() + } + } + + #[test] + fn a_proxy_without_no_proxy_is_said_before_connecting_pi() { + let d = tempfile::tempdir().unwrap(); + let base = "http://127.0.0.1:8788"; + let notes = |vars: &[(&str, &str)]| proxy_notes("pi", d.path(), base, &around(vars)); + let n = notes(&[("HTTP_PROXY", "http://proxy:3128")]); + assert_eq!(n.len(), 1); + assert_eq!(n[0].code, "adopt.plan.pi.proxy_env"); + assert_eq!(n[0].arg("name"), "HTTP_PROXY"); + assert_eq!(n[0].arg("host"), "127.0.0.1"); + // 小写的优先;设成空串就是没有代理 + assert_eq!( + notes(&[("http_proxy", "http://p:1")])[0].arg("name"), + "http_proxy" + ); + assert!(notes(&[("http_proxy", ""), ("HTTP_PROXY", "http://p:1")]).is_empty()); + // NO_PROXY 列了这个地址(带不带端口都行)、或者写了 * 就直连 + for no in [ + "127.0.0.1", + "localhost,127.0.0.1", + "127.0.0.1:8788", + "*", + " * ", + "[::1],127.0.0.1", + ] { + assert!( + notes(&[("HTTP_PROXY", "http://p:1"), ("NO_PROXY", no)]).is_empty(), + "{no}" + ); + } + // 只写 localhost、端口不对、别的主机的不算 + for no in ["localhost", "127.0.0.1:9999", "example.com"] { + assert_eq!( + notes(&[("HTTP_PROXY", "http://p:1"), ("NO_PROXY", no)]).len(), + 1, + "{no}" + ); + } + // http 的地址不看 HTTPS_PROXY;omp 不走代理,不说 + assert!(notes(&[("HTTPS_PROXY", "http://p:1")]).is_empty()); + assert!( + proxy_notes( + "omp", + d.path(), + base, + &around(&[("HTTP_PROXY", "http://p:1")]) + ) + .is_empty() + ); + assert!(notes(&[]).is_empty()); + } + + /// Pi 设置里的 `httpProxy`:两个变量都没设时它生效 + #[test] + fn the_http_proxy_setting_of_pi_counts_too() { + let d = tempfile::tempdir().unwrap(); + let settings = crate::paths::PI_SETTINGS.resolve(d.path()); + std::fs::create_dir_all(settings.parent().unwrap()).unwrap(); + std::fs::write(&settings, r#"{"httpProxy": "http://proxy:3128"}"#).unwrap(); + let n = proxy_notes("pi", d.path(), "http://127.0.0.1:8788", &around(&[])); + assert_eq!(n.len(), 1); + assert_eq!(n[0].code, "adopt.plan.pi.proxy_setting"); + let none = proxy_notes( + "pi", + d.path(), + "http://127.0.0.1:8788", + &around(&[("NO_PROXY", "127.0.0.1")]), + ); + assert!(none.is_empty()); + } + + /// 没取到用户环境(取不到、WSL)时,看 shell 配置里 export 的 + #[test] + #[cfg(not(windows))] + fn without_the_user_environment_the_shell_files_are_read() { + let d = tempfile::tempdir().unwrap(); + std::fs::write(d.path().join(".zshrc"), "export HTTP_PROXY=http://p:1\n").unwrap(); + let n = proxy_notes("pi", d.path(), "http://127.0.0.1:8788", &Around::default()); + assert_eq!(n.len(), 1); + std::fs::write( + d.path().join(".zshrc"), + "export HTTP_PROXY=http://p:1\nexport NO_PROXY=localhost,127.0.0.1\n", + ) + .unwrap(); + assert!( + proxy_notes("pi", d.path(), "http://127.0.0.1:8788", &Around::default()).is_empty() + ); + } + + #[test] + fn the_gateway_address_is_read_like_undici_reads_it() { + let t = Target::parse("http://[::1]:8788/x").unwrap(); + assert_eq!((t.host.as_str(), t.port, t.https), ("::1", 8788, false)); + assert!(!t.proxied("[::1]:8788")); + assert!(!t.proxied("::1")); + let t = Target::parse("https://Core.Example.com").unwrap(); + assert_eq!( + (t.host.as_str(), t.port, t.https), + ("core.example.com", 443, true) + ); + assert!(!t.proxied(".example.com")); + assert!(!t.proxied("*.example.com")); + assert!(t.proxied("other.com")); + assert!(Target::parse("not a url").is_none()); + } +} diff --git a/src-tauri/crates/tw-adopt/src/plan.rs b/src-tauri/crates/tw-adopt/src/plan.rs index 4b89a69..8922d40 100644 --- a/src-tauri/crates/tw-adopt/src/plan.rs +++ b/src-tauri/crates/tw-adopt/src/plan.rs @@ -335,6 +335,8 @@ pub fn plan_adopt( plan.carries_secret |= plan.also.iter().any(|p| p.carries_secret); let (mut notes, shadows) = adopt_notes(c, home, gw); notes.extend(cloud_notes(c, home, ¤t, &on, around)); + // Pi 访问网关也会走代理(NO_PROXY 不列这个地址的话):接管之前说 + notes.extend(crate::pi::proxy_notes(c.id, home, &gw.base, around)); // 原来直连 Bedrock 的:按那一套设置新建上游要填的,和凭据上要说的话 let off = |cloud: Cloud| { on.iter() @@ -450,15 +452,23 @@ pub(crate) fn adopt_notes(c: &Client, home: &Path, gw: &Gateway) -> (Vec, V let shadows = c.live_shadows(home); if !shadows.is_empty() { - notes.push(msg!( - "adopt.plan.shadowed", - paths = shadows - .iter() - .map(|p| p.display().to_string()) - .collect::>() - .join(", ") - => "{paths} was found, and it takes precedence over what was written here, so a setting of the same name there wins." - )); + let paths = shadows + .iter() + .map(|p| p.display().to_string()) + .collect::>() + .join(", "); + // oh-my-pi 只读在的第一个文件:盖住的是这里写的全部,不只是同名的几项 + notes.push(if c.reads_first_only() { + msg!( + "adopt.plan.shadowed.whole_file", paths = paths, client = c.name + => "{paths} was found; {client} reads only that file, so nothing written here is used." + ) + } else { + msg!( + "adopt.plan.shadowed", paths = paths + => "{paths} was found, and it takes precedence over what was written here, so a setting of the same name there wins." + ) + }); } (notes, shadows) } diff --git a/src-tauri/crates/tw-adopt/src/yaml.rs b/src-tauri/crates/tw-adopt/src/yaml.rs index 6e1b794..390b8dd 100644 --- a/src-tauri/crates/tw-adopt/src/yaml.rs +++ b/src-tauri/crates/tw-adopt/src/yaml.rs @@ -1,6 +1,6 @@ //! YAML 的外科手术,靠 [`tw_yaml`] 的 span-to-patch。 //! -//! 覆盖面比 JSON 和 TOML 窄,而且是**故意**的:这里只改映射里的标量键。 +//! 覆盖面比 JSON 和 TOML 窄,而且是**故意**的:这里只改映射里的键,不在列表里增删项。 //! 顶层的扁平键(Aider 的 `.aider.conf.yml`,`openai-api-base` 这类)走最 //! 早那条路:原地替换或者追加一行。嵌套的键(dsh 凭据文件里的 //! `refs.THINKWATCH_API_KEY`)交给 [`tw_yaml::insert`] / [`tw_yaml::remove_key`] @@ -11,6 +11,11 @@ //! [`crate::clients`] 里它为什么只给指引);按 `id` 定位列表行的是 //! [`crate::rows`],那是 dsh 补丁文件专用的一种形状。 //! +//! **整个换掉一个键的值**是另一回事:oh-my-pi 的 `providers.thinkwatch.models` 是我们 +//! 自己那个 provider 底下的一张清单,写的时候整张换、还原时整张拿走,不在用户的列表里插 +//! 一项。这一步交给 [`tw_yaml::put`] —— core 改 config.yaml 用的就是它:只动那个键那一段, +//! 写完重新解析,核对目标读回来就是给的那一段、目标之外一个节点都没变。 +//! //! **宁可少支持一个客户端,也不要写一个我们自己没把握的结构性改写。** //! 这个文件会去改用户的配置,出错的代价不是「功能没做」而是「他的东西 //! 被弄坏了」。 @@ -46,10 +51,110 @@ fn scalar(path: &[&str], v: &Val) -> Result { } } +/// 一个容器值写成零缩进的块式 YAML,交给 [`tw_yaml::put`]。空的写成 `[]` / `{}`。 +/// +/// ```yaml +/// - id: claude-sonnet-5 +/// api: anthropic-messages +/// - id: deepseek-chat +/// ``` +fn block(v: &Val) -> String { + fn inline(v: &Val) -> String { + match v { + Val::Str(s) => text(s), + Val::Num(n) => n.clone(), + Val::Bool(b) => b.to_string(), + Val::Null => "~".into(), + Val::Arr(_) => "[]".into(), + Val::Obj(_) => "{}".into(), + } + } + // 一个非空的容器:一行一项 + fn lines(v: &Val) -> Vec { + let nested = |head: String, x: &Val| -> Vec { + match x { + Val::Arr(es) if !es.is_empty() => nest(head, x), + Val::Obj(ms) if !ms.is_empty() => nest(head, x), + _ => vec![format!("{head} {}", inline(x))], + } + }; + match v { + Val::Obj(ms) => ms + .iter() + .flat_map(|(k, x)| nested(format!("{}:", text(k)), x)) + .collect(), + Val::Arr(es) => es + .iter() + .flat_map(|x| match x { + // `- id: a` 接着 ` api: b`:第一行跟在短横后面,其余缩进两格 + Val::Obj(ms) if !ms.is_empty() => dash(lines(x)), + Val::Arr(es) if !es.is_empty() => dash(lines(x)), + _ => vec![format!("- {}", inline(x))], + }) + .collect(), + _ => vec![inline(v)], + } + } + // `键:` 下一行起缩进两格 + fn nest(head: String, x: &Val) -> Vec { + std::iter::once(head) + .chain(lines(x).into_iter().map(|l| format!(" {l}"))) + .collect() + } + fn dash(ls: Vec) -> Vec { + ls.into_iter() + .enumerate() + .map(|(i, l)| { + if i == 0 { + format!("- {l}") + } else { + format!(" {l}") + } + }) + .collect() + } + match v { + Val::Arr(es) if es.is_empty() => "[]".into(), + Val::Obj(ms) if ms.is_empty() => "{}".into(), + _ => lines(v).join("\n"), + } +} + +/// 一个字符串在块里的写法:一眼看得出是安全的纯量就裸写(`anthropic-messages`、 +/// `http://127.0.0.1:8788`),否则加单引号,带控制字符的用双引号转义。 +/// +/// **宁可多加引号。**裸写错了的代价是一份意思变了的文件:`yes`、`on` 会被读成布尔, +/// `0x10` 读成数,`1:30` 读成六十进制。所以只放行字母开头、只有字母数字和 `-_./:@+` +/// 的,看起来像布尔、空值、数的一律加引号;冒号后面跟空格的,这个字符集里本来就没有。 +fn text(s: &str) -> String { + const LOOKALIKE: &[&str] = &[ + "true", "false", "yes", "no", "on", "off", "y", "n", "null", "nil", + ]; + let plain = s.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) + && s.bytes() + .all(|b| b.is_ascii_alphanumeric() || b"-_./:@+".contains(&b)) + && !s.ends_with(':') + && !LOOKALIKE.contains(&s.to_ascii_lowercase().as_str()) + && s.parse::().is_err(); + if plain { + s.to_string() + } else if s.chars().any(char::is_control) { + // JSON 的双引号字符串也是 YAML 的双引号字符串 + serde_json::to_string(s).unwrap_or_else(|_| format!("'{}'", s.replace('\'', "''"))) + } else { + format!("'{}'", s.replace('\'', "''")) + } +} + pub fn get(text: &str, path: &[&str]) -> Result, YErr> { match tw_yaml::find(text, &steps(path)) { Ok(f) => Ok(Some(f.value)), Err(PatchError::NotFound { .. }) => Ok(None), + // 一个容器(清单、映射):给它的单行写法。接管记原值时要它 —— 原来就有一张同名 + // 清单的话,那是要还回去的东西 + Err(PatchError::NotScalar(_)) => { + Ok(crate::plan::lookup(&crate::yamlval::value(text)?, path).map(|v| v.to_line())) + } Err(e) => Err(e.into()), } } @@ -57,7 +162,34 @@ pub fn get(text: &str, path: &[&str]) -> Result, YErr> { /// 写一个字段。已经有就原地替换,没有就**追加** —— 顶层的键追加到文件末尾, /// 嵌套的键追加到父映射末尾。不去猜该插在哪一行之间,那样只会打乱用户自己 /// 排的顺序。 +/// +/// 值是一个容器的,整个换掉那个键的值(见开头那段),渲染见 [`block`]。 pub fn set(text: &str, path: &[&str], value: &Val) -> Result { + // 一个节点都没有的文件(空的、只有注释):tw_yaml 找不到可以往里插的根。嵌套的键整段 + // 从顶层写出来,接在原文后面 —— oh-my-pi 的 `models.yml` 新建时第一项就是 + // `providers.thinkwatch.baseUrl` + if path.len() > 1 && crate::yamlval::tree(text)?.is_none() { + let nested = path + .iter() + .rev() + .fold(value.clone(), |v, k| Val::Obj(vec![(k.to_string(), v)])); + let mut out = text.to_string(); + if !out.is_empty() && !out.ends_with('\n') { + out.push('\n'); + } + out.push_str(&block(&nested)); + out.push('\n'); + return Ok(out); + } + if let Val::Arr(_) | Val::Obj(_) = value { + let rendered = block(value); + let put = if rendered.contains('\n') { + tw_yaml::Put::Block(&rendered) + } else { + tw_yaml::Put::Inline(&rendered) + }; + return Ok(tw_yaml::put(text, &steps(path), put)?); + } let v = scalar(path, value)?; if path.len() > 1 { return Ok(tw_yaml::insert(text, &steps(path), &v)?); @@ -399,13 +531,94 @@ mod tests { assert_eq!(back, "version: 1\n"); } + /// oh-my-pi 的模型清单:整张写在我们自己那个键底下,还原时整张拿走,别的一个字节不动 #[test] - fn a_container_is_refused_out_loud_instead_of_half_done() { - // 这一层只写标量。**说出来**,而不是写出一个我们没把握的结构。 - assert!(matches!( - set("a: 1\n", &["a"], &Val::Obj(Vec::new())), - Err(YErr::NotScalar(_)) - )); + fn a_list_is_written_as_a_whole_value_and_taken_away_with_its_key() { + let src = "# 我的 provider\nproviders:\n mine:\n apiKey: '!op read x' # 注释\n"; + let models = Val::Arr(vec![ + Val::Obj(vec![ + ("id".into(), Val::s("claude-sonnet-5")), + ("api".into(), Val::s("anthropic-messages")), + ("baseUrl".into(), Val::s("http://127.0.0.1:8788")), + ]), + Val::Obj(vec![("id".into(), Val::s("deepseek-chat"))]), + ]); + let path = ["providers", "thinkwatch", "models"]; + let out = set(src, &path, &models).unwrap(); + assert!(out.starts_with(src), "{out}"); + assert!( + out.ends_with( + " thinkwatch:\n models:\n - id: claude-sonnet-5\n api: anthropic-messages\n baseUrl: http://127.0.0.1:8788\n - id: deepseek-chat\n" + ), + "{out}" + ); + assert_eq!( + crate::plan::lookup(&crate::yamlval::value(&out).unwrap(), &path), + Some(models.clone()) + ); + // 已经有了就整张换掉;读的时候给单行的写法 + let again = set(&out, &path, &Val::Arr(vec![])).unwrap(); + assert!(again.contains("models: []"), "{again}"); + assert_eq!(get(&out, &path).unwrap(), Some(models.to_line())); + // 拿走它,空了的 `thinkwatch:` 一起走 + assert_eq!(remove(&out, &path).unwrap(), src); + } + + /// 空文件、只有注释的文件里写一个嵌套的键:整段从顶层写出来,删掉又回到原样 + #[test] + fn a_nested_key_goes_into_an_empty_file_too() { + for src in ["", "# 只有注释\n", "# 没有换行"] { + let out = set( + src, + &["providers", "thinkwatch", "baseUrl"], + &Val::s("http://h:1/v1"), + ) + .unwrap(); + assert_eq!( + get(&out, &["providers", "thinkwatch", "baseUrl"]) + .unwrap() + .as_deref(), + Some("http://h:1/v1"), + "{out}" + ); + let out = set( + &out, + &["providers", "thinkwatch", "api"], + &Val::s("openai-completions"), + ) + .unwrap(); + let back = remove(&out, &["providers", "thinkwatch", "api"]).unwrap(); + let back = remove(&back, &["providers", "thinkwatch", "baseUrl"]).unwrap(); + assert_eq!(crate::yamlval::tree(&back).unwrap(), None, "{back}"); + assert!(back.starts_with(src.trim_end()), "{back}"); + } + } + + /// 裸写会变意思的字符串一律加引号 + #[test] + fn a_string_that_would_read_as_something_else_is_quoted() { + for (s, want) in [ + ("anthropic-messages", "anthropic-messages"), + ("http://127.0.0.1:8788/v1", "http://127.0.0.1:8788/v1"), + ("openai/gpt-5", "openai/gpt-5"), + ("yes", "'yes'"), + ("On", "'On'"), + ("null", "'null'"), + ("1:30", "'1:30'"), + ("0x10", "'0x10'"), + ("nan", "'nan'"), + ("inf", "'inf'"), + ("-dash", "'-dash'"), + ("a: b", "'a: b'"), + ("it's", "'it''s'"), + ("", "''"), + ("a\nb", "\"a\\nb\""), + ] { + assert_eq!(text(s), want, "{s:?}"); + // 读回来还是这个字符串 + let doc = format!("k: {}\n", text(s)); + assert_eq!(get(&doc, &["k"]).unwrap().as_deref(), Some(s), "{doc}"); + } } #[test] diff --git a/src-tauri/crates/tw-adopt/tests/roundtrip.rs b/src-tauri/crates/tw-adopt/tests/roundtrip.rs index a48ddd1..ce84208 100644 --- a/src-tauri/crates/tw-adopt/tests/roundtrip.rs +++ b/src-tauri/crates/tw-adopt/tests/roundtrip.rs @@ -1334,6 +1334,250 @@ fn adopting_opencode_with_no_models_says_so_before_confirming() { assert!(!with.notes.iter().any(|n| n.code == "adopt.plan.no_models")); } +// ---- Pi 和 oh-my-pi:自己的一个 provider,模型清单里每个模型挑本家的 API ---------- + +/// 用过一阵的 Pi `models.json`:注释(Pi 读之前会去掉)、一个本机的 ollama +const PI: &str = r#"{ + // 本机的 ollama + "providers": { + "ollama": { + "baseUrl": "http://localhost:11434/v1", + "api": "openai-completions", + "apiKey": "ollama", + "models": [{ "id": "qwen2.5-coder:7b" }] + } + } +} +"#; + +#[test] +fn adopting_pi_adds_a_provider_with_each_models_own_api() { + use tw_adopt::json::Val; + let b = bed("pi", PI); + let c = client("pi"); + let p = plan_adopt(&c, &b.home, &gw(), &Around::default()).unwrap(); + // 默认模型不动:要说清去哪儿选;只查证过字段名也要说 + let codes: Vec<&str> = p.notes.iter().map(|n| n.code.as_str()).collect(); + assert!(codes.contains(&"adopt.cost.pi.default_model"), "{codes:?}"); + assert!(codes.contains(&"adopt.plan.fields_only"), "{codes:?}"); + apply(&c, &p, &b.backups).unwrap(); + + let path = c.config_path(&b.home); + assert!( + path.ends_with(".pi/agent/models.json"), + "{}", + path.display() + ); + let after = read(&path); + let at = |k: &[&str]| { + let mut p = vec!["providers", "thinkwatch"]; + p.extend_from_slice(k); + get(&after, &p) + }; + assert_eq!(at(&["baseUrl"]), Some(Val::s("http://127.0.0.1:8080/v1"))); + assert_eq!(at(&["api"]), Some(Val::s("openai-completions"))); + assert_eq!(at(&["apiKey"]), Some(Val::s("tw-用户的专属密钥"))); + // Claude 走 Anthropic Messages、地址不带 /v1;GPT 走 Responses + let models = tw_adopt::json::value( + r#"[ + {"id": "claude-sonnet", "api": "anthropic-messages", "baseUrl": "http://127.0.0.1:8080"}, + {"id": "gpt-5", "api": "openai-responses"} + ]"#, + ) + .unwrap(); + assert_eq!(at(&["models"]), Some(models), "{after}"); + // 别的一个字都不动:注释、ollama + assert!(after.contains("// 本机的 ollama"), "{after}"); + assert!(after.contains("\"qwen2.5-coder:7b\""), "{after}"); + + let d = tw_adopt::detect::detect_one(&c, &b.home); + assert_eq!(d.endpoint.as_deref(), Some("http://127.0.0.1:8080/v1")); + assert_eq!( + d.models, + Some(vec!["claude-sonnet".to_string(), "gpt-5".to_string()]) + ); + assert!(d.installed); + + let r = plan_restore(&c, &b.home).unwrap(); + apply_restore(&c, &r, &b.backups).unwrap(); + assert_eq!(read(&path), PI); +} + +/// 网关上的模型变了,重新写一遍清单:还原仍然回到最初的样子 +#[test] +fn rewriting_the_pi_model_list_keeps_the_first_record() { + let b = bed("pi", PI); + let c = client("pi"); + for models in [vec!["a"], vec!["a", "claude-b"]] { + let g = Gateway { + models: models.into_iter().map(str::to_string).collect(), + ..gw() + }; + let p = plan_adopt(&c, &b.home, &g, &Around::default()).unwrap(); + apply(&c, &p, &b.backups).unwrap(); + } + let d = tw_adopt::detect::detect_one(&c, &b.home); + assert_eq!( + d.models, + Some(vec!["a".to_string(), "claude-b".to_string()]) + ); + let r = plan_restore(&c, &b.home).unwrap(); + apply_restore(&c, &r, &b.backups).unwrap(); + assert_eq!(read(&c.config_path(&b.home)), PI); +} + +/// Pi 访问 127.0.0.1 也走代理:`NO_PROXY` 不列它的话,接管之前说 +#[test] +fn a_proxy_in_the_way_of_pi_is_said_before_connecting() { + let b = bed("pi", PI); + let c = client("pi"); + let around = |no_proxy: Option<&str>| Around { + env: [("HOME".to_string(), b.home.display().to_string())].into(), + proxy: std::iter::once(("HTTPS_PROXY", "http://proxy:3128")) + .chain(std::iter::once(("HTTP_PROXY", "http://proxy:3128"))) + .chain(no_proxy.map(|v| ("NO_PROXY", v))) + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(), + ..Default::default() + }; + let p = plan_adopt(&c, &b.home, &gw(), &around(None)).unwrap(); + let n = p + .notes + .iter() + .find(|n| n.code == "adopt.plan.pi.proxy_env") + .expect("没说代理的事"); + assert_eq!(n.args["name"], "HTTP_PROXY"); + assert_eq!(n.args["host"], "127.0.0.1"); + let p = plan_adopt(&c, &b.home, &gw(), &around(Some("localhost,127.0.0.1"))).unwrap(); + assert!( + !p.notes + .iter() + .any(|n| n.code.starts_with("adopt.plan.pi.proxy")) + ); + // 别的客户端不说这件事 + let o = bed("omp", ""); + let p = plan_adopt(&client("omp"), &o.home, &gw(), &around(None)).unwrap(); + assert!( + !p.notes + .iter() + .any(|n| n.code.starts_with("adopt.plan.pi.proxy")) + ); +} + +/// 用过一阵的 oh-my-pi `models.yml`:注释、一个按 discovery 找模型的 ollama +const OMP: &str = "# 我的 omp 模型\nproviders:\n ollama:\n baseUrl: http://127.0.0.1:11434\n api: openai-completions\n auth: none # 本机的不要密钥\n discovery:\n type: ollama\n"; + +#[test] +fn adopting_omp_says_it_authenticates_with_a_key() { + let b = bed("omp", OMP); + let c = client("omp"); + let p = plan_adopt(&c, &b.home, &gw(), &Around::default()).unwrap(); + apply(&c, &p, &b.backups).unwrap(); + + let path = c.config_path(&b.home); + assert!( + path.ends_with(".omp/agent/models.yml"), + "{}", + path.display() + ); + let after = read(&path); + let at = |k: &str| { + tw_adopt::yaml::get(&after, &["providers", "thinkwatch", k]) + .unwrap() + .unwrap_or_default() + }; + // **不写 `auth: apiKey` 的话,Anthropic 的那几个模型会伪装成 Claude Code 发出去** + assert_eq!(at("auth"), "apiKey"); + assert_eq!(at("baseUrl"), "http://127.0.0.1:8080/v1"); + assert_eq!(at("api"), "openai-completions"); + assert_eq!(at("apiKey"), "tw-用户的专属密钥"); + // YAML 读回来标量都是字符串,和同一张 JSON 写法的清单比 + let models = tw_adopt::json::value( + r#"[ + {"id": "claude-sonnet", "api": "anthropic-messages", "baseUrl": "http://127.0.0.1:8080"}, + {"id": "gpt-5", "api": "openai-responses"} + ]"#, + ) + .unwrap(); + assert_eq!(at("models"), models.to_line(), "{after}"); + assert!( + after.contains(" models:\n - id: claude-sonnet\n api: anthropic-messages\n"), + "{after}" + ); + // 用户的东西一个字节不动:原文整个还在(哨兵注释放在最前面) + assert!(after.contains(OMP), "{after}"); + assert!(after.starts_with("# === ThinkWatch: begin ==="), "{after}"); + + let d = tw_adopt::detect::detect_one(&c, &b.home); + assert_eq!(d.endpoint.as_deref(), Some("http://127.0.0.1:8080/v1")); + assert_eq!( + d.models, + Some(vec!["claude-sonnet".to_string(), "gpt-5".to_string()]) + ); + + // 再接管一次是空操作 + let again = plan_adopt(&c, &b.home, &gw(), &Around::default()).unwrap(); + assert!(again.is_noop(), "{}", again.after); + + let r = plan_restore(&c, &b.home).unwrap(); + apply_restore(&c, &r, &b.backups).unwrap(); + assert_eq!(read(&path), OMP); +} + +/// 没有 `models.yml` 只有 `models.yaml`:oh-my-pi 读的是后者,就写进后者,不另建一份把它盖住 +#[test] +fn omp_is_written_into_the_one_file_it_reads() { + let b = bed("omp", ""); + let c = client("omp"); + let yml = c.config_path(&b.home); + let yaml = yml.with_file_name("models.yaml"); + std::fs::write(&yaml, OMP).unwrap(); + assert_eq!(c.config_path(&b.home), yaml); + let p = plan_adopt(&c, &b.home, &gw(), &Around::default()).unwrap(); + apply(&c, &p, &b.backups).unwrap(); + assert!(!yml.exists()); + assert!(read(&yaml).contains("thinkwatch")); + + // 之后才建了 `models.yml`:它一在,写进 `models.yaml` 的就整个没人读了 + std::fs::write(&yml, "providers: {}\n").unwrap(); + assert_eq!(c.config_path(&b.home), yaml, "换了文件就还原不了"); + let f = tw_adopt::detect::diagnose(&c, &b.home, None, &Around::default()) + .into_iter() + .find(|f| f.title.code == "adopt.diag.shadowed") + .expect("没报被盖住"); + assert_eq!(f.level, tw_adopt::detect::Level::Blocking); + assert_eq!(f.detail.code, "adopt.diag.shadowed.whole_file"); + let p = plan_adopt(&c, &b.home, &gw(), &Around::default()).unwrap(); + assert!( + p.notes + .iter() + .any(|n| n.code == "adopt.plan.shadowed.whole_file"), + "{:?}", + p.notes + ); + + let r = plan_restore(&c, &b.home).unwrap(); + apply_restore(&c, &r, &b.backups).unwrap(); + assert_eq!(read(&yaml), OMP); +} + +/// 两边都还没有配置文件:新建的,还原时删掉 +#[test] +fn a_pi_or_omp_file_created_here_is_removed_again() { + for id in ["pi", "omp"] { + let b = bed(id, ""); + let c = client(id); + let path = c.config_path(&b.home); + let p = plan_adopt(&c, &b.home, &gw(), &Around::default()).unwrap(); + assert!(p.before.is_none(), "{id}"); + apply(&c, &p, &b.backups).unwrap(); + assert!(read(&path).contains("tw-用户的专属密钥"), "{id}"); + let r = plan_restore(&c, &b.home).unwrap(); + apply_restore(&c, &r, &b.backups).unwrap(); + assert!(!path.exists(), "{id}:{}", read(&path)); + } +} + // ---- Claude Code 直连云服务商:`CLAUDE_CODE_USE_BEDROCK` 这一类开关 ------------ /// `/setup-bedrock` 写出来的那种 settings.json:开关、区域、API key、钉好的模型都在 `env` 里 diff --git a/src-tauri/crates/tw-scan/src/report.rs b/src-tauri/crates/tw-scan/src/report.rs index 8a5bf9e..b6facc4 100644 --- a/src-tauri/crates/tw-scan/src/report.rs +++ b/src-tauri/crates/tw-scan/src/report.rs @@ -248,6 +248,18 @@ fn mcp_from(src: &Source, v: &Val) -> Vec { out.push(server(src, name.clone(), cfg)); } } + // oh-my-pi 的 `mcp.json` 顶层还有两张名单:`disabledServers` 里的不管条目自己怎么写都 + // 关着,`enabledServers` 里的哪怕条目写了 `enabled: false` 也开着;两张都有的,关着赢 + if src.client == "omp" { + let (off, on) = (strings(v, "disabledServers"), strings(v, "enabledServers")); + for m in &mut out { + if off.contains(&m.name) { + m.enabled = false; + } else if on.contains(&m.name) { + m.enabled = true; + } + } + } out.sort_by(|a, b| (&a.name, &a.client).cmp(&(&b.name, &b.client))); out } diff --git a/src-tauri/crates/tw-scan/src/sources.rs b/src-tauri/crates/tw-scan/src/sources.rs index 829b5b3..c40c279 100644 --- a/src-tauri/crates/tw-scan/src/sources.rs +++ b/src-tauri/crates/tw-scan/src/sources.rs @@ -104,6 +104,13 @@ pub struct Source { /// 我们自己留下的文件名里都有这一段。监听要跳过它们(见 [`crate::watch`])。 pub const SIDECAR_MARK: &str = ".thinkwatch.json"; +/// `~/.agents/skills`(和项目里的 `.agents/skills`)算在谁名下:**谁都不是**。 +/// +/// 它是 Agent Skills 约定的共用目录,Pi、oh-my-pi、DeepSeek Harness、Antigravity CLI、 +/// Copilot、Kimi、Goose、Crush、Kilo、Cline、MiMo 都读它。算在其中一家名下,清单上就像是 +/// 那一家独有的,删掉那一家的人会以为它也跟着没了。界面按这个标识显示成「共用目录」。 +pub const SHARED_SKILLS: &str = "agents"; + fn f(client: &'static str, kind: Kind, path: PathBuf) -> Source { Source { client, @@ -164,21 +171,40 @@ fn placed(moved: &BTreeMap, client: &str, role: Role, default: P .unwrap_or(default) } -/// 扫描要看的几个目录:Claude Code、Codex、agy 的(换过位置的按换过的),和 dsh 的。 -/// [`candidates`] 和 [`roots`] 从同一处取,两边说的才是同一批目录 -fn scan_dirs(home: &Path, moved: &BTreeMap) -> [PathBuf; 4] { - [ - placed(moved, "claude-code", Role::Scan, under(home, ".claude")), - placed(moved, "codex", Role::Scan, under(home, ".codex")), - // agy 的全局配置都在 `~/.gemini/config/` 下:hooks、MCP、subagent - placed( +/// 扫描要看的几个目录(换过位置的按换过的)。[`candidates`] 和 [`roots`] 从同一处取, +/// 两边说的才是同一批目录 +struct Dirs { + claude: PathBuf, + codex: PathBuf, + /// agy 的全局配置都在 `~/.gemini/config/` 下:hooks、MCP、subagent + agy: PathBuf, + dsh: PathBuf, + /// Pi 的 `~/.pi/agent`:skills、prompts(斜杠命令)、AGENTS.md + pi: PathBuf, + /// oh-my-pi 的 `~/.omp/agent`:skills、commands、prompts、AGENTS.md + omp: PathBuf, + /// 各家共用的那一个,见 [`SHARED_SKILLS`] + shared_skills: PathBuf, +} + +fn scan_dirs(home: &Path, moved: &BTreeMap) -> Dirs { + let at = |client: &str, default: tw_adopt::paths::Loc| { + placed(moved, client, Role::Scan, default.resolve(home)) + }; + Dirs { + claude: placed(moved, "claude-code", Role::Scan, under(home, ".claude")), + codex: placed(moved, "codex", Role::Scan, under(home, ".codex")), + agy: placed( moved, "antigravity-cli", Role::Scan, under(home, ".gemini/config"), ), - tw_adopt::paths::DSH_DIR.resolve(home), - ] + dsh: tw_adopt::paths::DSH_DIR.resolve(home), + pi: at("pi", tw_adopt::paths::PI_DIR), + omp: at("omp", tw_adopt::paths::OMP_DIR), + shared_skills: under(home, ".agents/skills"), + } } /// 用户级的那一小撮。**数量有限**,所以可以无条件全看一遍。 @@ -197,7 +223,15 @@ pub fn user_level(home: &Path, moved: &BTreeMap) -> Vec /// 哪儿冒出来,见 [`roots`]。 pub fn candidates(home: &Path, moved: &BTreeMap) -> Vec { let at = |client: &str, role: Role, default: PathBuf| placed(moved, client, role, default); - let [claude, codex, agy, dsh] = scan_dirs(home, moved); + let Dirs { + claude, + codex, + agy, + dsh, + pi, + omp, + shared_skills, + } = scan_dirs(home, moved); let mut v = vec![ // 危险度第一:hooks 直接执行 shell f("claude-code", Kind::Hooks, claude.join("settings.json")), @@ -249,10 +283,35 @@ pub fn candidates(home: &Path, moved: &BTreeMap) -> Vec tw_adopt::paths::ZED_SETTINGS.resolve(home), ), ), + // Pi 0.99 起的 MCP,和 oh-my-pi 自己的那两份(`.mcp.json` 是它为兼容也读的) + f( + "pi", + Kind::Mcp, + at("pi", Role::Mcp, tw_adopt::paths::PI_MCP.resolve(home)), + ), + f( + "omp", + Kind::Mcp, + at("omp", Role::Mcp, tw_adopt::paths::OMP_MCP.resolve(home)), + ), + f("omp", Kind::Mcp, omp.join(".mcp.json")), // 指令类 f("claude-code", Kind::Instructions, claude.join("CLAUDE.md")), f("codex", Kind::Instructions, codex.join("AGENTS.md")), ]; + // Pi 和 oh-my-pi 每次都读进上下文的:用户级的指令、换掉或补在系统提示词后面的那一份 + for name in [ + "AGENTS.md", + "AGENTS.override.md", + "CLAUDE.md", + "SYSTEM.md", + "APPEND_SYSTEM.md", + ] { + v.push(f("pi", Kind::Instructions, pi.join(name))); + } + for name in ["AGENTS.md", "SYSTEM.md", "RULES.md"] { + v.push(f("omp", Kind::Instructions, omp.join(name))); + } // opencode 两个文件都读、逐层合并,哪个里都可能有 MCP;指定了就只看那一个 match moved.get("opencode").and_then(|p| p.mcp.clone()) { Some(p) => v.push(f("opencode", Kind::Mcp, p)), @@ -275,12 +334,28 @@ pub fn candidates(home: &Path, moved: &BTreeMap) -> Vec for p in profile_patches(&dsh) { v.push(f("dsh", Kind::Mcp, p)); } - // 它的 skills 目录:自己的一个,加上各家共用的 `~/.agents/skills` - for p in skills_in(&dsh.join("skills")) + for p in skills_in(&dsh.join("skills")) { + v.push(f("dsh", Kind::Skill, p)); + } + // 各家共用的那一个,不算在哪一家名下 + for p in skills_in(&shared_skills) { + v.push(f(SHARED_SKILLS, Kind::Skill, p)); + } + for p in skills_in(&pi.join("skills")) { + v.push(f("pi", Kind::Skill, p)); + } + for p in skills_in(&omp.join("skills")) { + v.push(f("omp", Kind::Skill, p)); + } + // Pi 的 prompt 模板就是它的斜杠命令;oh-my-pi 两样都有 + for p in md_in(&pi.join("prompts")) { + v.push(f("pi", Kind::Command, p)); + } + for p in md_in(&omp.join("commands")) .into_iter() - .chain(skills_in(&under(home, ".agents/skills"))) + .chain(md_in(&omp.join("prompts"))) { - v.push(f("dsh", Kind::Skill, p)); + v.push(f("omp", Kind::Command, p)); } for p in md_in(&claude.join("commands")) { v.push(f("claude-code", Kind::Command, p)); @@ -309,17 +384,22 @@ pub struct Root { } pub fn roots(home: &Path, moved: &BTreeMap) -> Vec { - let [claude, _, agy, dsh] = scan_dirs(home, moved); + let d = scan_dirs(home, moved); let root = |dir: PathBuf, nested: bool| Root { dir, nested }; vec![ - root(claude.join("skills"), true), - root(claude.join("commands"), false), - root(claude.join("agents"), false), - root(dsh.join("profiles"), true), - root(dsh.join("skills"), true), - root(under(home, ".agents/skills"), true), - root(agy.join("skills"), true), - root(agy.join("agents"), false), + root(d.claude.join("skills"), true), + root(d.claude.join("commands"), false), + root(d.claude.join("agents"), false), + root(d.dsh.join("profiles"), true), + root(d.dsh.join("skills"), true), + root(d.shared_skills, true), + root(d.agy.join("skills"), true), + root(d.agy.join("agents"), false), + root(d.pi.join("skills"), true), + root(d.pi.join("prompts"), false), + root(d.omp.join("skills"), true), + root(d.omp.join("commands"), false), + root(d.omp.join("prompts"), false), ] } @@ -354,6 +434,8 @@ pub fn in_project(dir: &Path) -> Vec { Kind::Mcp, under(dir, ".agents/mcp_config.json"), ), + f("pi", Kind::Mcp, under(dir, ".pi/mcp.json")), + f("omp", Kind::Mcp, under(dir, ".omp/mcp.json")), ]; for p in md_in(&under(dir, ".claude/commands")) { v.push(f("claude-code", Kind::Command, p)); @@ -364,15 +446,21 @@ pub fn in_project(dir: &Path) -> Vec { for p in skills_in(&under(dir, ".claude/skills")) { v.push(f("claude-code", Kind::Skill, p)); } + // 项目里共用的 `.agents/skills`:和用户级的那一个一样,谁都不是 for p in skills_in(&under(dir, ".agents/skills")) { - v.push(f("antigravity-cli", Kind::Skill, p)); + v.push(f(SHARED_SKILLS, Kind::Skill, p)); } for p in md_in(&under(dir, ".agents/agents")) { v.push(f("antigravity-cli", Kind::Agent, p)); } - // 项目里共用的 `.agents/skills` 上面已经算在 agy 名下,dsh 这里只加它自己的 - for p in skills_in(&under(dir, ".dsh/skills")) { - v.push(f("dsh", Kind::Skill, p)); + for (client, sub) in [ + ("dsh", ".dsh/skills"), + ("pi", ".pi/skills"), + ("omp", ".omp/skills"), + ] { + for p in skills_in(&under(dir, sub)) { + v.push(f(client, Kind::Skill, p)); + } } v.retain(|s| s.path.exists()); for s in &mut v { @@ -429,6 +517,11 @@ mod tests { home.join(".agents/skills/e/SKILL.md"), home.join(".gemini/config/skills/f/SKILL.md"), home.join(".gemini/config/agents/g.md"), + home.join(".pi/agent/skills/h/SKILL.md"), + home.join(".pi/agent/prompts/i.md"), + home.join(".omp/agent/skills/j/SKILL.md"), + home.join(".omp/agent/commands/k.md"), + home.join(".omp/agent/prompts/l.md"), ] { touch(&p); } @@ -439,7 +532,7 @@ mod tests { matches!(s.kind, Kind::Skill | Kind::Command | Kind::Agent) || s.path == profile }) .collect(); - assert_eq!(found.len(), 8, "{found:?}"); + assert_eq!(found.len(), 13, "{found:?}"); for s in found { let dir = s.path.parent().unwrap(); assert!( @@ -509,7 +602,15 @@ mod tests { touch(&p.path().join(".agents/agents/b.md")); let got = in_project(p.path()); assert_eq!(got.len(), 4, "{got:?}"); - assert!(got.iter().all(|s| s.client == "antigravity-cli")); + // `.agents/skills` 是各家共用的,不算在 agy 名下 + for s in &got { + let want = if s.kind == Kind::Skill { + SHARED_SKILLS + } else { + "antigravity-cli" + }; + assert_eq!(s.client, want, "{s:?}"); + } } #[test] @@ -564,7 +665,8 @@ mod tests { ("dsh", Kind::Mcp), ("dsh", Kind::Mcp), ("dsh", Kind::Skill), - ("dsh", Kind::Skill), + // `~/.agents/skills` 不是 dsh 独有的 + (SHARED_SKILLS, Kind::Skill), ] ); let p = tempfile::tempdir().unwrap(); @@ -573,6 +675,80 @@ mod tests { assert_eq!(in_project(p.path()).len(), 2); } + /// Pi 和 oh-my-pi:MCP、skills、斜杠命令、每次读进上下文的指令文件 + #[test] + fn pi_and_omp_are_scanned_at_both_levels() { + let d = tempfile::tempdir().unwrap(); + let home = d.path(); + for p in [ + ".pi/agent/mcp.json", + ".pi/agent/AGENTS.md", + ".pi/agent/SYSTEM.md", + ".pi/agent/skills/审查/SKILL.md", + ".pi/agent/prompts/fix.md", + ".omp/agent/mcp.json", + ".omp/agent/.mcp.json", + ".omp/agent/RULES.md", + ".omp/agent/skills/a/SKILL.md", + ".omp/agent/commands/b.md", + // 不是它读的:models.json 不在扫描里 + ".pi/agent/models.json", + ] { + touch(&home.join(p)); + } + let got: Vec<_> = user_level(home, &BTreeMap::new()) + .into_iter() + .map(|s| (s.client, s.kind)) + .collect(); + let count = |client: &str, kind: Kind| got.iter().filter(|g| **g == (client, kind)).count(); + assert_eq!(count("pi", Kind::Mcp), 1, "{got:?}"); + assert_eq!(count("pi", Kind::Instructions), 2, "{got:?}"); + assert_eq!(count("pi", Kind::Skill), 1, "{got:?}"); + assert_eq!(count("pi", Kind::Command), 1, "{got:?}"); + assert_eq!(count("omp", Kind::Mcp), 2, "{got:?}"); + assert_eq!(count("omp", Kind::Instructions), 1, "{got:?}"); + assert_eq!(count("omp", Kind::Skill), 1, "{got:?}"); + assert_eq!(count("omp", Kind::Command), 1, "{got:?}"); + assert_eq!(got.len(), 10, "{got:?}"); + + let p = tempfile::tempdir().unwrap(); + touch(&p.path().join(".pi/mcp.json")); + touch(&p.path().join(".pi/skills/x/SKILL.md")); + touch(&p.path().join(".omp/mcp.json")); + touch(&p.path().join(".omp/skills/y/SKILL.md")); + let got: Vec<_> = in_project(p.path()) + .into_iter() + .map(|s| (s.client, s.kind)) + .collect(); + assert_eq!( + got, + [ + ("pi", Kind::Mcp), + ("omp", Kind::Mcp), + ("pi", Kind::Skill), + ("omp", Kind::Skill), + ] + ); + } + + /// `~/.agents/skills` 谁都读:清单上不算在哪一家名下 + #[test] + fn the_shared_skills_folder_belongs_to_no_single_client() { + let d = tempfile::tempdir().unwrap(); + touch(&d.path().join(".agents/skills/共用/SKILL.md")); + let got = user_level(d.path(), &BTreeMap::new()); + assert_eq!(got.len(), 1, "{got:?}"); + assert_eq!(got[0].client, SHARED_SKILLS); + assert_eq!(got[0].kind, Kind::Skill); + // 这个标识不是哪个客户端的 id + assert!( + !tw_adopt::clients::adoptable() + .iter() + .any(|c| c.id == SHARED_SKILLS) + && tw_adopt::mcp::target(SHARED_SKILLS).is_err() + ); + } + /// 换过位置的客户端:扫描看的目录和 MCP 那一份都按换过的找,默认位置的不再看 #[test] fn a_moved_client_is_scanned_where_it_was_moved() { diff --git a/src-tauri/crates/tw-scan/tests/scan.rs b/src-tauri/crates/tw-scan/tests/scan.rs index 3a22aa9..b53c476 100644 --- a/src-tauri/crates/tw-scan/tests/scan.rs +++ b/src-tauri/crates/tw-scan/tests/scan.rs @@ -813,3 +813,87 @@ fn opencode_jsonc_with_comments_and_trailing_commas_is_scanned() { assert_eq!(ms[0].command, "npx"); assert_eq!(ms[0].args, ["-y", "server-fs"]); } + +/// Pi 和 oh-my-pi 的 `mcp.json`:顶层 `mcpServers`,和别家列在同一张矩阵上 +#[test] +fn pi_and_omp_servers_show_up_next_to_everyone_elses() { + let b = bed(); + write( + &tw_adopt::paths::PI_MCP.resolve(&b.home), + r#"{ + "mcpServers": { + "filesystem": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "."] }, + "docs": { "url": "https://example.com/mcp", "headers": { "Authorization": "Bearer ${DOCS_TOKEN}" }, "enabled": false } + } +}"#, + ); + let r = run(&b.home); + let pi: Vec<_> = r.mcp.iter().filter(|m| m.client == "pi").collect(); + assert_eq!(pi.len(), 2, "{:?}", r.mcp); + let docs = pi.iter().find(|m| m.name == "docs").unwrap(); + assert!(docs.is_third_party()); + assert!(!docs.enabled, "enabled: false 是关着的"); + // 关着的远端 server 不报(它此刻跑不起来),开着的那个和 Claude Code 的同名、配置不同 + assert!( + !r.findings + .iter() + .any(|f| f.client == "pi" && f.rule == "remote-mcp") + ); + assert!(tw_scan::report::conflicting(&r.mcp).contains(&"filesystem".to_string())); +} + +/// oh-my-pi 顶层的两张名单:`disabledServers` 关着赢,`enabledServers` 打开写了关的 +#[test] +fn omp_server_lists_decide_which_servers_are_on() { + let b = bed(); + write( + &tw_adopt::paths::OMP_MCP.resolve(&b.home), + r#"{ + "$schema": "https://raw.githubusercontent.com/can1357/oh-my-pi/main/packages/coding-agent/src/config/mcp-schema.json", + "mcpServers": { + "far": { "type": "http", "url": "https://mcp.example.com/mcp" }, + "both": { "type": "stdio", "command": "x", "enabled": false }, + "back": { "type": "stdio", "command": "y", "enabled": false } + }, + "disabledServers": ["far", "both"], + "enabledServers": ["both", "back"] +}"#, + ); + let r = run(&b.home); + let on = |name: &str| { + r.mcp + .iter() + .find(|m| m.client == "omp" && m.name == name) + .unwrap_or_else(|| panic!("{name} 不在:{:?}", r.mcp)) + .enabled + }; + assert!(!on("far")); + assert!(!on("both"), "两张名单都有的,关着赢"); + assert!(on("back")); + // 关着的远端 server 不报 + assert!( + !r.findings + .iter() + .any(|f| f.client == "omp" && f.rule == "remote-mcp") + ); +} + +/// `~/.agents/skills` 里的 skill:清单上算在共用目录名下,规则照扫 +#[test] +fn a_skill_in_the_shared_folder_is_listed_and_scanned_as_shared() { + let b = bed(); + write( + &b.home.join(".agents/skills/全都要/SKILL.md"), + "---\nname: 全都要\nallowed-tools: [\"*\"]\n---\n\n随便用。\n", + ); + let r = run(&b.home); + let s = r.skills.iter().find(|s| s.name == "全都要").unwrap(); + assert_eq!(s.client, sources::SHARED_SKILLS); + assert!( + r.findings + .iter() + .any(|f| f.client == sources::SHARED_SKILLS && f.rule == "over-broad-tools"), + "{:?}", + r.findings + ); +} diff --git a/src-tauri/msg-codes.txt b/src-tauri/msg-codes.txt index a18ab7a..bc23344 100644 --- a/src-tauri/msg-codes.txt +++ b/src-tauri/msg-codes.txt @@ -17,11 +17,14 @@ adopt.cost.codex.reopen_terminal adopt.cost.codex.resume_through_gateway adopt.cost.codex.sessions_after_restore adopt.cost.codex.sessions_split +adopt.cost.dsh.account_direct adopt.cost.dsh.every_entry adopt.cost.dsh.models adopt.cost.dsh.settings_page adopt.cost.dsh.web_search +adopt.cost.omp.default_model adopt.cost.opencode.restart +adopt.cost.pi.default_model adopt.cost.zed.key_store adopt.diag.adopt_again adopt.diag.claude_desktop.managed @@ -74,6 +77,7 @@ adopt.diag.restart adopt.diag.shadowed adopt.diag.shadowed.fields adopt.diag.shadowed.no_fields +adopt.diag.shadowed.whole_file adopt.diag.shell_export adopt.diag.shell_export.harmless adopt.diag.shell_export.overrides @@ -153,8 +157,11 @@ adopt.plan.managed adopt.plan.no_models adopt.plan.no_record adopt.plan.parse_failed +adopt.plan.pi.proxy_env +adopt.plan.pi.proxy_setting adopt.plan.read_failed adopt.plan.shadowed +adopt.plan.shadowed.whole_file adopt.plan.stale adopt.restore.cloud_env adopt.restore.cloud_on diff --git a/src-tauri/src/clients/mod.rs b/src-tauri/src/clients/mod.rs index 2b5ca43..9be337c 100644 --- a/src-tauri/src/clients/mod.rs +++ b/src-tauri/src/clients/mod.rs @@ -94,7 +94,7 @@ async fn gateway(state: &AppState) -> Out { /// 这把密钥在网关上能用哪些模型:网关的 `GET /v1/models` 对它答的。 /// /// **问的是网关,不是 core 的控制面** —— 同一把密钥在网关上被允许用哪些模型,只有 -/// 网关按它的 `allow` 答得准。opencode 要把这份清单写进配置(它不自己去问)。 +/// 网关按它的 `allow` 答得准。opencode、Pi、oh-my-pi 要把这份清单写进配置(它们不自己去问)。 async fn models_of(base: &str, key: &str) -> Result, Msg> { fetch_models(base, key, false).await } @@ -231,6 +231,8 @@ impl Place { Place::Here => { let mut a = Around::here(user_env::last().await, true); a.core_env = core_env; + // Pi 访问网关会不会经过代理,看这几个(带给 core 的那一份里没有它们) + a.proxy = user_env::proxy().await.into_iter().collect(); a } Place::Wsl(w) => Around::wsl(w, core_env), @@ -275,7 +277,7 @@ pub(crate) async fn blocking(f: impl FnOnce() -> T + Send + ' #[tauri::command] pub async fn list_clients(state: tauri::State<'_, AppState>) -> Out { let gw = gateway(&state).await?; - // 把模型写进配置的那几个(opencode),问一下网关此刻给它那把密钥答什么:拿来 + // 把模型写进配置的那几个(opencode、Pi、oh-my-pi),问一下网关此刻给它那把密钥答什么:拿来 // 判断配置里的清单过没过期,也给手动配置那一栏照着写。还没有它自己的密钥就按 // 接管时会用的那把问。**问不到就不说** —— 网关停着的时候客户端页照样要打得开 let mut models = BTreeMap::new(); diff --git a/src-tauri/src/clients/ops.rs b/src-tauri/src/clients/ops.rs index c31f2d9..6e4ee1c 100644 --- a/src-tauri/src/clients/ops.rs +++ b/src-tauri/src/clients/ops.rs @@ -74,7 +74,7 @@ pub fn unknown(id: &str) -> Msg { /// 客户端页的那一张表。 /// -/// `models` 是要把模型写进配置的客户端(opencode)此刻从网关问到的模型清单,按 +/// `models` 是要把模型写进配置的客户端(opencode、Pi、oh-my-pi)此刻从网关问到的模型清单,按 /// 客户端 id:拿它和配置里写着的比,不一样就提示更新;手动配置的那几项也照它写。 /// 问不到的不在里面。 pub fn list( @@ -1228,6 +1228,58 @@ pub(crate) mod tests { ); } + /// Pi 和 oh-my-pi 也把模型写进配置:清单跟网关对不上了一样提示更新;diff 里看不到密钥 + #[test] + fn a_stale_pi_or_omp_model_list_is_flagged_too() { + for id in ["pi", "omp"] { + let home = tempfile::tempdir().unwrap(); + let keys = vec![key(id, "tw-p", Some(id), false)]; + let now = |ms: &[&str]| { + BTreeMap::from([( + id.to_string(), + ms.iter().map(|m| m.to_string()).collect::>(), + )]) + }; + let stale = |models: &BTreeMap>| { + list(home.path(), &gw(keys.clone()), models) + .clients + .into_iter() + .find(|c| c.id == id) + .unwrap() + .models_stale + }; + let target = tw_adopt::clients::Gateway::keyed( + "http://127.0.0.1:8788", + "tw-p", + vec!["claude-a".into(), "b".into()], + ); + let v = plan_adopt( + home.path(), + id, + &gw(keys.clone()), + target.models.clone(), + &Around::default(), + ) + .unwrap(); + assert!(!v.after.contains("tw-p"), "{id}:{}", v.after); + assert!( + v.fields.iter().any(|f| f.secret && f.value.is_none()), + "{id}" + ); + adopt( + home.path(), + &backups(&home), + id, + &target, + None, + &Around::default(), + ) + .unwrap(); + assert!(!stale(&now(&["b", "claude-a"])), "{id}"); + assert!(stale(&now(&["claude-a"])), "{id}"); + } + } + /// 别处的 home(WSL 里的、测试的临时目录)不带用户指定的配置文件:那份设置只管 /// 这台电脑上的 #[test] diff --git a/src-tauri/src/supervisor/user_env.rs b/src-tauri/src/supervisor/user_env.rs index 35350a5..23e7943 100644 --- a/src-tauri/src/supervisor/user_env.rs +++ b/src-tauri/src/supervisor/user_env.rs @@ -25,13 +25,48 @@ /// 取到的这一份记下来([`last`]):接管 Claude Code 时要看用户环境里打开没打开 /// `CLAUDE_CODE_USE_BEDROCK` 这类开关,不为这个再跑一次登录 shell。 pub async fn load() -> Vec<(String, String)> { - let env: Vec<(String, String)> = read().await.into_iter().filter(|(k, _)| keep(k)).collect(); + let (env, proxy): (Vars, Vars) = read().await.into_iter().partition(|(k, _)| keep(k)); *LAST.lock().unwrap_or_else(|e| e.into_inner()) = Some(env.clone()); + // 滤掉的那些里,代理变量另外记着:不带给 core,可接管 Pi 时要看它访问网关会不会经过 + // 代理(`tw_adopt::pi::proxy_notes`) + *PROXY.lock().unwrap_or_else(|e| e.into_inner()) = + Some(proxy.into_iter().filter(|(k, _)| is_proxy(k)).collect()); env } +/// 一份环境变量:名字和值 +type Vars = Vec<(String, String)>; + /// 最近一次取到的那一份([`load`])。 -static LAST: std::sync::Mutex>> = std::sync::Mutex::new(None); +static LAST: std::sync::Mutex> = std::sync::Mutex::new(None); + +/// 同一次取到的代理变量([`proxy`])。 +static PROXY: std::sync::Mutex> = std::sync::Mutex::new(None); + +/// 用户环境里的代理变量(`http_proxy`、`HTTPS_PROXY`、`NO_PROXY` 这些,大小写照原样),和 +/// [`last`] 出自同一次读取;还没取过就现取一次。 +pub async fn proxy() -> Vec<(String, String)> { + let known = PROXY.lock().unwrap_or_else(|e| e.into_inner()).clone(); + match known { + Some(vars) => vars, + None => { + load().await; + PROXY + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone() + .unwrap_or_default() + } + } +} + +/// 是不是一个代理变量。比较不分大小写,同 [`keep`] +fn is_proxy(name: &str) -> bool { + matches!( + name.to_ascii_uppercase().as_str(), + "HTTP_PROXY" | "HTTPS_PROXY" | "ALL_PROXY" | "NO_PROXY" + ) +} /// 最近一次取到的用户环境,还没取过(本机的 core 还没起过、连着远程)就现取一次。 /// @@ -346,6 +381,17 @@ mod tests { } } + /// 代理变量不带给 core,另外记着给接管 Pi 时用 + #[test] + fn proxy_variables_are_set_aside_rather_than_carried_over() { + for name in ["http_proxy", "HTTPS_PROXY", "no_proxy", "All_Proxy"] { + assert!(is_proxy(name) && !keep(name), "{name}"); + } + for name in ["PATH", "HOME", "THINKWATCH_HOME"] { + assert!(!is_proxy(name), "{name}"); + } + } + /// 真跑一次登录 shell。**记号之间真的拿到了东西**,而不是超时或者被 /// shell 的启动输出搅乱之后返回空的 —— 那样 `load` 也不会报错,只是悄悄没用。 #[cfg(unix)] diff --git a/src-tauri/src/wire.rs b/src-tauri/src/wire.rs index 2874d37..fad602f 100644 --- a/src-tauri/src/wire.rs +++ b/src-tauri/src/wire.rs @@ -88,7 +88,7 @@ pub struct DetectedClient { /// 接管之后会失去或改变的功能 pub costs: Vec, /// 配置里写着的模型清单和网关此刻对它那把密钥答的不一样了(上游或路由变了)。 - /// 只有把模型写进配置的客户端(opencode)会是 `true`;点一下走一遍接管的 + /// 只有把模型写进配置的客户端(opencode、Pi、oh-my-pi)会是 `true`;点一下走一遍接管的 /// 「差异 → 确认 → 写入」重写它,**不在后台悄悄改** pub models_stale: bool, /// 配置位置能换(行菜单里给「更改路径…」,见 [`ClientLocations`])。Claude Desktop、 diff --git a/src/generated/lite-api.ts b/src/generated/lite-api.ts index a716363..775d5b1 100644 --- a/src/generated/lite-api.ts +++ b/src/generated/lite-api.ts @@ -81,7 +81,7 @@ verified: Verification, costs: Array, /** * 配置里写着的模型清单和网关此刻对它那把密钥答的不一样了(上游或路由变了)。 - * 只有把模型写进配置的客户端(opencode)会是 `true`;点一下走一遍接管的 + * 只有把模型写进配置的客户端(opencode、Pi、oh-my-pi)会是 `true`;点一下走一遍接管的 * 「差异 → 确认 → 写入」重写它,**不在后台悄悄改** */ models_stale: boolean, diff --git a/src/i18n/core.zh.json b/src/i18n/core.zh.json index cf20b31..7f81951 100644 --- a/src/i18n/core.zh.json +++ b/src/i18n/core.zh.json @@ -645,9 +645,12 @@ "adopt.cost.aider.lookup_order": "Aider 依次读取主目录、Git 项目根目录和当前目录中的配置,后读取的会覆盖先读取的,接管只修改主目录中的配置。", "adopt.cost.aider.restart": "修改后需要重新启动 Aider。", "adopt.cost.dsh.every_entry": "网页版、桌面版和 headless 模式都会经过网关,无需重新启动。", + "adopt.cost.dsh.account_direct": "桌面版中通过 DeepSeek 账号登录使用的模型直接连接 api.deepseek.com,不经过网关。", "adopt.cost.dsh.web_search": "联网搜索仍直接连接 DeepSeek,不经过网关。", "adopt.cost.dsh.settings_page": "接管期间,无法在 DeepSeek Harness 的设置页中修改 llm-deepseek 这一项配置。", "adopt.cost.dsh.models": "DeepSeek Harness 请求的模型为 deepseek-flash、deepseek-v4-pro 和 deepseek-v4-flash,需要在路由中将这些模型名交给 DeepSeek 上游,或改写为其他上游的模型。", + "adopt.cost.pi.default_model": "默认模型保持不变;ThinkWatch 的模型在 /model 中选择,按 Ctrl+S 可设为默认模型。", + "adopt.cost.omp.default_model": "默认模型保持不变;ThinkWatch 的模型在 /model 中选择,指定为 default 角色后即为默认模型。", "adopt.cost.claude_desktop.restart": "修改后需要完全退出 Claude Desktop 再重新打开。", "adopt.cost.claude_desktop.sign_in": "打开时如出现登录页,在登录页选择通过网关继续,只需一次。", "adopt.cost.claude_desktop.separate_history": "该模式下的对话与原有对话分开保存。", @@ -673,6 +676,7 @@ "adopt.diag.shadowed": "{path} 的优先级高于接管写入的配置", "adopt.diag.shadowed.no_fields": "该文件存在,但不包含相关字段。", "adopt.diag.shadowed.fields": "该文件中包含 {fields},会覆盖接管写入的设置。", + "adopt.diag.shadowed.whole_file": "该文件存在时,{client} 只读取该文件,接管写入的配置不会被读取。", "adopt.diag.look_at_fields": "检查 {path} 中的相关字段", "adopt.diag.project_config": "当前项目中有同名配置文件", "adopt.diag.project_config.detail": "{path} 会覆盖用户级配置。", @@ -769,6 +773,9 @@ "adopt.plan.fields_only": "字段名已查证,尚未在本机实际运行验证。收到第一个请求之前,请勿视为已生效。", "adopt.plan.no_models": "网关目前没有该密钥可用的模型,{client} 中不会出现 ThinkWatch 的模型。有可用模型后,请在客户端页更新模型列表。", "adopt.plan.shadowed": "检测到 {paths},其优先级高于接管写入的配置,其中的同名设置会覆盖接管的设置。", + "adopt.plan.shadowed.whole_file": "检测到 {paths},{client} 只读取该文件,接管写入的配置不会生效。", + "adopt.plan.pi.proxy_env": "已设置 {name},而 NO_PROXY 中没有 {host},Pi 访问网关的请求会经过该代理。将 {host} 加入 NO_PROXY 后改为直接连接。", + "adopt.plan.pi.proxy_setting": "{path} 中设置了 httpProxy,而 NO_PROXY 中没有 {host},Pi 访问网关的请求会经过该代理。将 {host} 加入 NO_PROXY 后改为直接连接。", "adopt.plan.claude_desktop.no_claude_model": "网关列出的模型中没有名称类似 Claude 的模型,而 Claude Desktop 只接受这类名称。将写入 {model} 作为其模型;需要在路由中添加一条规则,把 {model} 改写为上游实际使用的模型,例如:{rule}", "adopt.plan.cloud_off.settings": "{path} 打开了 {name},{client} 因此直连 {cloud:cloud}。接管期间关闭这个开关,还原时重新打开。", "adopt.plan.cloud_off.shell": "{path} 第 {line} 行导出了 {name},{client} 因此直连 {cloud:cloud}。接管期间关闭这个开关,还原时重新打开。", diff --git a/src/logos.test.ts b/src/logos.test.ts index bd4ac57..063c48b 100644 --- a/src/logos.test.ts +++ b/src/logos.test.ts @@ -35,6 +35,11 @@ describe("上游和客户端的标志", () => { expect(clientGlyph("gemini-cli")).toBeNull(); expect(clientGlyph("zed-editor")).toBe("zed"); expect(clientGlyph("aider")).toBeNull(); + expect(clientGlyph("pi")).toBe("pi"); + expect(clientGlyph("Pi")).toBe("pi"); + // 分支不借用 Pi 的标志 + expect(clientGlyph("omp")).toBeNull(); + expect(clientGlyph("oh-my-pi")).toBeNull(); }); it("图形是单色的:数据里没有颜色", () => { diff --git a/src/mcp/McpPage.i18n.tsx b/src/mcp/McpPage.i18n.tsx index 6136a19..617c3ae 100644 --- a/src/mcp/McpPage.i18n.tsx +++ b/src/mcp/McpPage.i18n.tsx @@ -76,6 +76,8 @@ export const mcpText = messages( skills: "技能", skillsNote: "仅列出,不支持跨客户端复制:技能的跨客户端格式尚无通行标准。", client: "客户端", + // ~/.agents/skills:各客户端共用的技能目录,不属于某一个客户端 + sharedSkills: "共用目录", event: "事件", command: "命令", name: "名称", @@ -167,6 +169,7 @@ export const mcpText = messages( skills: "Skills", skillsNote: "Listed only. Copying between clients is not supported: there is no common format for skills across clients yet.", client: "Client", + sharedSkills: "Shared folder", event: "Event", command: "Command", name: "Name", diff --git a/src/mcp/McpPage.tsx b/src/mcp/McpPage.tsx index 74b09cf..bd6764a 100644 --- a/src/mcp/McpPage.tsx +++ b/src/mcp/McpPage.tsx @@ -26,6 +26,9 @@ import { LEVELS, levelTone } from "./parts"; type McpTab = "servers" | "extensions" | "findings"; +/** 扫描报告里共用技能目录(`~/.agents/skills`、项目里的 `.agents/skills`)的归属标识 */ +const SHARED_SKILLS = "agents"; + /** 一次扫描:各客户端的配置面,和哪些客户端能写。`at` 是扫完的时刻 */ interface Scan { report: ScanReport; @@ -123,7 +126,8 @@ export default function McpPage({ const report = data.data?.report; const targets = data.data?.targets ?? []; - const nameOf = (c: string) => targets.find((x) => x.client === c)?.name ?? c; + // 技能所在的 `~/.agents/skills` 是各家共用的,不算在哪一个客户端名下(tw-scan 的 `SHARED_SKILLS`) + const nameOf = (c: string) => (c === SHARED_SKILLS ? t.sharedSkills : (targets.find((x) => x.client === c)?.name ?? c)); const movable = (c: string) => targets.find((x) => x.client === c)?.movable ?? false; const servers = report ? new Set(report.mcp.map((m) => m.name)).size : null; diff --git a/src/ui/logo-data.ts b/src/ui/logo-data.ts index 9c66737..21dd6dd 100644 --- a/src/ui/logo-data.ts +++ b/src/ui/logo-data.ts @@ -199,6 +199,11 @@ export const GLYPHS = { "M23.78 5.004h-.228a2.187 2.187 0 00-2.18 2.196v4.912c0 .98-.804 1.775-1.76 1.775a1.818 1.818 0 01-1.472-.773L13.168 5.95a2.197 2.197 0 00-1.81-.95c-1.134 0-2.154.972-2.154 2.173v4.94c0 .98-.797 1.775-1.76 1.775-.57 0-1.136-.289-1.472-.773L.408 5.098C.282 4.918 0 5.007 0 5.228v4.284c0 .216.066.426.188.604l5.475 7.889c.324.466.8.812 1.351.938 1.377.316 2.645-.754 2.645-2.117V11.89c0-.98.787-1.775 1.76-1.775h.002c.586 0 1.135.288 1.472.773l4.972 7.163a2.15 2.15 0 001.81.95c1.158 0 2.151-.973 2.151-2.173v-4.939c0-.98.787-1.775 1.76-1.775h.194c.122 0 .22-.1.22-.222V5.225a.221.221 0 00-.22-.222z", ], }, + pi: { + title: "Pi", + src: "lobe", + paths: ["M1 1h16.5v11H12v5.5H6.5V23H1V1zm5.5 5.5V12H12V6.5H6.5z", "M17.5 12H23v11h-5.5V12z"], + }, zed: { title: "Zed Industries", src: "simple-icons", diff --git a/src/ui/logos.tsx b/src/ui/logos.tsx index b97a97d..cc5eeba 100644 --- a/src/ui/logos.tsx +++ b/src/ui/logos.tsx @@ -175,6 +175,8 @@ const CLIENTS: Record = { windsurf: "windsurf", dsh: "deepseek", deepseekharness: "deepseek", + // Lobe Icons 的 Pi Agent(pi.dev)。它的分支 oh-my-pi 没有标志,画首字母方块 + pi: "pi", }; /** 客户端是哪个。`claude-code`、`Claude Code`、`claude_code` 都认。认不出来返回 `null` */ From abd557692d4914c6fe72482e50f41b254d3d4e98 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:36:24 +0800 Subject: [PATCH 2/2] docs(adopt): say why the Pi and oh-my-pi MCP files stay read-only Co-Authored-By: Claude Opus 5.5 --- src-tauri/crates/tw-adopt/src/mcp.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src-tauri/crates/tw-adopt/src/mcp.rs b/src-tauri/crates/tw-adopt/src/mcp.rs index 32a3ff2..d681b84 100644 --- a/src-tauri/crates/tw-adopt/src/mcp.rs +++ b/src-tauri/crates/tw-adopt/src/mcp.rs @@ -103,8 +103,8 @@ impl Target { /// /// 判据是**我们有没有实际见过那个形状**。`mcpServers` 那三家和 Codex 的 /// `mcp_servers` 在本机都有真实样本,字段名一致(`command` / `args` / -/// `env`);opencode、Zed 和 Antigravity CLI 的 MCP 段本机没有样本,**照着猜写进去, -/// 用户拿到的是一份客户端读不懂的配置** —— 那比不提供这个功能糟得多。 +/// `env`);opencode、Zed、Antigravity CLI、Pi 和 oh-my-pi 的 MCP 段本机没有样本, +/// **照着猜写进去,用户拿到的是一份客户端读不懂的配置** —— 那比不提供这个功能糟得多。 pub fn targets() -> Vec { vec![ Target { @@ -189,8 +189,9 @@ pub fn targets() -> Vec { )), custom_path: None, }, - // Pi 0.99 起的 MCP 和 oh-my-pi 自己的那一份:顶层 `mcpServers`,写法照 Claude 的 - // (两边的文档都说可以原样抄过来)。只从源码和文档里查证过、本机没有样本 —— 先只读 + // Pi 0.99 起的 MCP 和 oh-my-pi 自己的那一份:顶层 `mcpServers`,写法和 Claude 的一样 + // (Pi 的文档说 Claude 那几家的条目原样抄过来就行)。只从文档和源码里查证过、本机没有 + // 样本 —— 先只读 Target { client: "pi", name: "Pi",