From 3d7cf6aa32e13970187ae957ec14044c32e543d2 Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 24 Oct 2026 10:54:35 +0800 Subject: [PATCH] refactor(omp): port #8308's text-level models.yml editor and share Pi plumbing Replace the passthrough/splice YAML layer with a single omp_config.rs: #6747's verified agent-dir resolution (profiles, PI_CONFIG_DIR, XDG data dir) plus #8308's block-level editor that rewrites only the one provider entry, refuses anchors/tags/flow style instead of re-emitting them, and rolls the file back byte for byte when the database commit fails. - Validate only the node being written; model-level `api` also accepts omp's runner APIs. - Drop ProviderMeta.ompPassthroughYaml, validation.rs and omp_state; the current-state command lives in commands/omp.rs and reuses PiCurrentState. - Usage scripts go through the Pi command with an `app` argument. - MCP reuses Pi's transport helpers; mcp.json writes are revision checked. - Skills follow Pi: no DB column, state derived from /skills, and uninstall keeps an unverifiable same-name directory in either app. - Move enabled_omp to a new v22 -> v23 migration (v22 shipped in 4.0.19) and add it to mcp_servers only; revert the .gitignore additions. Co-authored-by: allenxu09 --- .gitignore | 2 - src-tauri/src/app_config.rs | 9 +- src-tauri/src/commands/omp.rs | 73 +- src-tauri/src/commands/pi.rs | 6 +- src-tauri/src/database/dao/skills.rs | 49 +- src-tauri/src/database/mod.rs | 2 +- src-tauri/src/database/schema.rs | 22 +- src-tauri/src/database/tests.rs | 66 +- src-tauri/src/lib.rs | 1 - src-tauri/src/mcp/omp.rs | 23 +- src-tauri/src/mcp/pi.rs | 5 +- src-tauri/src/omp_config.rs | 1111 +++++++++++++ src-tauri/src/omp_config/mod.rs | 1997 ----------------------- src-tauri/src/omp_config/validation.rs | 608 ------- src-tauri/src/provider.rs | 8 - src-tauri/src/services/mod.rs | 1 - src-tauri/src/services/omp_state.rs | 100 -- src-tauri/src/services/provider/live.rs | 2 +- src-tauri/src/services/provider/mod.rs | 13 +- src-tauri/src/services/provider/omp.rs | 526 +++--- src-tauri/src/services/provider/pi.rs | 21 +- src-tauri/src/services/skill.rs | 556 ++----- src-tauri/tests/provider_service.rs | 62 +- 23 files changed, 1680 insertions(+), 3583 deletions(-) create mode 100644 src-tauri/src/omp_config.rs delete mode 100644 src-tauri/src/omp_config/mod.rs delete mode 100644 src-tauri/src/omp_config/validation.rs delete mode 100644 src-tauri/src/services/omp_state.rs diff --git a/.gitignore b/.gitignore index 72d55fd7f..525d4a7b4 100644 --- a/.gitignore +++ b/.gitignore @@ -29,5 +29,3 @@ copilot-api .history CODEBUDDY.md mainWindow.js -openspec -.*/ \ No newline at end of file diff --git a/src-tauri/src/app_config.rs b/src-tauri/src/app_config.rs index 041ed12d2..a50aa16fc 100644 --- a/src-tauri/src/app_config.rs +++ b/src-tauri/src/app_config.rs @@ -64,7 +64,7 @@ impl McpApps { AppType::Mcode => self.mcode = enabled, AppType::Kimi => self.kimi = enabled, AppType::Pi => self.pi = enabled, - AppType::ClaudeDesktop => {} // Claude Desktop 3P provider config doesn't support MCP here + AppType::ClaudeDesktop => {} AppType::Agy => self.agy = enabled, AppType::Omp => self.omp = enabled, } @@ -165,7 +165,7 @@ impl SkillApps { AppType::Pi => self.pi, AppType::Mcode => self.mcode, AppType::Kimi => self.kimi, - AppType::OpenClaw => false, // OpenClaw doesn't support Skills + AppType::OpenClaw => false, AppType::Agy => self.agy, AppType::Omp => self.omp, AppType::ClaudeDesktop => false, @@ -184,7 +184,7 @@ impl SkillApps { AppType::Pi => self.pi = enabled, AppType::Mcode => self.mcode = enabled, AppType::Kimi => self.kimi = enabled, - AppType::OpenClaw => {} // OpenClaw doesn't support Skills, ignore + AppType::OpenClaw => {} AppType::Agy => self.agy = enabled, AppType::Omp => self.omp = enabled, AppType::ClaudeDesktop => {} // Claude Desktop 3P profiles don't use CC Switch skill sync @@ -944,7 +944,6 @@ impl MultiAppConfig { AppType::Hermes => &mut config.prompts.hermes.prompts, // Pi was added after prompts moved to SQLite. Keeping it out of // this legacy config avoids a second, unused prompt state. - // Omp likewise stores prompts in its native agent config. AppType::Pi | AppType::Mcode | AppType::Kimi | AppType::Agy | AppType::Omp => { return Ok(false) } @@ -1131,7 +1130,7 @@ mod tests { assert_eq!("omp".parse::().unwrap(), AppType::Omp); assert_eq!("oh-my-pi".parse::().unwrap(), AppType::Omp); assert_eq!("oh_my_pi".parse::().unwrap(), AppType::Omp); - assert_eq!("omp".parse::().unwrap(), AppType::Omp); + assert_eq!("ohmypi".parse::().unwrap(), AppType::Omp); assert_eq!("OMP".parse::().unwrap(), AppType::Omp); assert_eq!(AppType::Omp.as_str(), "omp"); } diff --git a/src-tauri/src/commands/omp.rs b/src-tauri/src/commands/omp.rs index 35cf52e8a..814c9e2b8 100644 --- a/src-tauri/src/commands/omp.rs +++ b/src-tauri/src/commands/omp.rs @@ -1,20 +1,67 @@ -use crate::provider::UsageScript; -use crate::services::omp_state::{OmpCurrentState, OmpStateService}; -use crate::services::ProviderService; +use crate::error::AppError; +use crate::services::pi_state::PiCurrentState; use crate::store::AppState; use tauri::State; -#[tauri::command] -pub(crate) fn get_omp_current_state(state: State<'_, AppState>) -> Result { - OmpStateService::current(state.inner()).map_err(|error| error.to_string()) +/// Explicit providers in models.yml, plus the provider part of `modelRoles.default` (which may +/// name a built-in provider with no explicit node). Same shape as Pi's state. +fn current_state(state: &AppState) -> Result { + let _guard = futures::executor::block_on(state.proxy_service.lock_switch_for_app("omp")); + let enabled_provider_ids = crate::omp_config::get_providers()?.into_keys().collect(); + let default_provider_id = + crate::omp_config::read_default_provider_id().unwrap_or_else(|error| { + log::warn!("Failed to read Oh My Pi default model: {error}"); + None + }); + Ok(PiCurrentState { + enabled_provider_ids, + default_provider_id, + }) } #[tauri::command] -pub(crate) fn update_omp_provider_usage_script( - state: State<'_, AppState>, - id: String, - #[allow(non_snake_case)] usageScript: UsageScript, -) -> Result { - ProviderService::update_omp_usage_script(state.inner(), &id, usageScript) - .map_err(|error| error.to_string()) +pub(crate) fn get_omp_current_state(state: State<'_, AppState>) -> Result { + current_state(state.inner()).map_err(|error| error.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::database::Database; + use crate::omp_config::test_support::TestAgentDir; + use serial_test::serial; + use std::fs; + use std::sync::Arc; + + fn write(models: &str, settings: &str) { + let dir = crate::omp_config::get_omp_agent_dir().unwrap(); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join("models.yml"), models).unwrap(); + fs::write(dir.join("config.yml"), settings).unwrap(); + } + + #[test] + #[serial] + fn state_exposes_explicit_providers_and_default() { + let _agent = TestAgentDir::new(); + let state = AppState::new(Arc::new(Database::memory().unwrap())); + write( + "providers:\n managed:\n baseUrl: https://api.example.com/v1\n api: openai-completions\n models:\n - id: model-a\n native:\n baseUrl: https://native.example\n", + "modelRoles:\n default: managed/model-a\n", + ); + let current = current_state(&state).unwrap(); + assert_eq!(current.enabled_provider_ids, vec!["managed", "native"]); + assert_eq!(current.default_provider_id.as_deref(), Some("managed")); + } + + #[test] + #[serial] + fn built_in_default_without_explicit_node_is_tolerated() { + let _agent = TestAgentDir::new(); + let state = AppState::new(Arc::new(Database::memory().unwrap())); + write("providers: {}\n", "modelRoles:\n default: openai/gpt-4o\n"); + let current = current_state(&state).unwrap(); + assert!(current.enabled_provider_ids.is_empty()); + assert_eq!(current.default_provider_id.as_deref(), Some("openai")); + } } diff --git a/src-tauri/src/commands/pi.rs b/src-tauri/src/commands/pi.rs index b2673c6a4..59c540675 100644 --- a/src-tauri/src/commands/pi.rs +++ b/src-tauri/src/commands/pi.rs @@ -1,8 +1,10 @@ +use crate::app_config::AppType; use crate::provider::UsageScript; use crate::services::pi_state::{PiCurrentState, PiStateService}; use crate::services::ProviderService; use crate::session_manager::providers::pi::PiSessionDiscovery; use crate::store::AppState; +use std::str::FromStr; use tauri::State; #[tauri::command] @@ -13,10 +15,12 @@ pub(crate) fn get_pi_current_state(state: State<'_, AppState>) -> Result, + app: String, id: String, #[allow(non_snake_case)] usageScript: UsageScript, ) -> Result { - ProviderService::update_pi_usage_script(state.inner(), &id, usageScript) + let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; + ProviderService::update_pi_usage_script(state.inner(), app_type, &id, usageScript) .map_err(|error| error.to_string()) } diff --git a/src-tauri/src/database/dao/skills.rs b/src-tauri/src/database/dao/skills.rs index f1579ef3d..d1103963a 100644 --- a/src-tauri/src/database/dao/skills.rs +++ b/src-tauri/src/database/dao/skills.rs @@ -23,7 +23,7 @@ impl Database { .prepare( "SELECT id, name, description, directory, repo_owner, repo_name, repo_branch, readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild, - enabled_opencode, enabled_hermes, enabled_mcode, enabled_agy, enabled_omp, enabled_kimi, installed_at, content_hash, updated_at + enabled_opencode, enabled_hermes, installed_at, content_hash, updated_at, enabled_mcode, enabled_agy, enabled_kimi FROM skills ORDER BY name ASC", ) .map_err(|e| AppError::Database(e.to_string()))?; @@ -47,14 +47,14 @@ impl Database { opencode: row.get(12)?, hermes: row.get(13)?, pi: false, - mcode: row.get(14)?, - agy: row.get(15)?, - omp: row.get(16)?, - kimi: row.get(17)?, + omp: false, + mcode: row.get(17)?, + agy: row.get(18)?, + kimi: row.get(19)?, }, - installed_at: row.get(18)?, - content_hash: row.get(19)?, - updated_at: row.get::<_, i64>(20).unwrap_or(0), + installed_at: row.get(14)?, + content_hash: row.get(15)?, + updated_at: row.get::<_, i64>(16).unwrap_or(0), }) }) .map_err(|e| AppError::Database(e.to_string()))?; @@ -74,7 +74,7 @@ impl Database { .prepare( "SELECT id, name, description, directory, repo_owner, repo_name, repo_branch, readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild, - enabled_opencode, enabled_hermes, enabled_mcode, enabled_agy, enabled_omp, enabled_kimi, installed_at, content_hash, updated_at + enabled_opencode, enabled_hermes, installed_at, content_hash, updated_at, enabled_mcode, enabled_agy, enabled_kimi FROM skills WHERE id = ?1", ) .map_err(|e| AppError::Database(e.to_string()))?; @@ -97,14 +97,14 @@ impl Database { opencode: row.get(12)?, hermes: row.get(13)?, pi: false, - mcode: row.get(14)?, - agy: row.get(15)?, - omp: row.get(16)?, - kimi: row.get(17)?, + omp: false, + mcode: row.get(17)?, + agy: row.get(18)?, + kimi: row.get(19)?, }, - installed_at: row.get(18)?, - content_hash: row.get(19)?, - updated_at: row.get::<_, i64>(20).unwrap_or(0), + installed_at: row.get(14)?, + content_hash: row.get(15)?, + updated_at: row.get::<_, i64>(16).unwrap_or(0), }) }); @@ -121,9 +121,9 @@ impl Database { conn.execute( "INSERT OR REPLACE INTO skills (id, name, description, directory, repo_owner, repo_name, repo_branch, - readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild, enabled_opencode, enabled_hermes, enabled_mcode, enabled_agy, enabled_omp, enabled_kimi, - installed_at, content_hash, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21)", + readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild, enabled_opencode, enabled_hermes, + installed_at, content_hash, updated_at, enabled_mcode, enabled_agy, enabled_kimi) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)", params![ skill.id, skill.name, @@ -139,13 +139,12 @@ impl Database { skill.apps.grokbuild, skill.apps.opencode, skill.apps.hermes, - skill.apps.mcode, - skill.apps.agy, - skill.apps.omp, - skill.apps.kimi, skill.installed_at, skill.content_hash, skill.updated_at, + skill.apps.mcode, + skill.apps.agy, + skill.apps.kimi, ], ) .map_err(|e| AppError::Database(e.to_string()))?; @@ -214,8 +213,8 @@ impl Database { let conn = lock_conn!(self.conn); let affected = conn .execute( - "UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3, enabled_grokbuild = ?4, enabled_opencode = ?5, enabled_hermes = ?6, enabled_mcode = ?8, enabled_agy = ?9, enabled_omp = ?10, enabled_kimi = ?11 WHERE id = ?7", - params![apps.claude, apps.codex, apps.gemini, apps.grokbuild, apps.opencode, apps.hermes, id, apps.mcode, apps.agy, apps.omp, apps.kimi], + "UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3, enabled_grokbuild = ?4, enabled_opencode = ?5, enabled_hermes = ?6, enabled_mcode = ?8, enabled_agy = ?9, enabled_kimi = ?10 WHERE id = ?7", + params![apps.claude, apps.codex, apps.gemini, apps.grokbuild, apps.opencode, apps.hermes, id, apps.mcode, apps.agy, apps.kimi], ) .map_err(|e| AppError::Database(e.to_string()))?; Ok(affected > 0) diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs index 5609c6a48..959cd5eba 100644 --- a/src-tauri/src/database/mod.rs +++ b/src-tauri/src/database/mod.rs @@ -51,7 +51,7 @@ use std::sync::Mutex; /// 当前 Schema 版本号 /// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑 -pub(crate) const SCHEMA_VERSION: i32 = 22; +pub(crate) const SCHEMA_VERSION: i32 = 23; /// 安全地序列化 JSON,避免 unwrap panic pub(crate) fn to_json_string(value: &T) -> Result { diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs index 64efc20c1..e21dd8628 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -105,7 +105,6 @@ impl Database { enabled_agy BOOLEAN NOT NULL DEFAULT 0, enabled_kimi BOOLEAN NOT NULL DEFAULT 0, enabled_hermes BOOLEAN NOT NULL DEFAULT 0, - enabled_omp BOOLEAN NOT NULL DEFAULT 0, installed_at INTEGER NOT NULL DEFAULT 0, content_hash TEXT, updated_at INTEGER NOT NULL DEFAULT 0 @@ -584,7 +583,6 @@ impl Database { Self::set_user_version(conn, 20)?; } 20 => { - log::info!("迁移数据库从 v20 到 v21(添加 Antigravity 支持)"); for table in ["mcp_servers", "skills"] { if Self::table_exists(conn, table)? { Self::add_column_if_missing( @@ -598,8 +596,6 @@ impl Database { Self::set_user_version(conn, 21)?; } 21 => { - // Kimi Code support (upstream) then Oh My Pi support (local), - // both idempotent via add_column_if_missing. for table in ["mcp_servers", "skills"] { if Self::table_exists(conn, table)? { Self::add_column_if_missing( @@ -608,16 +604,22 @@ impl Database { "enabled_kimi", "BOOLEAN NOT NULL DEFAULT 0", )?; - Self::add_column_if_missing( - conn, - table, - "enabled_omp", - "BOOLEAN NOT NULL DEFAULT 0", - )?; } } Self::set_user_version(conn, 22)?; } + 22 => { + // Oh My Pi:只有 MCP 存开关;Skills 的启用状态按原生目录现算(同 Pi)。 + if Self::table_exists(conn, "mcp_servers")? { + Self::add_column_if_missing( + conn, + "mcp_servers", + "enabled_omp", + "BOOLEAN NOT NULL DEFAULT 0", + )?; + } + Self::set_user_version(conn, 23)?; + } _ => { return Err(AppError::Database(format!( "未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}" diff --git a/src-tauri/src/database/tests.rs b/src-tauri/src/database/tests.rs index 36a3e2f86..0580aaa33 100644 --- a/src-tauri/src/database/tests.rs +++ b/src-tauri/src/database/tests.rs @@ -1459,68 +1459,32 @@ fn incremental_vacuum_reclaims_entire_freelist() { assert_eq!(freelist(&conn), 0, "all free pages should be reclaimed"); } +/// v22 已随 v4.0.19 发布:从它升级必须补上 `mcp_servers.enabled_omp`,否则读 MCP 列表报 +/// `no such column`。Skills 没有 omp 列(启用状态按原生目录现算)。 #[test] -fn migration_v20_to_v21_adds_enabled_omp_columns() { +fn migration_v22_to_v23_adds_enabled_omp_to_mcp_servers_only() { let conn = Connection::open_in_memory().expect("open memory db"); - // v20 形态:含 Mcode/Pi 列(v18->v20 产物),无 Oh My Pi 列 - conn.execute_batch( - "CREATE TABLE mcp_servers ( - id TEXT PRIMARY KEY, - enabled_hermes BOOLEAN NOT NULL DEFAULT 0, - enabled_mcode BOOLEAN NOT NULL DEFAULT 0, - enabled_pi BOOLEAN NOT NULL DEFAULT 0 - ); - CREATE TABLE skills ( - id TEXT PRIMARY KEY, - enabled_hermes BOOLEAN NOT NULL DEFAULT 0, - enabled_mcode BOOLEAN NOT NULL DEFAULT 0 - );", - ) - .expect("seed v20 shape"); + Database::create_tables_on_conn(&conn).expect("create tables"); + conn.execute_batch("ALTER TABLE mcp_servers DROP COLUMN enabled_omp;") + .expect("rewind mcp_servers to v22 shape"); conn.execute( - "INSERT INTO mcp_servers (id, enabled_hermes, enabled_mcode, enabled_pi) VALUES ('mcp-1', 1, 1, 1)", + "INSERT INTO mcp_servers (id, name, server_config, enabled_pi) VALUES ('mcp-1', 'm', '{}', 1)", [], ) .expect("seed mcp row"); - conn.execute( - "INSERT INTO skills (id, enabled_hermes, enabled_mcode) VALUES ('skill-1', 1, 0)", - [], - ) - .expect("seed skill row"); - Database::set_user_version(&conn, 20).expect("set user_version=20"); + Database::set_user_version(&conn, 22).expect("set user_version=22"); Database::apply_schema_migrations_on_conn(&conn).expect("apply migrations"); - assert_eq!( - Database::get_user_version(&conn).expect("user_version after migration"), - SCHEMA_VERSION - ); - for (table, column) in [ - ("mcp_servers", "enabled_agy"), - ("skills", "enabled_agy"), - ("mcp_servers", "enabled_omp"), - ("skills", "enabled_omp"), - ] { - assert!( - Database::has_column(&conn, table, column).expect("check column"), - "{table}.{column} should exist after v20 -> {SCHEMA_VERSION}" - ); - } - // 既有标志列的值必须保留 - let (mcp_hermes, mcp_mcode, mcp_pi, mcp_omp): (i64, i64, i64, i64) = conn + assert_eq!(Database::get_user_version(&conn).unwrap(), SCHEMA_VERSION); + assert!(Database::has_column(&conn, "mcp_servers", "enabled_omp").unwrap()); + assert!(!Database::has_column(&conn, "skills", "enabled_omp").unwrap()); + let (pi, omp): (i64, i64) = conn .query_row( - "SELECT enabled_hermes, enabled_mcode, enabled_pi, enabled_omp FROM mcp_servers WHERE id = 'mcp-1'", + "SELECT enabled_pi, enabled_omp FROM mcp_servers WHERE id = 'mcp-1'", [], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + |row| Ok((row.get(0)?, row.get(1)?)), ) .expect("read mcp row"); - assert_eq!((mcp_hermes, mcp_mcode, mcp_pi, mcp_omp), (1, 1, 1, 0)); - let (skill_hermes, skill_mcode, skill_omp): (i64, i64, i64) = conn - .query_row( - "SELECT enabled_hermes, enabled_mcode, enabled_omp FROM skills WHERE id = 'skill-1'", - [], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), - ) - .expect("read skill row"); - assert_eq!((skill_hermes, skill_mcode, skill_omp), (1, 0, 0)); + assert_eq!((pi, omp), (1, 0)); } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d3836bf72..f01d7ec99 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1628,7 +1628,6 @@ pub fn run() { commands::get_pi_session_discovery, // Oh My Pi native provider and session views commands::get_omp_current_state, - commands::update_omp_provider_usage_script, // Profile management (项目配置方案) commands::list_profiles, commands::create_profile, diff --git a/src-tauri/src/mcp/omp.rs b/src-tauri/src/mcp/omp.rs index 809902c2a..847de3e22 100644 --- a/src-tauri/src/mcp/omp.rs +++ b/src-tauri/src/mcp/omp.rs @@ -6,8 +6,9 @@ use crate::store::AppState; use serde_json::{json, Value}; use std::sync::Mutex; +use super::pi::{transport_spec, unified_spec, TRANSPORT_FIELDS}; + static WRITE_LOCK: Mutex<()> = Mutex::new(()); -const TRANSPORT_FIELDS: [&str; 7] = ["command", "args", "env", "cwd", "url", "headers", "type"]; #[derive(Clone, Copy)] pub(crate) enum OmpChange<'a> { @@ -247,23 +248,3 @@ pub(crate) fn import(state: &AppState) -> Result { Err(AppError::InvalidInput(format!("Imported {count} Oh My Pi MCP servers; skipped {}. Native configurations were preserved.", skipped.join("; ")))) } } - -fn unified_spec(native: &Value) -> Value { - let mut spec = native.clone(); - if spec.is_object() && spec.get("type").is_none() { - if spec.get("command").is_some() { - spec["type"] = json!("stdio"); - } else if spec.get("url").is_some() { - spec["type"] = json!("http"); - } - } - spec -} - -fn transport_spec(spec: &Value) -> Value { - let mut spec = unified_spec(spec); - if let Some(object) = spec.as_object_mut() { - object.retain(|key, _| TRANSPORT_FIELDS.contains(&key.as_str())); - } - spec -} diff --git a/src-tauri/src/mcp/pi.rs b/src-tauri/src/mcp/pi.rs index df1032b12..6f7691417 100644 --- a/src-tauri/src/mcp/pi.rs +++ b/src-tauri/src/mcp/pi.rs @@ -20,7 +20,8 @@ use std::{ }; static WRITE_LOCK: Mutex<()> = Mutex::new(()); -const TRANSPORT_FIELDS: [&str; 7] = ["command", "args", "env", "cwd", "url", "headers", "type"]; +pub(crate) const TRANSPORT_FIELDS: [&str; 7] = + ["command", "args", "env", "cwd", "url", "headers", "type"]; /// Pi 自己的条目字段:重建条目时从数据库里保存的连接定义(导入时原样存下)带回来 const PI_FIELDS: [&str; 6] = [ "timeout", @@ -246,7 +247,7 @@ pub fn import(state: &AppState) -> Result { } } -fn transport_spec(spec: &Value) -> Value { +pub(crate) fn transport_spec(spec: &Value) -> Value { let mut spec = unified_spec(spec); if let Some(object) = spec.as_object_mut() { object.retain(|key, _| TRANSPORT_FIELDS.contains(&key.as_str())); diff --git a/src-tauri/src/omp_config.rs b/src-tauri/src/omp_config.rs new file mode 100644 index 000000000..62484a1eb --- /dev/null +++ b/src-tauri/src/omp_config.rs @@ -0,0 +1,1111 @@ +//! Oh My Pi (`omp`, can1357/oh-my-pi) owns its defaults and login. CC Switch manages only the +//! explicit `providers` entries in `models.yml` and the MCP servers in `mcp.json`, and reads +//! `config.yml` for the default model. +//! +//! The agent dir is `~/.omp/agent`, or `~/.omp/profiles//agent` for a named profile. +//! models.yml is edited as text: bytes outside the one entry CC Switch writes are never touched, +//! and a document it can't edit safely (anchors, tags, flow style) is refused, not rewritten. + +use crate::config::{atomic_write_private, get_home_dir}; +use crate::error::AppError; +use indexmap::IndexMap; +use serde_json::Value; +use std::fs; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +pub(crate) const MAX_FILE_BYTES: u64 = 1024 * 1024; +static MODELS_LOCK: Mutex<()> = Mutex::new(()); +static MCP_LOCK: Mutex<()> = Mutex::new(()); +#[cfg(test)] +static TEST_AGENT_DIR: Mutex> = Mutex::new(None); + +// ============================================================================ +// Path resolution +// ============================================================================ + +pub(crate) fn get_omp_agent_dir() -> Result { + #[cfg(test)] + if let Some(path) = TEST_AGENT_DIR + .lock() + .expect("lock Oh My Pi test directory") + .clone() + { + return Ok(path); + } + + if let Some(dir) = crate::settings::get_omp_override_dir() { + return Ok(dir); + } + + get_omp_native_agent_dir() +} + +pub(crate) fn get_omp_active_profile() -> Result, AppError> { + let selected = std::env::var_os("OMP_PROFILE").or_else(|| std::env::var_os("PI_PROFILE")); + normalize_profile( + selected + .as_deref() + .map(|name| name.to_string_lossy()) + .as_deref(), + ) +} + +fn normalize_profile(raw: Option<&str>) -> Result, AppError> { + let name = raw.unwrap_or_default().trim(); + if name.is_empty() || name == "default" { + return Ok(None); + } + let reserved = name + .split('.') + .next() + .unwrap_or_default() + .to_ascii_uppercase(); + let reserved_device = matches!(reserved.as_str(), "CON" | "PRN" | "AUX" | "NUL") + || (reserved.len() == 4 + && (reserved.starts_with("COM") || reserved.starts_with("LPT")) + && reserved.as_bytes()[3].is_ascii_digit()); + if name.len() > 64 + || !name.as_bytes()[0].is_ascii_lowercase() && !name.as_bytes()[0].is_ascii_digit() + || !name + .bytes() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || b"._-".contains(&c)) + || name.ends_with('.') + || reserved_device + { + return Err(AppError::Config(format!( + "Invalid Oh My Pi profile: {name}" + ))); + } + Ok(Some(name.to_string())) +} + +fn native_agent_dir( + home: &Path, + config_dir: Option<&str>, + profile: Option<&str>, + inherited_profile: Option<&str>, + agent_dir: Option<&Path>, +) -> Result { + // PI_CONFIG_DIR is a directory name relative to home, as in omp's path.join(home, name). + let config_dir = config_dir.filter(|name| !name.is_empty()).unwrap_or(".omp"); + let root = home.join(config_dir.trim_start_matches(std::path::MAIN_SEPARATOR)); + if let Some(profile) = profile { + return Ok(root.join("profiles").join(profile).join("agent")); + } + // A parent omp process may have exported its named profile directory. + // Explicit default mode must ignore that derived override. + let agent_dir = agent_dir.filter(|agent| { + inherited_profile + .is_none_or(|profile| *agent != root.join("profiles").join(profile).join("agent")) + }); + if let Some(agent) = agent_dir.filter(|path| !path.as_os_str().is_empty()) { + return if agent.is_absolute() { + Ok(agent.to_path_buf()) + } else { + std::env::current_dir() + .map(|cwd| cwd.join(agent)) + .map_err(|error| { + AppError::Config(format!("Cannot resolve Oh My Pi agent directory: {error}")) + }) + }; + } + Ok(root.join("agent")) +} + +pub(crate) fn get_omp_native_agent_dir() -> Result { + let profile = get_omp_active_profile()?; + let config_dir = std::env::var("PI_CONFIG_DIR").ok(); + let agent_dir = std::env::var_os("PI_CODING_AGENT_DIR").map(PathBuf::from); + let inherited_profile = normalize_profile(std::env::var("PI_PROFILE").ok().as_deref()) + .ok() + .flatten(); + native_agent_dir( + &get_home_dir(), + config_dir.as_deref(), + profile.as_deref(), + inherited_profile.as_deref(), + agent_dir.as_deref(), + ) +} + +pub(crate) fn get_omp_data_dir() -> Result { + let agent = get_omp_agent_dir()?; + #[cfg(any(target_os = "linux", target_os = "macos"))] + { + let profile = get_omp_active_profile()?; + let config_dir = std::env::var("PI_CONFIG_DIR").ok(); + let default_agent = native_agent_dir( + &get_home_dir(), + config_dir.as_deref(), + profile.as_deref(), + None, + None, + )?; + let xdg = std::env::var_os("XDG_DATA_HOME") + .filter(|value| !value.is_empty()) + .map(PathBuf::from); + Ok(data_dir( + &agent, + &default_agent, + profile.as_deref(), + xdg.as_deref(), + )) + } + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + Ok(agent) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn data_dir( + agent: &Path, + default_agent: &Path, + profile: Option<&str>, + xdg: Option<&Path>, +) -> PathBuf { + if agent == default_agent { + if let Some(xdg) = xdg { + let mut root = xdg.join("omp"); + if let Some(profile) = profile { + root = root.join("profiles").join(profile); + } + if root.exists() { + return root; + } + } + } + agent.to_path_buf() +} + +/// Existing `models.yml` → `models.yaml` wins; greenfield defaults to `models.yml`. +pub(crate) fn get_omp_models_path() -> Result { + let agent = get_omp_agent_dir()?; + for name in ["models.yml", "models.yaml"] { + let path = agent.join(name); + if path.exists() { + return Ok(path); + } + } + Ok(agent.join("models.yml")) +} + +/// Existing `config.yml` → `config.yaml` wins; greenfield defaults to `config.yml`. +pub(crate) fn get_omp_settings_path() -> Result { + let agent = get_omp_agent_dir()?; + for name in ["config.yml", "config.yaml"] { + let path = agent.join(name); + if path.exists() { + return Ok(path); + } + } + Ok(agent.join("config.yml")) +} + +/// User-level MCP config path (`~/.omp/agent/mcp.json`). +pub(crate) fn get_omp_mcp_path() -> Result { + Ok(get_omp_agent_dir()?.join("mcp.json")) +} + +// ============================================================================ +// Byte-level I/O +// ============================================================================ + +pub(crate) fn read_bytes(path: &Path) -> Result>, AppError> { + let file = match fs::File::open(path) { + Ok(file) => file, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(AppError::io(path, e)), + }; + let mut bytes = Vec::new(); + file.take(MAX_FILE_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|e| AppError::io(path, e))?; + if bytes.len() as u64 > MAX_FILE_BYTES { + return Err(AppError::Config("omp.errors.fileTooLarge".into())); + } + Ok(Some(bytes)) +} + +/// Exact bytes are the revision: compare again immediately before the atomic private write. +/// Hold the feature's mutex through this call, including the database commit and rollback. +pub(crate) fn write_and_commit( + path: &Path, + before: Option<&[u8]>, + after: &[u8], + commit: impl FnOnce() -> Result, +) -> Result { + if after.len() as u64 > MAX_FILE_BYTES { + return Err(AppError::Config("omp.errors.fileTooLarge".into())); + } + if read_bytes(path)?.as_deref() != before { + return Err(AppError::Conflict("omp.errors.externalChange".into())); + } + atomic_write_private(path, after)?; + match commit() { + Ok(result) => Ok(result), + Err(error) => { + if read_bytes(path)?.as_deref() != Some(after) { + return Err(AppError::Conflict("omp.errors.rollbackConflict".into())); + } + match before { + Some(bytes) => atomic_write_private(path, bytes)?, + None => fs::remove_file(path).map_err(|e| AppError::io(path, e))?, + } + Err(error) + } + } +} + +fn read_text(path: &Path) -> Result<(Option>, String), AppError> { + let bytes = read_bytes(path)?; + let text = std::str::from_utf8(bytes.as_deref().unwrap_or_default()) + .map_err(|_| AppError::Config("omp.errors.invalidYaml".into()))? + .to_string(); + Ok((bytes, text)) +} + +fn parse(text: &str) -> Result { + let mut document: serde_yaml::Value = serde_yaml::from_str(text) + .map_err(|_| AppError::Config("omp.errors.invalidYaml".into()))?; + if document.is_null() { + document = serde_yaml::Value::Mapping(Default::default()); + } + if !document.is_mapping() { + return Err(AppError::Config("omp.errors.invalidYaml".into())); + } + Ok(document) +} + +fn providers(document: &serde_yaml::Value) -> Result, AppError> { + match document.get("providers") { + None | Some(serde_yaml::Value::Null) => Ok(IndexMap::new()), + Some(value) => serde_yaml::from_value(value.clone()) + .map_err(|_| AppError::Config("omp.errors.invalidYaml".into())), + } +} + +// ============================================================================ +// Providers (models.yml) +// ============================================================================ + +pub(crate) fn get_providers() -> Result, AppError> { + let _guard = MODELS_LOCK.lock()?; + providers(&parse(&read_text(&get_omp_models_path()?)?.1)?) +} + +/// `api` on a provider node, as omp v18.12's models.yml schema enumerates it. +/// omp rejects the whole file — every custom provider — when one node is invalid, +/// so an unknown value is refused here rather than written. +const APIS: &[&str] = &[ + "openai-completions", + "openai-responses", + "openai-codex-responses", + "azure-openai-responses", + "anthropic-messages", + "bedrock-converse-stream", + "google-generative-ai", + "google-gemini-cli", + "google-vertex", + "openrouter-decisions", + "openai-decisions", + "workers-ai-systemone", + "typesafe", +]; + +/// Runner APIs a single model may also name (omp's `RUNNER_API_KINDS`, minus web search). +const MODEL_ONLY_APIS: &[&str] = &[ + "openai-images", + "openrouter-images", + "xai-tts", + "openai-speech", + "openai-embeddings", + "openrouter-rerank", + "openrouter-video", + "openai-transcriptions", +]; + +fn invalid_provider() -> AppError { + AppError::Config("omp.errors.invalidProvider".into()) +} + +fn check_type(node: &Value, keys: &[&str], check: impl Fn(&Value) -> bool) -> Result<(), AppError> { + for key in keys { + if node.get(key).is_some_and(|v| !check(v)) { + return Err(invalid_provider()); + } + } + Ok(()) +} + +fn string_map(v: &Value) -> bool { + v.as_object() + .is_some_and(|m| m.values().all(Value::is_string)) +} + +fn check_common(node: &Value, apis: &[&[&str]]) -> Result<(), AppError> { + if !node.is_object() { + return Err(invalid_provider()); + } + check_type(node, &["baseUrl", "apiKey"], |v| { + v.as_str().is_some_and(|s| !s.is_empty()) + })?; + check_type(node, &["api"], |v| { + v.as_str() + .is_some_and(|s| apis.iter().any(|list| list.contains(&s))) + })?; + check_type(node, &["headers"], string_map)?; + if let Some(compat) = node.get("compat") { + if !compat.is_object() { + return Err(invalid_provider()); + } + check_type( + compat, + &[ + "supportsStore", + "supportsDeveloperRole", + "supportsReasoningEffort", + "supportsUsageInStreaming", + "requiresToolResultName", + "requiresMistralToolIds", + "requiresAssistantAfterToolResult", + "requiresThinkingAsText", + "supportsStrictMode", + ], + Value::is_boolean, + )?; + } + Ok(()) +} + +/// Validate the one node CC Switch writes. Other providers in the file are never judged. +pub(crate) fn validate_provider(id: &str, node: &Value) -> Result<(), AppError> { + if id.trim().is_empty() { + return Err(invalid_provider()); + } + check_common(node, &[APIS])?; + let models = match node.get("models") { + None => &[][..], + Some(v) => v.as_array().ok_or_else(invalid_provider)?.as_slice(), + }; + if models.is_empty() { + // An override-only node must still override something (omp's own rule). + let overrides = [ + "baseUrl", + "headers", + "apiKey", + "compat", + "disableStrictTools", + "guardrailIdentifier", + "requestMetadata", + "remoteCompaction", + "discovery", + ]; + if !overrides.iter().any(|k| { + node.get(k).is_some_and(|value| match value { + Value::Bool(b) => *b, + Value::String(s) => !s.is_empty(), + _ => !value.is_null(), + }) + }) && node.get("auth").and_then(Value::as_str) != Some("none") + && node + .get("modelOverrides") + .and_then(Value::as_object) + .is_none_or(|m| m.is_empty()) + { + return Err(invalid_provider()); + } + } else if node.get("baseUrl").is_none() + || (node.get("apiKey").is_none() + && !matches!( + node.get("auth").and_then(Value::as_str), + Some("none" | "oauth") + )) + { + return Err(invalid_provider()); + } + for model in models { + check_common(model, &[APIS, MODEL_ONLY_APIS])?; + if model + .get("id") + .and_then(Value::as_str) + .is_none_or(|id| id.is_empty()) + || (model.get("api").is_none() && node.get("api").is_none()) + { + return Err(invalid_provider()); + } + check_type(model, &["input"], |v| { + v.as_array().is_some_and(|a| { + a.iter() + .all(|v| matches!(v.as_str(), Some("text" | "image"))) + }) + })?; + check_type(model, &["contextWindow", "maxTokens"], |v| { + v.as_f64().is_some_and(|n| n > 0.0) + })?; + if let Some(cost) = model.get("cost") { + if !cost.is_object() + || ["input", "output", "cacheRead", "cacheWrite"] + .iter() + .any(|k| cost.get(k).is_none_or(|v| !v.is_number())) + { + return Err(invalid_provider()); + } + } + } + // Proxy discovery supplies its API at runtime, as in omp's native validator. + if let Some(discovery) = node.get("discovery") { + if discovery.get("type").and_then(Value::as_str) != Some("proxy") + && node.get("api").is_none() + { + return Err(invalid_provider()); + } + } + Ok(()) +} + +fn manual_edit() -> AppError { + AppError::Config("omp.errors.manualEdit".into()) +} + +/// A block mapping only. Never reserialize the user's document. Blank lines and comments +/// at the end of a node belong to the surrounding text and survive replacement/removal. +fn edit_provider(text: &str, id: &str, replacement: Option<&Value>) -> Result { + if text.contains('\t') { + return Err(manual_edit()); + } + let document = parse(text).map_err(|_| manual_edit())?; + let mut intended = providers(&document)?; + if let Some(value) = replacement { + validate_provider(id, value)?; + intended.insert(id.into(), value.clone()); + } else { + intended.shift_remove(id); + } + let lines: Vec<&str> = text.split_inclusive('\n').collect(); + let mut offsets = Vec::new(); + let mut offset = 0; + for line in &lines { + offsets.push(offset); + offset += line.len(); + } + offsets.push(offset); + let mut header = None; + for (i, line) in lines.iter().enumerate() { + if line.starts_with(' ') || line.trim().is_empty() || line.trim_start().starts_with('#') { + continue; + } + if line.trim() == "---" { + continue; + } + if line.trim() == "..." { + return Err(manual_edit()); + } + if mapping_key(line)? == "providers" { + header = Some(i); + } + } + let mut output = text.to_string(); + let (start, end, indent) = if let Some(header) = header { + let line = lines[header]; + let (_, tail) = line.split_once(':').ok_or_else(manual_edit)?; + let tail = tail.split('#').next().unwrap_or_default().trim(); + if !matches!(tail, "" | "null" | "~") { + return Err(manual_edit()); + } + let end = ((header + 1)..lines.len()) + .find(|&i| { + !lines[i].starts_with(' ') + && !lines[i].trim().is_empty() + && !lines[i].trim_start().starts_with('#') + }) + .unwrap_or(lines.len()); + let mut indent = None; + let mut children = Vec::new(); + for (i, line) in lines.iter().enumerate().take(end).skip(header + 1) { + if line.trim().is_empty() || line.trim_start().starts_with('#') { + continue; + } + reject_yaml_tokens(line)?; + let spaces = line.len() - line.trim_start_matches(' ').len(); + let child_indent = *indent.get_or_insert(spaces); + if spaces < child_indent { + return Err(manual_edit()); + } + if spaces == child_indent { + if line.trim() == "{}" { + children.push((i, String::new())); + continue; + } + let key = mapping_key(line.trim_start())?; + if key == "<<" { + return Err(manual_edit()); + } + children.push((i, key)); + } + } + let indent = indent.unwrap_or(2); + let mut start = end; + while start > header + 1 + && (lines[start - 1].trim().is_empty() + || lines[start - 1].trim_start().starts_with('#')) + { + start -= 1; + } + let mut stop = start; + if let Some(index) = children + .iter() + .position(|(_, key)| key == id || key.is_empty()) + { + start = children[index].0; + stop = children.get(index + 1).map(|c| c.0).unwrap_or(end); + while stop > start + 1 + && (lines[stop - 1].trim().is_empty() + || lines[stop - 1].trim_start().starts_with('#')) + { + stop -= 1; + } + } + if !tail.is_empty() { + // Normalize only the providers header when its value is null. Edit the + // provider range first: the header rewrite changes its length. + let colon = line.find(':').ok_or_else(manual_edit)?; + let suffix = line + .find('#') + .map(|i| format!(" {}", &line[i..])) + .unwrap_or_else(|| "\n".into()); + let mut original = text.to_string(); + let emitted = emit_provider(id, replacement, indent)?; + original.replace_range(offsets[start]..offsets[stop], &emitted); + original.replace_range( + offsets[header]..offsets[header + 1], + &format!("{}:{}", &line[..colon], suffix), + ); + verify_edit(&original, &intended)?; + return Ok(original); + } + (offsets[start], offsets[stop], indent) + } else { + if replacement.is_none() { + return Ok(output); + } + if !output.is_empty() && !output.ends_with('\n') { + output.push('\n'); + } + output.push_str("providers:\n"); + (output.len(), output.len(), 2) + }; + let mut emitted = if intended.is_empty() { + format!("{}{{}}\n", " ".repeat(indent)) + } else { + emit_provider(id, replacement, indent)? + }; + if start == end && !output[..start].is_empty() && !output[..start].ends_with('\n') { + emitted.insert(0, '\n'); + } + output.replace_range(start..end, &emitted); + verify_edit(&output, &intended)?; + Ok(output) +} + +fn emit_provider(id: &str, value: Option<&Value>, indent: usize) -> Result { + let Some(value) = value else { + return Ok(String::new()); + }; + let yaml = + serde_yaml::to_string(&IndexMap::from([(id, value)])).map_err(|_| invalid_provider())?; + Ok(yaml + .lines() + .map(|line| format!("{}{line}\n", " ".repeat(indent))) + .collect()) +} + +fn verify_edit(text: &str, intended: &IndexMap) -> Result<(), AppError> { + if providers(&parse(text)?)? != *intended { + return Err(manual_edit()); + } + Ok(()) +} + +fn mapping_key(line: &str) -> Result { + // Find the mapping delimiter outside a quoted key (provider IDs are opaque). + let mut quote = None; + let mut escaped = false; + let mut colon = None; + for (index, c) in line.char_indices() { + if escaped { + escaped = false; + continue; + } + if quote == Some('"') && c == '\\' { + escaped = true; + continue; + } + if quote == Some(c) { + quote = None; + continue; + } + if quote.is_some() { + continue; + } + match c { + '"' | '\'' => quote = Some(c), + ':' => { + colon = Some(index); + break; + } + _ => {} + } + } + let colon = colon.ok_or_else(manual_edit)?; + let header = format!("{}: null", &line[..colon]); + let mapping: serde_yaml::Mapping = serde_yaml::from_str(&header).map_err(|_| manual_edit())?; + mapping + .keys() + .next() + .and_then(serde_yaml::Value::as_str) + .map(str::to_string) + .ok_or_else(manual_edit) +} + +/// Anchors, aliases, tags and merge keys can't be edited as text without breaking a +/// reference elsewhere; refuse them before writing. +fn reject_yaml_tokens(line: &str) -> Result<(), AppError> { + let mut quote = None; + let mut escaped = false; + let mut boundary = true; + let mut previous = [' '; 2]; + for c in line.chars() { + if escaped { + escaped = false; + continue; + } + if quote == Some('"') && c == '\\' { + escaped = true; + continue; + } + if quote == Some(c) { + quote = None; + boundary = false; + continue; + } + if quote.is_some() { + continue; + } + if c == ':' && previous == ['<', '<'] { + return Err(manual_edit()); + } + match c { + '"' | '\'' if boundary => quote = Some(c), + '#' if boundary => break, + '&' | '*' | '!' if boundary => return Err(manual_edit()), + _ => {} + } + previous = [previous[1], c]; + boundary = c.is_whitespace() || matches!(c, ':' | '[' | '{' | ','); + } + Ok(()) +} + +#[derive(Clone, Copy)] +pub(crate) enum Change { + Insert, + ReplaceIfPresent, + Remove, +} + +/// Apply one provider change to models.yml and commit the database in the same critical +/// section. `commit` receives the provider's previous native value. +pub(crate) fn change_and_commit( + id: &str, + config: Option<&Value>, + change: Change, + commit: impl FnOnce(Option) -> Result, +) -> Result { + let _guard = MODELS_LOCK.lock()?; + let path = get_omp_models_path()?; + let (before, text) = read_text(&path)?; + let native = providers(&parse(&text).map_err(|_| manual_edit())?)?; + let old = native.get(id).cloned(); + match change { + Change::Insert if old.is_some() => { + return Err(AppError::Config("omp.errors.duplicateProvider".into())) + } + Change::ReplaceIfPresent | Change::Remove if old.is_none() => return commit(None), + _ => {} + } + let after = edit_provider(&text, id, config)?; + write_and_commit(&path, before.as_deref(), after.as_bytes(), || commit(old)) +} + +pub(crate) fn provider_base_url(config: &Value) -> Result { + fn nonempty(value: Option<&Value>) -> Option<&str> { + value.and_then(Value::as_str).filter(|s| !s.is_empty()) + } + let provider = config.as_object().ok_or_else(invalid_provider)?; + nonempty(provider.get("baseUrl")) + .or_else(|| { + provider + .get("models") + .and_then(Value::as_array) + .and_then(|models| { + models + .iter() + .find_map(|model| nonempty(model.get("baseUrl"))) + }) + }) + .map(str::to_string) + .ok_or_else(|| AppError::InvalidInput("Oh My Pi provider has no request URL".to_string())) +} + +// ============================================================================ +// Settings (config.yml), read only +// ============================================================================ + +/// The provider part of `modelRoles.default` (`/`). CC Switch never writes it. +pub(crate) fn read_default_provider_id() -> Result, AppError> { + let document = parse(&read_text(&get_omp_settings_path()?)?.1)?; + Ok(document + .get("modelRoles") + .and_then(|roles| roles.get("default")) + .and_then(serde_yaml::Value::as_str) + .filter(|selector| !selector.is_empty()) + .map(|selector| { + selector + .split_once('/') + .map_or(selector, |(provider, _)| provider) + .to_string() + })) +} + +// ============================================================================ +// MCP servers (mcp.json) +// ============================================================================ + +/// Mutate the native MCP document. Other root keys are kept; an external write between the +/// read and the write is refused. +pub(crate) fn update_omp_mcp_document( + update: impl FnOnce(&mut Value) -> Result, +) -> Result<(), AppError> { + let _guard = MCP_LOCK.lock()?; + let path = get_omp_mcp_path()?; + let before = read_bytes(&path)?; + let mut document: Value = match &before { + None => Value::Object(Default::default()), + Some(bytes) => serde_json::from_slice(bytes) + .map_err(|_| AppError::Config("omp.errors.invalidMcp".into()))?, + }; + if !document.is_object() || document.get("mcpServers").is_some_and(|v| !v.is_object()) { + return Err(AppError::Config("omp.errors.invalidMcp".into())); + } + if update(&mut document)? { + let mut after = serde_json::to_vec_pretty(&document) + .map_err(|e| AppError::JsonSerialize { source: e })?; + after.push(b'\n'); + write_and_commit(&path, before.as_deref(), &after, || Ok(()))?; + } + Ok(()) +} + +#[cfg(test)] +pub(crate) mod test_support { + use std::path::{Path, PathBuf}; + + pub(crate) struct TestAgentDir { + _dir: Option, + previous: Option, + } + + impl TestAgentDir { + pub(crate) fn new() -> Self { + let dir = tempfile::tempdir().expect("create Oh My Pi test directory"); + let agent_dir = dir.path().join("agent"); + std::fs::create_dir_all(&agent_dir).expect("create Oh My Pi agent directory"); + Self::set(agent_dir, Some(dir)) + } + + #[allow(dead_code)] // used only by tests; compiled-in outside cfg(test) via shared test_support + pub(crate) fn at(agent_dir: &Path) -> Self { + Self::set(agent_dir.to_path_buf(), None) + } + + fn set(agent_dir: PathBuf, dir: Option) -> Self { + let previous = super::TEST_AGENT_DIR + .lock() + .expect("lock Oh My Pi test directory") + .replace(agent_dir); + Self { + _dir: dir, + previous, + } + } + } + + impl Drop for TestAgentDir { + fn drop(&mut self) { + *super::TEST_AGENT_DIR + .lock() + .expect("lock Oh My Pi test directory") = self.previous.take(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn provider() -> Value { + json!({"baseUrl":"https://example.com/v1", "apiKey":"LITERAL_KEY", "api":"openai-completions", "models":[{"id":"model-a"}]}) + } + + #[test] + fn native_profile_and_config_root_resolution() { + let home = tempfile::tempdir().expect("home"); + assert_eq!(normalize_profile(Some("").or(Some("work"))).unwrap(), None); + assert_eq!( + normalize_profile(Some("default").or(Some("work"))).unwrap(), + None + ); + assert_eq!( + normalize_profile(None.or(Some("work"))).unwrap().as_deref(), + Some("work") + ); + assert_eq!( + native_agent_dir(home.path(), Some(".custom"), None, None, None).unwrap(), + home.path().join(".custom/agent") + ); + assert_eq!( + native_agent_dir( + home.path(), + Some(".custom"), + Some("work"), + None, + Some(Path::new("ignored")) + ) + .unwrap(), + home.path().join(".custom/profiles/work/agent") + ); + let inherited = home.path().join(".custom/profiles/work/agent"); + assert_eq!( + native_agent_dir( + home.path(), + Some(".custom"), + None, + Some("work"), + Some(&inherited) + ) + .unwrap(), + home.path().join(".custom/agent") + ); + let custom = home.path().join("custom-agent"); + assert_eq!( + native_agent_dir( + home.path(), + Some(".custom"), + None, + Some("work"), + Some(&custom) + ) + .unwrap(), + custom + ); + for invalid in ["../other", "UPPER", "con", "nul.txt", "trailing."] { + assert!(normalize_profile(Some(invalid)).is_err(), "{invalid}"); + } + } + + #[test] + #[cfg(any(target_os = "linux", target_os = "macos"))] + fn xdg_data_root_follows_existing_profile_and_agent_override() { + let home = tempfile::tempdir().expect("home"); + let xdg = tempfile::tempdir().expect("xdg"); + let default = home.path().join(".omp/agent"); + std::fs::create_dir_all(xdg.path().join("omp")).unwrap(); + assert_eq!( + data_dir(&default, &default, None, Some(xdg.path())), + xdg.path().join("omp") + ); + let custom = home.path().join("custom-agent"); + assert_eq!(data_dir(&custom, &default, None, Some(xdg.path())), custom); + let work = home.path().join(".omp/profiles/work/agent"); + assert_eq!(data_dir(&work, &work, Some("work"), Some(xdg.path())), work); + std::fs::create_dir_all(xdg.path().join("omp/profiles/work")).unwrap(); + assert_eq!( + data_dir(&work, &work, Some("work"), Some(xdg.path())), + xdg.path().join("omp/profiles/work") + ); + } + + #[test] + fn surgical_edits_preserve_every_untouched_byte() { + let before = "# before\nother: 'quoted' # keep\nproviders: # catalog\n first:\n baseUrl: https://first.example\n\n # comment for target\n target:\n apiKey: old # replaced\n\n # comment for last\n last:\n headers: {X-Test: 'verbatim'}\n\n# after\nother2: [1, 2]\n"; + let replacement = provider(); + let after = edit_provider(before, "target", Some(&replacement)).unwrap(); + let prefix = before.split(" target:").next().unwrap(); + let suffix = &before[before.find("\n # comment for last").unwrap()..]; + assert!(after.starts_with(prefix)); + assert!(after.ends_with(suffix)); + assert_eq!( + providers(&parse(&after).unwrap()).unwrap()["target"], + replacement + ); + let removed = edit_provider(&after, "target", None).unwrap(); + assert!(removed.starts_with(prefix) && removed.ends_with(suffix)); + let added = edit_provider(&removed, "new", Some(&provider())).unwrap(); + assert!(added.starts_with(prefix)); + assert!(added.ends_with("# after\nother2: [1, 2]\n")); + } + + #[test] + fn refuses_unsafe_yaml_without_reemitting_it() { + for text in [ + "providers: {p: {apiKey: key}}\n", + "providers:\n p: &anchor\n apiKey: key\n", + "providers:\n p:\n headers: *anchor\n", + "providers:\n p: !custom {apiKey: key}\n", + "providers:\n <<: {p: {apiKey: key}}\n", + "providers:\n p:\n apiKey: key\n <<: {headers: {}}\n", + "providers:\n p: {apiKey: a}\n p: {apiKey: b}\n", + "providers:\n p:\n apiKey: a\n apiKey: b\n", + "providers:\n\tp: {}\n", + "[]\n", + "providers: 42\n", + "---\nproviders:\n...\n", + ] { + assert!( + edit_provider(text, "p", Some(&provider())).is_err(), + "{text}" + ); + } + } + + #[test] + fn handles_empty_null_and_last_removal_without_invalid_native_null() { + for text in [ + "", + "# keep\n", + "providers:\n", + "providers: null # keep\n", + "providers: ~\n", + ] { + let added = edit_provider(text, "p", Some(&provider())).unwrap(); + assert_eq!(providers(&parse(&added).unwrap()).unwrap().len(), 1); + let removed = edit_provider(&added, "p", None).unwrap(); + assert!( + parse(&removed).unwrap()["providers"].is_mapping(), + "{removed}" + ); + assert!(edit_provider(&removed, "p", Some(&provider())).is_ok()); + } + } + + #[test] + fn validates_only_the_replacement_node() { + for node in [ + json!({}), + json!({"baseUrl":1}), + json!({"apiKey":""}), + json!({"apiKey":"k","api":"not-an-api"}), + json!({"baseUrl":"url","models":[{"id":"x"}]}), + json!({"apiKey":"k","models":{}}), + json!({"baseUrl":"url", "apiKey":"k", "models":[{"id":"x"}]}), + json!({"apiKey":"k", "compat": []}), + json!({"apiKey":"k", "compat": {"supportsStore":"yes"}}), + json!({"headers":{"X":1}}), + ] { + assert!(validate_provider("p", &node).is_err(), "{node}"); + } + for (key, value) in [ + ("headers", json!({"X": 1})), + ("input", json!(["audio"])), + ("contextWindow", json!("large")), + ("maxTokens", json!(0)), + ("cost", json!({"input": 1})), + ( + "cost", + json!({"input": "free", "output": 0, "cacheRead": 0, "cacheWrite": 0}), + ), + ("api", json!("future-api")), + ("compat", json!({"supportsStore": "yes"})), + ("id", json!("")), + ] { + let mut invalid = provider(); + invalid["models"][0][key] = value; + assert!(validate_provider("p", &invalid).is_err(), "{key}"); + } + assert!(validate_provider( + "p", + &json!({"baseUrl":"url", "discovery":{"type":"future"}}) + ) + .is_err()); + assert!(validate_provider("p", &provider()).is_ok()); + assert!(validate_provider("builtin", &json!({"baseUrl":"https://example.com"})).is_ok()); + let mut no_auth = provider(); + no_auth.as_object_mut().unwrap().remove("apiKey"); + no_auth["auth"] = json!("none"); + assert!(validate_provider("p", &no_auth).is_ok()); + no_auth["auth"] = json!("oauth"); + assert!(validate_provider("p", &no_auth).is_ok()); + let mut per_model_api = provider(); + let api = per_model_api + .as_object_mut() + .unwrap() + .remove("api") + .unwrap(); + per_model_api["models"][0]["api"] = api; + assert!(validate_provider("p", &per_model_api).is_ok()); + let before = "providers:\n bad: {models: [{id: x}]}\n future: {api: future-api, kind: future-kind, discovery: {type: future}, thinking: {mode: future}}\n"; + let edited = edit_provider(before, "p", Some(&provider())).unwrap(); + assert!(edited.starts_with(before)); + let mut replacement = provider(); + replacement["apiKey"] = json!("next-key"); + assert!(edit_provider(&edited, "p", Some(&replacement)) + .unwrap() + .starts_with(before)); + assert!(edit_provider(before, "bad", None).is_ok()); + } + + #[test] + fn revisions_size_limit_private_write_and_rollback() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("models.yml"); + fs::write(&path, "external").unwrap(); + assert!(write_and_commit(&path, Some(b"expected"), b"ours", || Ok(())).is_err()); + assert_eq!(fs::read(&path).unwrap(), b"external"); + let result: Result<(), AppError> = + write_and_commit(&path, Some(b"external"), b"ours", || { + Err(AppError::Database("read only".into())) + }); + assert!(result.is_err()); + assert_eq!(fs::read(&path).unwrap(), b"external"); + fs::remove_file(&path).unwrap(); + let result: Result<(), AppError> = write_and_commit(&path, None, b"ours", || { + Err(AppError::Database("read only".into())) + }); + assert!(result.is_err() && !path.exists()); + write_and_commit(&path, None, b"ours", || Ok(())).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + fs::write(&path, vec![b' '; MAX_FILE_BYTES as usize + 1]).unwrap(); + assert!(read_bytes(&path).is_err()); + } + + #[test] + #[serial_test::serial] + fn yaml_extension_follows_native_precedence() { + let _agent = test_support::TestAgentDir::new(); + let dir = get_omp_agent_dir().unwrap(); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join("models.yaml"), "providers: {}\n").unwrap(); + assert_eq!(get_omp_models_path().unwrap(), dir.join("models.yaml")); + fs::write(dir.join("models.yml"), "providers: {}\n").unwrap(); + assert_eq!(get_omp_models_path().unwrap(), dir.join("models.yml")); + } +} diff --git a/src-tauri/src/omp_config/mod.rs b/src-tauri/src/omp_config/mod.rs deleted file mode 100644 index e984db675..000000000 --- a/src-tauri/src/omp_config/mod.rs +++ /dev/null @@ -1,1997 +0,0 @@ -//! Thin adapter for Oh My Pi's native files. -//! -//! Oh My Pi (`omp`, can1357/oh-my-pi) keeps its configuration under -//! `~/.omp/agent` (or `~/.omp/profiles//agent` for a named profile): -//! - `models.yml` / `models.yaml` — provider entries (`providers` map), YAML. -//! - `config.yml` / `config.yaml` — settings (`modelRoles.default`), YAML. -//! - `mcp.json` — MCP servers (`mcpServers` map), JSON. -//! - -use crate::config::{atomic_write_private, get_home_dir}; -use crate::error::AppError; -use indexmap::IndexMap; -use serde_json::{Map, Value}; -use sha2::{Digest, Sha256}; -use std::fs; -use std::io::Read; -use std::path::{Path, PathBuf}; -use std::sync::{LazyLock, Mutex, MutexGuard}; - -mod validation; - -const MAX_FILE_BYTES: u64 = 1024 * 1024; -const MISSING_REVISION: &str = "missing"; -static FILE_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); -#[cfg(test)] -static TEST_AGENT_DIR: LazyLock>> = LazyLock::new(|| Mutex::new(None)); - -// ============================================================================ -// Path resolution -// ============================================================================ - -pub(crate) fn get_omp_agent_dir() -> Result { - #[cfg(test)] - if let Some(path) = TEST_AGENT_DIR - .lock() - .expect("lock Oh My Pi test directory") - .clone() - { - return Ok(path); - } - - if let Some(dir) = crate::settings::get_omp_override_dir() { - return Ok(dir); - } - - get_omp_native_agent_dir() -} - -pub(crate) fn get_omp_active_profile() -> Result, AppError> { - let selected = std::env::var_os("OMP_PROFILE").or_else(|| std::env::var_os("PI_PROFILE")); - normalize_profile( - selected - .as_deref() - .map(|name| name.to_string_lossy()) - .as_deref(), - ) -} - -fn normalize_profile(raw: Option<&str>) -> Result, AppError> { - let name = raw.unwrap_or_default().trim(); - if name.is_empty() || name == "default" { - return Ok(None); - } - let reserved = name - .split('.') - .next() - .unwrap_or_default() - .to_ascii_uppercase(); - let reserved_device = matches!(reserved.as_str(), "CON" | "PRN" | "AUX" | "NUL") - || (reserved.len() == 4 - && (reserved.starts_with("COM") || reserved.starts_with("LPT")) - && reserved.as_bytes()[3].is_ascii_digit()); - if name.len() > 64 - || !name.as_bytes()[0].is_ascii_lowercase() && !name.as_bytes()[0].is_ascii_digit() - || !name - .bytes() - .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || b"._-".contains(&c)) - || name.ends_with('.') - || reserved_device - { - return Err(AppError::Config(format!( - "Invalid Oh My Pi profile: {name}" - ))); - } - Ok(Some(name.to_string())) -} - -fn native_agent_dir( - home: &Path, - config_dir: Option<&str>, - profile: Option<&str>, - inherited_profile: Option<&str>, - agent_dir: Option<&Path>, -) -> Result { - // PI_CONFIG_DIR is a directory name relative to home, as in omp's path.join(home, name). - let config_dir = config_dir.filter(|name| !name.is_empty()).unwrap_or(".omp"); - let root = home.join(config_dir.trim_start_matches(std::path::MAIN_SEPARATOR)); - if let Some(profile) = profile { - return Ok(root.join("profiles").join(profile).join("agent")); - } - // A parent omp process may have exported its named profile directory. - // Explicit default mode must ignore that derived override. - let agent_dir = agent_dir.filter(|agent| { - inherited_profile - .is_none_or(|profile| *agent != root.join("profiles").join(profile).join("agent")) - }); - if let Some(agent) = agent_dir.filter(|path| !path.as_os_str().is_empty()) { - return if agent.is_absolute() { - Ok(agent.to_path_buf()) - } else { - std::env::current_dir() - .map(|cwd| cwd.join(agent)) - .map_err(|error| { - AppError::Config(format!("Cannot resolve Oh My Pi agent directory: {error}")) - }) - }; - } - Ok(root.join("agent")) -} - -pub(crate) fn get_omp_native_agent_dir() -> Result { - let profile = get_omp_active_profile()?; - let config_dir = std::env::var("PI_CONFIG_DIR").ok(); - let agent_dir = std::env::var_os("PI_CODING_AGENT_DIR").map(PathBuf::from); - let inherited_profile = normalize_profile(std::env::var("PI_PROFILE").ok().as_deref()) - .ok() - .flatten(); - native_agent_dir( - &get_home_dir(), - config_dir.as_deref(), - profile.as_deref(), - inherited_profile.as_deref(), - agent_dir.as_deref(), - ) -} - -pub(crate) fn get_omp_data_dir() -> Result { - let agent = get_omp_agent_dir()?; - #[cfg(any(target_os = "linux", target_os = "macos"))] - { - let profile = get_omp_active_profile()?; - let config_dir = std::env::var("PI_CONFIG_DIR").ok(); - let default_agent = native_agent_dir( - &get_home_dir(), - config_dir.as_deref(), - profile.as_deref(), - None, - None, - )?; - let xdg = std::env::var_os("XDG_DATA_HOME") - .filter(|value| !value.is_empty()) - .map(PathBuf::from); - Ok(data_dir( - &agent, - &default_agent, - profile.as_deref(), - xdg.as_deref(), - )) - } - #[cfg(not(any(target_os = "linux", target_os = "macos")))] - Ok(agent) -} - -#[cfg(any(target_os = "linux", target_os = "macos"))] -fn data_dir( - agent: &Path, - default_agent: &Path, - profile: Option<&str>, - xdg: Option<&Path>, -) -> PathBuf { - if agent == default_agent { - if let Some(xdg) = xdg { - let mut root = xdg.join("omp"); - if let Some(profile) = profile { - root = root.join("profiles").join(profile); - } - if root.exists() { - return root; - } - } - } - agent.to_path_buf() -} - -/// Existing `models.yml` → `models.yaml` wins; greenfield defaults to `models.yml`. -pub(crate) fn get_omp_models_path() -> Result { - let agent = get_omp_agent_dir()?; - for name in ["models.yml", "models.yaml"] { - let path = agent.join(name); - if path.exists() { - return Ok(path); - } - } - Ok(agent.join("models.yml")) -} - -/// Existing `config.yml` → `config.yaml` wins; greenfield defaults to `config.yml`. -pub(crate) fn get_omp_settings_path() -> Result { - let agent = get_omp_agent_dir()?; - for name in ["config.yml", "config.yaml"] { - let path = agent.join(name); - if path.exists() { - return Ok(path); - } - } - Ok(agent.join("config.yml")) -} - -/// User-level MCP config path (`~/.omp/agent/mcp.json`). -pub(crate) fn get_omp_mcp_path() -> Result { - Ok(get_omp_agent_dir()?.join("mcp.json")) -} - -// ============================================================================ -// Read/write primitives -// ============================================================================ - -fn lock_files() -> Result, AppError> { - FILE_LOCK - .lock() - .map_err(|error| AppError::Config(format!("Oh My Pi file lock is poisoned: {error}"))) -} - -fn read_file_limited(path: &Path, label: &str) -> Result, AppError> { - let file = fs::File::open(path).map_err(|error| AppError::io(path, error))?; - let metadata = file.metadata().map_err(|error| AppError::io(path, error))?; - if metadata.len() > MAX_FILE_BYTES { - return Err(AppError::InvalidInput(format!( - "{label} file exceeds the 1 MiB limit: {}", - path.display() - ))); - } - let mut bytes = Vec::with_capacity(metadata.len() as usize); - file.take(MAX_FILE_BYTES + 1) - .read_to_end(&mut bytes) - .map_err(|error| AppError::io(path, error))?; - if bytes.len() as u64 > MAX_FILE_BYTES { - return Err(AppError::InvalidInput(format!( - "{label} file exceeds the 1 MiB limit: {}", - path.display() - ))); - } - Ok(bytes) -} - -fn revision(bytes: &[u8]) -> String { - format!("{:x}", Sha256::digest(bytes)) -} - -fn read_yaml_value(path: &Path, label: &str) -> Result { - let bytes = read_file_limited(path, label)?; - let source = String::from_utf8(bytes).map_err(|error| { - AppError::Config(format!( - "{label} file must be UTF-8 ({}): {error}", - path.display() - )) - })?; - if source.trim().is_empty() { - return Ok(Value::Object(Map::new())); - } - let yaml: serde_yaml::Value = serde_yaml::from_str(&source).map_err(|error| { - AppError::Config(format!( - "{label} file is not valid YAML ({}): {error}", - path.display() - )) - })?; - serde_json::to_value(yaml).map_err(|error| { - AppError::Config(format!( - "{label} file could not be converted from YAML ({}): {error}", - path.display() - )) - }) -} - -fn read_json_value(path: &Path, label: &str) -> Result { - let bytes = read_file_limited(path, label)?; - let source = String::from_utf8(bytes).map_err(|error| { - AppError::Config(format!( - "{label} file must be UTF-8 ({}): {error}", - path.display() - )) - })?; - if source.trim().is_empty() { - return Ok(Value::Object(Map::new())); - } - serde_json::from_str(&source).map_err(|error| { - AppError::Config(format!( - "{label} file is not valid JSON ({}): {error}", - path.display() - )) - }) -} - -fn read_document_with_revision( - path: &Path, - label: &str, - yaml: bool, -) -> Result<(Value, String), AppError> { - if !path.exists() { - return Ok((Value::Object(Map::new()), MISSING_REVISION.to_string())); - } - let bytes = read_file_limited(path, label)?; - let revision = revision(&bytes); - let document = if yaml { - read_yaml_value(path, label)? - } else { - read_json_value(path, label)? - }; - Ok((document, revision)) -} - -fn write_document( - path: &Path, - document: &Value, - expected_revision: &str, - label: &str, -) -> Result<(), AppError> { - let mut bytes = - serde_json::to_vec_pretty(document).map_err(|source| AppError::JsonSerialize { source })?; - bytes.push(b'\n'); - ensure_parent(path)?; - ensure_revision(path, expected_revision, label)?; - atomic_write_private(path, &bytes) -} - -fn ensure_revision(path: &Path, expected_revision: &str, label: &str) -> Result<(), AppError> { - let actual_revision = match fs::File::open(path) { - Ok(_) => revision(&read_file_limited(path, label)?), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => MISSING_REVISION.to_string(), - Err(error) => return Err(AppError::io(path, error)), - }; - if actual_revision == expected_revision { - Ok(()) - } else { - Err(AppError::Conflict(format!( - "{label} changed outside CC Switch: {}", - path.display() - ))) - } -} - -fn ensure_parent(path: &Path) -> Result<(), AppError> { - let parent = path.parent().ok_or_else(|| { - AppError::Config(format!( - "Oh My Pi config path has no parent directory: {}", - path.display() - )) - })?; - if !parent.exists() { - fs::create_dir_all(parent).map_err(|source| AppError::io(parent, source))?; - } - Ok(()) -} - -fn optional_string( - object: &Map, - key: &str, - path: &Path, -) -> Result, AppError> { - match object.get(key) { - None | Some(Value::Null) => Ok(None), - Some(Value::String(value)) => Ok(Some(value.clone())), - Some(_) => Err(AppError::Config(format!( - "Oh My Pi settings '{key}' must be a string: {}", - path.display() - ))), - } -} - -fn nonempty_string(value: Option<&Value>) -> Option<&str> { - value - .and_then(Value::as_str) - .filter(|value| !value.is_empty()) -} - -// ============================================================================ -// Provider CRUD (models.yml) - -/// Root keys owned by the curated Oh My Pi form controls. They are serialized -/// from the settings object; everything else may come from the raw passthrough -/// text (with comments) the user typed in the YAML editor. -/// -/// `discovery` is deliberately absent: the form keeps the editor's own -/// `discovery:` block (comments, `timeoutMs`, `injectV1`) in the passthrough -/// text, and [`compose_provider_entry`] writes a passthrough key verbatim -/// instead of serializing it from the settings object. Listing it here would -/// drop the block from the passthrough and re-serialize it from the curated -/// value, throwing its comments away. -const CONTROLLED_PROVIDER_KEYS: [&str; 7] = [ - "name", "baseUrl", "api", "apiKey", "headers", "compat", "models", -]; - -/// Byte range of a top-level (`column 0`) YAML section, hermes-style: the -/// section spans from its key line to the next top-level key line or EOF. -fn top_level_section_range(raw: &str, section_key: &str) -> Option<(usize, usize)> { - let target = format!("{section_key}:"); - let mut start = None; - let mut offset = 0; - for line in raw.split('\n') { - let trimmed = line.trim_end_matches('\r'); - let is_key_line = !line.is_empty() - && !line.starts_with([' ', '\t', '#', '-']) - && line.find(':').is_some_and(|colon| { - line[colon + 1..].is_empty() || line[colon + 1..].starts_with([' ', '\t', '\r']) - }); - if start.is_none() { - if is_key_line && trimmed.starts_with(&target) { - let after = &trimmed[target.len()..]; - if after.is_empty() || after.starts_with([' ', '\t']) { - start = Some(offset); - } - } - } else if is_key_line { - if let Some(start) = start { - return Some((start, offset)); - } - } - offset += line.len() + 1; - } - start.map(|start| (start, raw.len())) -} - -fn line_indent(line: &str) -> usize { - line.len() - line.trim_start_matches([' ', '\t']).len() -} - -/// The YAML key a mapping key line declares, or `None` when the line is not a -/// key line (`:`, `: value`). -/// -/// Quoted keys are unwrapped, honouring YAML's doubled-quote escape: `'it''s':` -/// names `it's`. -fn key_line_name(line: &str) -> Option { - let mut chars = line.char_indices(); - let (_, first) = chars.next()?; - if first == '\'' || first == '"' { - let mut name = String::new(); - let mut rest: Option<&str> = None; - while let Some((_, ch)) = chars.next() { - if ch != first { - name.push(ch); - continue; - } - // Doubled quote inside the scalar: a literal quote, not the end. - match chars.next() { - Some((_, next)) if next == first => name.push(first), - Some((next_index, ':')) => { - rest = Some(&line[next_index + 1..]); - break; - } - _ => return None, - } - } - let rest = rest?; - return (rest.is_empty() || rest.starts_with([' ', '\t'])).then_some(name); - } - let colon = line.find(':')?; - let (key, rest) = (&line[..colon], &line[colon + 1..]); - if !(rest.is_empty() || rest.starts_with([' ', '\t'])) { - return None; - } - let key = key.trim_end(); - (!key.is_empty()).then(|| key.to_string()) -} - -/// Whether a `providers:` child line declares `provider_key`. -/// -/// Compared on the **YAML value** of the key, not as text: `serde_yaml` quotes -/// any key whose plain form would not round-trip (`'true':`, `"1e5":`), so a -/// text-prefix match misses entries the file really holds — the removal then -/// silently no-ops while the update appends a duplicate key. -fn provider_entry_matches(line: &str, provider_key: &str) -> bool { - key_line_name(line).is_some_and(|name| name == provider_key) -} - -/// Guard one text-level splice by comparing the **semantics** of the file -/// before and after it. -/// -/// A text splice can break in ways its own output does not show: an anchor -/// (`&name`) defined on one provider and referenced (`*name`, or a `<<:` merge -/// key) from another leaves a dangling alias once either side is rewritten, and -/// omp then disables *every* custom provider (`ModelsConfigFile.tryLoad` → -/// `{error, models: []}`). Tabs and flow-style sequences break indentation the -/// same way. `expected` is the providers map the edit intends to leave behind; -/// anything else — a parse failure, a survivor, a lost sibling, a duplicated -/// key, a value that no longer round-trips — is refused before the write, so -/// the file on disk is never the one we could not reason about. -fn verify_splice( - before: &str, - after: &str, - provider_key: &str, - expected: &Map, -) -> Result<(), AppError> { - let before_map = - providers_from_raw(before).map_err(|error| splice_refused(provider_key, &error))?; - let after_map = - providers_from_raw(after).map_err(|error| splice_refused(provider_key, &error))?; - // The edited key is expected to differ; every *other* key must not, or the - // splice reached past the block it was supposed to rewrite. - if let Some(untouched) = first_divergence(&before_map, &after_map, provider_key) { - return Err(splice_refused( - provider_key, - &AppError::Config(format!( - "editing it would also change '{untouched}', which is why the edit was not written" - )), - )); - } - if after_map != *expected { - return Err(splice_refused( - provider_key, - &AppError::Config(format!( - "the result does not mean what was asked for (expected {expected:?}, got {after_map:?})" - )), - )); - } - Ok(()) -} - -/// The first key other than `edited` that the splice was not supposed to touch -/// but changed. -fn first_divergence( - before: &Map, - after: &Map, - edited: &str, -) -> Option { - before - .iter() - .find(|(key, value)| key.as_str() != edited && after.get(*key) != Some(*value)) - .map(|(key, _)| key.clone()) -} - -fn splice_refused(provider_key: &str, cause: &AppError) -> AppError { - AppError::Config(format!( - "Oh My Pi provider '{provider_key}' could not be edited in models.yml: {cause}. \ - Edit that file by hand — CC Switch only writes in place when the rest of the file \ - keeps its exact meaning." - )) -} - -/// The providers map as parsed from `raw`, so a rewrite can be checked against -/// the intent instead of trusted. -/// -/// A blank file (or a file that parses as null) is a valid starting point — the -/// first insert into a `models.yml` that has no providers yet — not an error. -fn providers_from_raw(raw: &str) -> Result, AppError> { - if raw.trim().is_empty() { - return Ok(Map::new()); - } - let document: serde_yaml::Value = serde_yaml::from_str(raw) - .map_err(|error| AppError::Config(format!("the file is not valid YAML: {error}")))?; - if document.is_null() { - return Ok(Map::new()); - } - let root = document - .as_mapping() - .ok_or_else(|| AppError::Config("the file's root is not an object".to_string()))?; - match root.get(serde_yaml::Value::String("providers".to_string())) { - None | Some(serde_yaml::Value::Null) => Ok(Map::new()), - Some(providers) => serde_json::to_value(providers) - .ok() - .and_then(|value| value.as_object().cloned()) - .ok_or_else(|| AppError::Config("'providers' is not an object".to_string())), - } -} - -/// Byte range of one `providers.` entry inside the `providers:` section. -/// -/// Entries are the first indented mapping level below `providers:`; an entry -/// ends at the next line indented no deeper (a sibling entry or the end of the -/// section). Blank lines and comments belong to whatever follows them. -fn provider_entry_range(raw: &str, provider_key: &str) -> Option<(usize, usize)> { - let (section_start, section_end) = top_level_section_range(raw, "providers")?; - let mut entry_indent: Option = None; - let mut entry_start: Option = None; - let mut entry_end: Option = None; - let mut offset = section_start; - for line in raw[section_start..section_end].split('\n') { - let trimmed_end = line.trim_end_matches('\r'); - let trimmed = trimmed_end.trim_start(); - let indent = line_indent(trimmed_end); - let line_end = offset + line.len() + 1; - if trimmed.is_empty() { - offset = line_end; - continue; - } - // A comment indented deeper than the entry key can only document that - // entry, so it stays inside the block. One at the entry's own indent is - // a separator that introduces the next provider: ending the range here - // keeps it with its owner instead of moving it on save. - if trimmed.starts_with('#') { - if let Some(current_end) = entry_end { - if indent > entry_indent.unwrap_or(0) { - entry_end = Some(line_end); - } else { - return Some((entry_start?, current_end)); - } - } - offset = line_end; - continue; - } - // The `providers:` key line itself (indent 0) must not set the entry - // indent; entries are the first indented level below it. - if entry_indent.is_none() && indent == 0 { - offset = line_end; - continue; - } - match entry_indent { - None => entry_indent = Some(indent), - Some(entry_indent) => { - if let Some(start) = entry_start { - if indent <= entry_indent { - return Some((start, entry_end.unwrap_or(offset))); - } - } - } - } - if entry_start.is_none() - && indent == entry_indent.expect("set above") - && provider_entry_matches(trimmed, provider_key) - { - entry_start = Some(offset); - } - if entry_start.is_some() { - entry_end = Some(line_end); - } - offset = line_end; - } - entry_start.map(|start| (start, section_end.min(entry_end.unwrap_or(offset)))) -} - -/// The raw text of one provider entry (as written in models.yml), or `None` -/// when the entry is absent. Indentation is stripped so the result can be -/// shown in the "other fields" YAML editor. -pub(crate) fn read_omp_provider_entry_raw(provider_key: &str) -> Result, AppError> { - let _guard = lock_files()?; - let path = get_omp_models_path()?; - if !path.exists() { - return Ok(None); - } - let raw = String::from_utf8(read_file_limited(&path, "Oh My Pi models")?).map_err(|error| { - AppError::Config(format!( - "Oh My Pi models file must be UTF-8 ({}): {error}", - path.display() - )) - })?; - Ok(entry_raw_from_source(&raw, provider_key)) -} - -/// The raw text of every requested provider entry, from a single file read. -/// Missing keys are simply absent from the result. -pub(crate) fn read_omp_provider_entries_raw( - provider_keys: &[String], -) -> Result, AppError> { - let _guard = lock_files()?; - let path = get_omp_models_path()?; - let mut entries = IndexMap::new(); - if !path.exists() { - return Ok(entries); - } - let raw = String::from_utf8(read_file_limited(&path, "Oh My Pi models")?).map_err(|error| { - AppError::Config(format!( - "Oh My Pi models file must be UTF-8 ({}): {error}", - path.display() - )) - })?; - for key in provider_keys { - if let Some(text) = entry_raw_from_source(&raw, key) { - entries.insert(key.clone(), text); - } - } - Ok(entries) -} - -fn entry_raw_from_source(raw: &str, provider_key: &str) -> Option { - let (start, end) = provider_entry_range(raw, provider_key)?; - let entry = &raw[start..end]; - // Skip the `:` line and dedent its indent level plus one more: the - // editor text is the entry's fields at column 0 ("other fields" YAML), - // not the keyed entry. The key line's comment never reaches the editor; - // the writer preserves it in place instead. - let key_indent = entry.lines().next().map(line_indent).unwrap_or(0); - let strip = key_indent + 2; - let dedented = entry - .lines() - .skip(1) - .map(|line| { - if line.len() >= strip && line[..strip].trim().is_empty() { - &line[strip..] - } else { - line.trim_start_matches('\r') - } - }) - .collect::>() - .join("\n"); - Some(dedented.trim_end_matches(['\n', '\r', ' ']).to_string()) -} - -/// Top-level (column 0) lines of a controlled key, plus the nested block that -/// follows them, are dropped from the passthrough text: those keys belong to -/// the curated form fields and are serialized from the settings object. -/// Returns the kept text plus each controlled key's trailing comment (the -/// text after `#` on its key line, e.g. `baseUrl: https://x #local`), which -/// the composer re-attaches to the serialized line. -fn drop_controlled_keys(passthrough: &str) -> (String, Vec<(String, String)>) { - let mut kept: Vec<&str> = Vec::new(); - let mut comments: Vec<(String, String)> = Vec::new(); - let mut skipping_indent: Option = None; - for line in passthrough.lines() { - let trimmed_end = line.trim_end_matches('\r'); - let trimmed = trimmed_end.trim_start(); - if trimmed.is_empty() || trimmed.starts_with('#') { - // Comments and blank lines always survive, even inside a skipped - // controlled-key block — they may document the field after them. - kept.push(line); - continue; - } - let indent = line_indent(trimmed_end); - if let Some(limit) = skipping_indent { - // Nested content (including sequence items at the same indent, as - // YAML allows for block sequences under a mapping key) belongs to - // the skipped controlled key. - if indent > limit || (indent == limit && trimmed.starts_with('-')) { - continue; - } - skipping_indent = None; - } - if indent == 0 { - let key = trimmed.split(':').next().unwrap_or_default().trim(); - if CONTROLLED_PROVIDER_KEYS.contains(&key) { - skipping_indent = Some(0); - if let Some((_, comment)) = trimmed_end.split_once('#') { - // Keep the text exactly as typed (minus the marker itself): - // `# secret` must not come back as `#secret`, and dropping the - // leading space would also reflow a `#noqa`-style marker. - comments.push((key.to_string(), comment.trim_end().to_string())); - } - continue; - } - } - kept.push(line); - } - (kept.join("\n"), comments) -} - -/// Compose the `models.yml` fragment for one provider entry: curated fields -/// serialized from `config`, then the raw passthrough text (comments kept) -/// indented under the entry. Keys parsed from `passthrough_raw` are excluded -/// from the serialized part so they are written exactly as typed. -fn compose_provider_entry( - provider_key: &str, - config: &Value, - passthrough_raw: Option<&str>, -) -> Result { - let Some(object) = config.as_object() else { - return Err(AppError::Config( - "Oh My Pi provider configuration must be an object".to_string(), - )); - }; - - let (passthrough_kept, key_comments) = passthrough_raw - .map(drop_controlled_keys) - .unwrap_or_default(); - let mut passthrough_text = passthrough_kept; - if passthrough_text.trim().is_empty() { - passthrough_text.clear(); - } - - // A stale editor snapshot may repeat the `:` line (comment included); - // peel it off so it doesn't leak into the passthrough YAML. - let first_line = passthrough_text - .lines() - .next() - .unwrap_or_default() - .to_string(); - if first_line - .trim_start() - .starts_with(&format!("{provider_key}:")) - { - passthrough_text = passthrough_text - .lines() - .skip(1) - .collect::>() - .join("\n"); - if passthrough_text.trim().is_empty() { - passthrough_text.clear(); - } - } - - // Which root keys does the passthrough text own? Parsed (not extracted from - // lines) so quoted/exotic keys are handled correctly. - let passthrough_keys: std::collections::HashSet = if passthrough_text.is_empty() { - Default::default() - } else { - serde_yaml::from_str::(&passthrough_text) - .ok() - .and_then(|doc| match doc { - serde_yaml::Value::Mapping(map) => Some( - map.into_iter() - .filter_map(|(key, _)| key.as_str().map(str::to_string)) - .collect(), - ), - _ => None, - }) - .unwrap_or_default() - }; - - let mut curated = serde_yaml::Mapping::new(); - for (key, value) in object { - if passthrough_keys.contains(key) { - continue; - } - let yaml_value = - serde_json::from_value::(value.clone()).map_err(|error| { - AppError::Config(format!( - "Oh My Pi provider '{provider_key}' could not be converted to YAML: {error}" - )) - })?; - curated.insert(serde_yaml::Value::String(key.clone()), yaml_value); - } - - let body = if curated.is_empty() { - format!("{provider_key}:\n") - } else { - let mut entry = serde_yaml::Mapping::new(); - entry.insert( - serde_yaml::Value::String(provider_key.to_string()), - serde_yaml::Value::Mapping(curated), - ); - serde_yaml::to_string(&serde_yaml::Value::Mapping(entry)).map_err(|error| { - AppError::Config(format!( - "Failed to serialize Oh My Pi provider '{provider_key}': {error}" - )) - })? - }; - let mut body = body.trim_end_matches('\n').to_string(); - // Re-attach the trailing comments users typed on controlled-key lines - // (e.g. `baseUrl: https://x #local`): the line itself is re-serialized - // from the form value, only the comment survives from the editor text. - for (key, comment) in &key_comments { - if comment.is_empty() { - continue; - } - // serde_yaml 0.9 serializes the entry's fields at indent 2. - let marker = format!("\n {key}:"); - if let Some(line_start) = body.find(&marker) { - // Search past the marker's own leading newline. - let line_end = body[line_start + 1..] - .find('\n') - .map(|end| line_start + 1 + end) - .unwrap_or(body.len()); - let line = body[line_start..line_end].to_string(); - if !line.contains('#') { - let annotated = format!("{line} #{comment}"); - body.replace_range(line_start..line_end, &annotated); - } - } - } - if !passthrough_text.is_empty() { - for line in passthrough_text.trim_end().lines() { - if line.trim().is_empty() { - continue; - } - body.push('\n'); - body.push_str(" "); - body.push_str(line.trim_end_matches('\r')); - } - } - // The fragment lives inside `providers:` — indent every line by one level. - let fragment = body - .lines() - .map(|line| format!(" {line}")) - .collect::>() - .join("\n"); - Ok(format!("{fragment}\n")) -} - -/// Replace (or append) one provider entry in the raw models.yml text, leaving -/// every other byte — comments included — untouched. -fn replace_provider_entry_in_raw(raw: &str, provider_key: &str, entry_fragment: &str) -> String { - if let Some((start, end)) = provider_entry_range(raw, provider_key) { - // Preserve the key line's comment (e.g. `example: #local`): it lives - // outside the editor text, so only the file can carry it. Re-attach - // it to the regenerated key line unless one was typed there already. - let old_key_line = raw[start..end].lines().next().unwrap_or_default(); - let new_key_line = entry_fragment.lines().next().unwrap_or_default(); - let owned_fragment; - let old_comment = old_key_line.split_once('#').map(|(_, c)| c); - let new_comment = new_key_line.split_once('#').map(|(_, c)| c); - let entry_fragment = if let (Some(comment), None) = (old_comment, new_comment) { - let mut fragment = new_key_line.to_string(); - fragment.push_str(" #"); - fragment.push_str(comment.trim_end_matches('\r')); - fragment.push('\n'); - fragment.push_str( - &entry_fragment - .lines() - .skip(1) - .collect::>() - .join("\n"), - ); - if entry_fragment.ends_with('\n') { - fragment.push('\n'); - } - owned_fragment = fragment; - owned_fragment.as_str() - } else { - entry_fragment - }; - let mut result = String::with_capacity(raw.len()); - result.push_str(&raw[..start]); - result.push_str(entry_fragment); - let mut remainder = &raw[end..]; - while remainder.starts_with('\n') || remainder.starts_with('\r') { - remainder = &remainder[1..]; - } - result.push_str(remainder); - return result; - } - - let mut result = raw.to_string(); - if let Some((_, section_end)) = top_level_section_range(raw, "providers") { - // Insert before the next top-level section (or EOF). - let insert_at = result.len().min(section_end); - let mut prefix = result[..insert_at].to_string(); - while prefix.ends_with('\n') || prefix.ends_with('\r') || prefix.ends_with(' ') { - prefix.pop(); - } - prefix.push('\n'); - let suffix = result[insert_at..].to_string(); - result = prefix; - result.push_str(entry_fragment); - // The fragment already ends with a newline; only add separation when - // another top-level section follows. - if !suffix.is_empty() && !suffix.starts_with('\n') { - result.push('\n'); - } - result.push_str(&suffix); - } else { - if !result.is_empty() && !result.ends_with('\n') { - result.push('\n'); - } - result.push_str("providers:\n"); - result.push_str(entry_fragment); - } - result -} - -/// Remove one provider entry from the raw models.yml text, leaving every -/// other byte — comments included — untouched. -fn remove_provider_entry_from_raw(raw: &str, provider_key: &str) -> String { - let Some((entry_start, entry_end)) = provider_entry_range(raw, provider_key) else { - return raw.to_string(); - }; - let entry_indent = line_indent(raw[entry_start..].split('\n').next().unwrap_or_default()); - let start = reclaim_leading_comments(raw, entry_start, entry_indent); - let end = reclaim_trailing_comments(raw, entry_end, entry_indent); - let mut result = String::with_capacity(raw.len()); - result.push_str(&raw[..start]); - let mut remainder = &raw[end..]; - while remainder.starts_with('\n') || remainder.starts_with('\r') { - remainder = &remainder[1..]; - } - result.push_str(remainder); - result -} - -/// Move the start of a removal back over the comment lines that belong to the -/// entry being deleted. -/// -/// `provider_entry_range` starts at the key line, but a comment directly above -/// an entry usually documents *that* entry. Leaving it behind re-attaches it to -/// whichever entry follows — silently rewriting someone else's documentation. -/// Only the contiguous run of comments indented at least as deep as the entry -/// is reclaimed; a blank line ends the run, and the `providers:` header is -/// never taken. -fn reclaim_leading_comments(raw: &str, entry_start: usize, entry_indent: usize) -> usize { - let header_end = raw - .find("providers:") - .map(|index| index + "providers:".len()) - .unwrap_or(0); - let mut start = entry_start; - while start > header_end { - let Some(previous_newline) = raw[..start].rfind('\n') else { - break; - }; - let previous_start = raw[..previous_newline] - .rfind('\n') - .map(|index| index + 1) - .unwrap_or(0); - if previous_start <= header_end { - break; - } - let line = &raw[previous_start..previous_newline]; - let trimmed = line.trim_start(); - // A blank line separates the comment from the entry; keep it. - if trimmed.is_empty() || !trimmed.starts_with('#') { - break; - } - if line.len() - trimmed.len() < entry_indent { - break; - } - start = previous_start; - } - start -} - -/// Take back the comment lines that trail *inside* the entry being removed. -/// -/// They are indented deeper than the entry key, so they can only document the -/// entry itself. A comment at the entry's own indent is left alone: it precedes -/// the next provider and belongs to it. -fn reclaim_trailing_comments(raw: &str, entry_end: usize, entry_indent: usize) -> usize { - let mut end = entry_end; - while end < raw.len() { - let line_end = raw[end..] - .find('\n') - .map(|index| end + index) - .unwrap_or(raw.len()); - let line = &raw[end..line_end]; - let trimmed = line.trim_start(); - if trimmed.is_empty() || !trimmed.starts_with('#') { - break; - } - if line.len() - trimmed.len() <= entry_indent { - break; - } - end = (line_end + 1).min(raw.len()); - } - end -} - -/// Write one provider entry to models.yml at the text level: only the -/// `providers.` block is rewritten (curated fields serialized from -/// `config`, other fields from `passthrough_raw` verbatim with comments); -/// the rest of the file — comments included — is left untouched. -/// -/// The write goes through `verify_splice`, so the file is only replaced when -/// the result still parses and every other provider keeps its exact meaning. -/// -/// Returns `Ok(false)` when nothing changed on disk. -pub(crate) fn write_omp_provider_entry( - provider_key: &str, - config: &Value, - passthrough_raw: Option<&str>, -) -> Result { - validate_provider_node(provider_key, config)?; - let _guard = lock_files()?; - let path = get_omp_models_path()?; - let raw = if path.exists() { - String::from_utf8(read_file_limited(&path, "Oh My Pi models")?).map_err(|error| { - AppError::Config(format!( - "Oh My Pi models file must be UTF-8 ({}): {error}", - path.display() - )) - })? - } else { - String::new() - }; - let entry_fragment = compose_provider_entry(provider_key, config, passthrough_raw)?; - let after = replace_provider_entry_in_raw(&raw, provider_key, &entry_fragment); - if after == raw { - return Ok(false); - } - let mut expected = - providers_from_raw(&raw).map_err(|error| splice_refused(provider_key, &error))?; - expected.insert(provider_key.to_string(), config.clone()); - verify_splice(&raw, &after, provider_key, &expected)?; - ensure_parent(&path)?; - atomic_write_private(&path, after.as_bytes())?; - Ok(true) -} - -pub(crate) fn read_omp_native_providers() -> Result, AppError> { - let _guard = lock_files()?; - let path = get_omp_models_path()?; - let document = read_document_with_revision(&path, "Oh My Pi models", true)?.0; - Ok(providers(&document, &path)? - .iter() - .map(|(key, config)| (key.clone(), config.clone())) - .collect()) -} - -pub(crate) fn read_omp_native_provider(provider_key: &str) -> Result, AppError> { - let _guard = lock_files()?; - let path = get_omp_models_path()?; - let document = read_document_with_revision(&path, "Oh My Pi models", true)?.0; - Ok(providers(&document, &path)?.get(provider_key).cloned()) -} - -pub(crate) fn omp_provider_exists(provider_key: &str) -> Result { - let _guard = lock_files()?; - let path = get_omp_models_path()?; - let document = read_document_with_revision(&path, "Oh My Pi models", true)?.0; - Ok(providers(&document, &path)?.contains_key(provider_key)) -} - -pub(crate) fn insert_omp_provider(provider_key: &str, config: &Value) -> Result { - validate_provider_node(provider_key, config)?; - let _guard = lock_files()?; - let path = get_omp_models_path()?; - let (document, _) = read_document_with_revision(&path, "Oh My Pi models", true)?; - match providers(&document, &path)?.get(provider_key) { - Some(current) if current == config => return Ok(false), - Some(_) => { - return Err(AppError::InvalidInput(format!( - "Oh My Pi provider key '{provider_key}' already exists in models.yml" - ))) - } - None => {} - } - - // Text-level writer: only the `providers.` block is composed; the - // rest of the file — comments included — is preserved byte for byte. - drop(_guard); - write_omp_provider_entry(provider_key, config, None)?; - Ok(true) -} - -pub(crate) fn replace_omp_provider( - provider_key: &str, - expected: &Value, - replacement: &Value, -) -> Result<(), AppError> { - replace_omp_provider_if_present(provider_key, expected, replacement, None)?.ok_or_else( - || { - AppError::Conflict(format!( - "Oh My Pi provider '{provider_key}' is no longer present in models.yml" - )) - }, - )?; - Ok(()) -} - -/// Replace a provider entry at the text level so comments survive. Returns the -/// previous entry value. -pub(crate) fn replace_omp_provider_if_present( - provider_key: &str, - expected: &Value, - replacement: &Value, - passthrough_raw: Option<&str>, -) -> Result, AppError> { - validate_provider_node(provider_key, replacement)?; - let _guard = lock_files()?; - let path = get_omp_models_path()?; - let (document, _) = read_document_with_revision(&path, "Oh My Pi models", true)?; - let Some(current) = providers(&document, &path)?.get(provider_key).cloned() else { - return Ok(None); - }; - if current != *expected { - return Err(AppError::Conflict(format!( - "Oh My Pi provider '{provider_key}' changed outside CC Switch" - ))); - } - if current == *replacement && passthrough_raw.is_none() { - return Ok(Some(current)); - } - // Text-level writer takes its own file lock; release ours first. It runs - // even when the parsed config is unchanged: comment-only edits never - // change `replacement` yet must reach the file (the writer's own - // `new_raw == raw` check is the real no-op guard). - drop(_guard); - write_omp_provider_entry(provider_key, replacement, passthrough_raw)?; - Ok(Some(current)) -} - -pub(crate) fn remove_omp_provider(provider_key: &str) -> Result, AppError> { - remove_omp_provider_inner(provider_key, None) -} - -pub(crate) fn remove_omp_provider_if_matches( - provider_key: &str, - expected: &Value, -) -> Result { - remove_omp_provider_inner(provider_key, Some(expected)).map(|removed| removed.is_some()) -} - -fn remove_omp_provider_inner( - provider_key: &str, - expected: Option<&Value>, -) -> Result, AppError> { - let _guard = lock_files()?; - let path = get_omp_models_path()?; - let (document, expected_revision) = - read_document_with_revision(&path, "Oh My Pi models", true)?; - let Some(current) = providers(&document, &path)?.get(provider_key).cloned() else { - return Ok(None); - }; - if expected.is_some_and(|expected| current != *expected) { - return Err(AppError::Conflict(format!( - "Oh My Pi provider '{provider_key}' changed outside CC Switch" - ))); - } - // Text-level splice: only the `providers.` block is removed; the - // rest of the file — comments included — is preserved byte for byte. - // - // This used to trust the splice. It cannot: an anchor defined on the entry - // being deleted leaves its aliases dangling elsewhere, which makes omp - // reject the whole file and disable *every* custom provider, and an entry - // written with a quoted key (`'true':`) is not found by a text-prefix match - // at all — the removal silently no-ops while the caller drops the DB row, - // so the provider reappears on the next list. Both are caught here by - // `verify_splice`, which re-parses the result and compares every other - // provider against the file we read. - if path.exists() { - let raw = - String::from_utf8(read_file_limited(&path, "Oh My Pi models")?).map_err(|error| { - AppError::Config(format!( - "Oh My Pi models file must be UTF-8 ({}): {error}", - path.display() - )) - })?; - let after = remove_provider_entry_from_raw(&raw, provider_key); - if after == raw { - return Err(AppError::Config(format!( - "Oh My Pi provider '{provider_key}' could not be located in models.yml for removal" - ))); - } - let mut expected = - providers_from_raw(&raw).map_err(|error| splice_refused(provider_key, &error))?; - expected.remove(provider_key); - verify_splice(&raw, &after, provider_key, &expected)?; - // Fail if the file changed between the read above and this write. - ensure_revision(&path, &expected_revision, "Oh My Pi models")?; - ensure_parent(&path)?; - atomic_write_private(&path, after.as_bytes())?; - } - Ok(Some(current)) -} - -pub(crate) fn restore_omp_provider_if_missing( - provider_key: &str, - config: &Value, -) -> Result<(), AppError> { - validate_provider_node(provider_key, config)?; - let _guard = lock_files()?; - let path = get_omp_models_path()?; - let (document, _) = read_document_with_revision(&path, "Oh My Pi models", true)?; - match providers(&document, &path)?.get(provider_key) { - Some(current) if current == config => Ok(()), - Some(_) => Err(AppError::Conflict(format!( - "cannot restore Oh My Pi provider '{provider_key}' because another value now owns the key" - ))), - None => { - // Text-level writer: only the `providers.` block is composed; - // the rest of the file — comments included — is preserved. - drop(_guard); - write_omp_provider_entry(provider_key, config, None)?; - Ok(()) - } - } -} - -/// Validate the shape CC Switch can persist as one `models.yml.providers.` node. -/// -/// Both full providers (`models` non-empty) and override-only providers -/// (`models` absent/empty) are accepted; the node must simply be a non-empty -/// object keyed by a non-empty id. -pub(crate) fn validate_provider_node(provider_key: &str, config: &Value) -> Result<(), AppError> { - validation::validate_provider_node(provider_key, config) -} - -pub(crate) fn provider_base_url(config: &Value) -> Result { - let provider = config.as_object().ok_or_else(|| { - AppError::InvalidInput("Oh My Pi provider configuration must be an object".to_string()) - })?; - nonempty_string(provider.get("baseUrl")) - .or_else(|| { - provider - .get("models") - .and_then(Value::as_array) - .and_then(|models| { - models - .iter() - .find_map(|model| nonempty_string(model.get("baseUrl"))) - }) - }) - .map(str::to_string) - .ok_or_else(|| AppError::InvalidInput("Oh My Pi provider has no request URL".to_string())) -} - -fn providers<'a>(document: &'a Value, path: &Path) -> Result<&'a Map, AppError> { - let root = document.as_object().ok_or_else(|| { - AppError::Config(format!( - "Oh My Pi models root must be an object: {}", - path.display() - )) - })?; - match root.get("providers") { - // `providers:` with all entries removed parses as null; treat it as - // empty rather than failing every later read. - None | Some(Value::Null) => Ok(empty_json_object()), - Some(Value::Object(providers)) => Ok(providers), - Some(_) => Err(AppError::Config(format!( - "Oh My Pi models 'providers' must be an object: {}", - path.display() - ))), - } -} - -fn empty_json_object() -> &'static Map { - static EMPTY: LazyLock> = LazyLock::new(Map::new); - &EMPTY -} - -// ============================================================================ -// Settings (config.yml) -// ============================================================================ - -/// Read the full `config.yml` as a JSON value. -fn read_omp_settings() -> Result { - let _guard = lock_files()?; - let path = get_omp_settings_path()?; - if !path.exists() { - return Ok(Value::Object(Map::new())); - } - read_yaml_value(&path, "Oh My Pi settings") -} - -/// Read `modelRoles.default` (the full `/` selector, if set). -pub(crate) fn read_omp_default_model() -> Result, AppError> { - let document = read_omp_settings()?; - let path = get_omp_settings_path()?; - let Some(object) = document.as_object() else { - return Ok(None); - }; - let Some(model_roles) = object.get("modelRoles") else { - return Ok(None); - }; - let Some(roles) = model_roles.as_object() else { - return Ok(None); - }; - optional_string(roles, "default", &path) -} - -/// Split a `/` selector on the first `/` and return the provider id. -pub(crate) fn provider_from_selector(selector: &str) -> &str { - selector - .split_once('/') - .map(|(provider, _)| provider) - .unwrap_or(selector) -} - -// ============================================================================ -// MCP server I/O (mcp.json) -// ============================================================================ - -#[cfg(test)] -pub(crate) fn read_omp_mcp_servers() -> Result, AppError> { - let _guard = lock_files()?; - let path = get_omp_mcp_path()?; - let document = read_document_with_revision(&path, "Oh My Pi MCP", false)?.0; - let root = document.as_object().ok_or_else(|| { - AppError::Config(format!( - "Oh My Pi MCP root must be an object: {}", - path.display() - )) - })?; - match root.get("mcpServers") { - None => Ok(IndexMap::new()), - Some(Value::Object(servers)) => Ok(servers - .iter() - .map(|(key, config)| (key.clone(), config.clone())) - .collect()), - Some(_) => Err(AppError::Config(format!( - "Oh My Pi MCP 'mcpServers' must be an object: {}", - path.display() - ))), - } -} - -/// Mutate the native MCP document while preserving other root keys and checking external writes. -pub(crate) fn update_omp_mcp_document( - update: impl FnOnce(&mut Value) -> Result, -) -> Result<(), AppError> { - let _guard = lock_files()?; - let path = get_omp_mcp_path()?; - let (mut document, expected_revision) = - read_document_with_revision(&path, "Oh My Pi MCP", false)?; - if !document.is_object() { - return Err(AppError::Config(format!( - "Oh My Pi MCP root must be an object: {}", - path.display() - ))); - } - if update(&mut document)? { - write_document(&path, &document, &expected_revision, "Oh My Pi MCP")?; - } - Ok(()) -} - -#[cfg(test)] -pub(crate) fn set_omp_mcp_server(id: &str, config: &Value) -> Result<(), AppError> { - let _guard = lock_files()?; - let path = get_omp_mcp_path()?; - let (mut document, expected_revision) = - read_document_with_revision(&path, "Oh My Pi MCP", false)?; - let root = document.as_object_mut().ok_or_else(|| { - AppError::Config(format!( - "Oh My Pi MCP root must be an object: {}", - path.display() - )) - })?; - let servers = root - .entry("mcpServers".to_string()) - .or_insert_with(|| Value::Object(Map::new())); - let servers = servers.as_object_mut().ok_or_else(|| { - AppError::Config(format!( - "Oh My Pi MCP 'mcpServers' must be an object: {}", - path.display() - )) - })?; - servers.insert(id.to_string(), config.clone()); - write_document(&path, &document, &expected_revision, "Oh My Pi MCP") -} - -#[cfg(test)] -pub(crate) fn remove_omp_mcp_server(id: &str) -> Result<(), AppError> { - let _guard = lock_files()?; - let path = get_omp_mcp_path()?; - let (mut document, expected_revision) = - read_document_with_revision(&path, "Oh My Pi MCP", false)?; - let root = document.as_object_mut().ok_or_else(|| { - AppError::Config(format!( - "Oh My Pi MCP root must be an object: {}", - path.display() - )) - })?; - let Some(servers) = root.get_mut("mcpServers").and_then(Value::as_object_mut) else { - return Ok(()); - }; - servers.remove(id); - write_document(&path, &document, &expected_revision, "Oh My Pi MCP") -} - -#[cfg(test)] -pub(crate) mod test_support { - use std::path::{Path, PathBuf}; - - pub(crate) struct TestAgentDir { - _dir: Option, - previous: Option, - } - - impl TestAgentDir { - pub(crate) fn new() -> Self { - let dir = tempfile::tempdir().expect("create Oh My Pi test directory"); - let agent_dir = dir.path().join("agent"); - Self::set(agent_dir, Some(dir)) - } - - #[allow(dead_code)] // used only by tests; compiled-in outside cfg(test) via shared test_support - pub(crate) fn at(agent_dir: &Path) -> Self { - Self::set(agent_dir.to_path_buf(), None) - } - - fn set(agent_dir: PathBuf, dir: Option) -> Self { - let previous = super::TEST_AGENT_DIR - .lock() - .expect("lock Oh My Pi test directory") - .replace(agent_dir); - Self { - _dir: dir, - previous, - } - } - } - - impl Drop for TestAgentDir { - fn drop(&mut self) { - *super::TEST_AGENT_DIR - .lock() - .expect("lock Oh My Pi test directory") = self.previous.take(); - } - } -} - -#[cfg(test)] -mod tests { - use super::test_support::TestAgentDir; - use super::*; - use serde_json::json; - use serial_test::serial; - - #[test] - fn drop_controlled_keys_keeps_comments_and_unmanaged_fields() { - let passthrough = "# edited comment\nbaseUrl: https://api.duolalab.test/v1 #local\napi: openai-completions\napiKey: sk-live\n# extra unmanaged field\nauth: none\nmodels:\n- id: glm-4.7\n name: GLM 4.7\n"; - let (dropped, comments) = drop_controlled_keys(passthrough); - assert!(dropped.contains("# edited comment"), "{dropped}"); - assert!(dropped.contains("# extra unmanaged field"), "{dropped}"); - assert!(dropped.contains("auth: none"), "{dropped}"); - assert!(!dropped.contains("baseUrl:"), "{dropped}"); - assert!(!dropped.contains("apiKey:"), "{dropped}"); - assert!(!dropped.contains("models:"), "{dropped}"); - assert!(!dropped.contains("glm-4.7"), "{dropped}"); - // Trailing comments on controlled-key lines are captured for the - // composer to re-attach. - assert_eq!(comments, vec![("baseUrl".to_string(), "local".to_string())]); - } - - #[test] - fn native_profile_and_config_root_resolution() { - let home = tempfile::tempdir().expect("home"); - assert_eq!(normalize_profile(Some("").or(Some("work"))).unwrap(), None); - assert_eq!( - normalize_profile(Some("default").or(Some("work"))).unwrap(), - None - ); - assert_eq!( - normalize_profile(None.or(Some("work"))).unwrap().as_deref(), - Some("work") - ); - assert_eq!( - native_agent_dir(home.path(), Some(".custom"), None, None, None).unwrap(), - home.path().join(".custom/agent") - ); - assert_eq!( - native_agent_dir( - home.path(), - Some(".custom"), - Some("work"), - None, - Some(Path::new("ignored")) - ) - .unwrap(), - home.path().join(".custom/profiles/work/agent") - ); - let inherited = home.path().join(".custom/profiles/work/agent"); - assert_eq!( - native_agent_dir( - home.path(), - Some(".custom"), - None, - Some("work"), - Some(&inherited) - ) - .unwrap(), - home.path().join(".custom/agent") - ); - let custom = home.path().join("custom-agent"); - assert_eq!( - native_agent_dir( - home.path(), - Some(".custom"), - None, - Some("work"), - Some(&custom) - ) - .unwrap(), - custom - ); - for invalid in ["../other", "UPPER", "con", "nul.txt", "trailing."] { - assert!(normalize_profile(Some(invalid)).is_err(), "{invalid}"); - } - } - - #[test] - #[cfg(any(target_os = "linux", target_os = "macos"))] - fn xdg_data_root_follows_existing_profile_and_agent_override() { - let home = tempfile::tempdir().expect("home"); - let xdg = tempfile::tempdir().expect("xdg"); - let default = home.path().join(".omp/agent"); - std::fs::create_dir_all(xdg.path().join("omp")).unwrap(); - assert_eq!( - data_dir(&default, &default, None, Some(xdg.path())), - xdg.path().join("omp") - ); - let custom = home.path().join("custom-agent"); - assert_eq!(data_dir(&custom, &default, None, Some(xdg.path())), custom); - let work = home.path().join(".omp/profiles/work/agent"); - assert_eq!(data_dir(&work, &work, Some("work"), Some(xdg.path())), work); - std::fs::create_dir_all(xdg.path().join("omp/profiles/work")).unwrap(); - assert_eq!( - data_dir(&work, &work, Some("work"), Some(xdg.path())), - xdg.path().join("omp/profiles/work") - ); - } - - fn provider() -> Value { - json!({ - "baseUrl": "https://api.example.com/v1", - "api": "openai-completions", - "apiKey": "secret", - "models": [{"id": "example-model", "name": "Example Model"}] - }) - } - - fn override_only_provider() -> Value { - json!({ - "baseUrl": "https://custom-proxy.example.com", - "api": "anthropic-messages", - "apiKey": "proxy-key" - }) - } - - fn write_agent_file(name: &str, content: &str) { - let agent = get_omp_agent_dir().expect("agent dir"); - std::fs::create_dir_all(&agent).expect("create agent dir"); - std::fs::write(agent.join(name), content).expect("write file"); - } - - fn read_agent_file(name: &str) -> String { - let agent = get_omp_agent_dir().expect("agent dir"); - std::fs::read_to_string(agent.join(name)).expect("read file") - } - - /// A comment at the entry's own indent introduces the provider below it; - /// saving one provider must not move it. - #[test] - #[serial] - fn saving_a_provider_leaves_the_next_providers_comment_in_place() { - let _agent = TestAgentDir::new(); - write_agent_file( - "models.yml", - "providers:\n demo:\n baseUrl: https://a.example\n api: openai-completions\n apiKey: k\n # about other\n other:\n baseUrl: https://b.example\n", - ); - let config = read_omp_native_provider("demo") - .expect("read demo") - .expect("demo present"); - let mut edited = config.clone(); - edited["apiKey"] = json!("rotated"); - replace_omp_provider_if_present("demo", &config, &edited, None).expect("save demo"); - - let source = read_agent_file("models.yml"); - assert!( - source.contains(" # about other\n other:"), - "the sibling's comment must stay attached to it:\n{source}" - ); - assert!( - !source.contains(" # about other"), - "the sibling's comment must not be swallowed into demo:\n{source}" - ); - } - - /// Comments that document the entry itself survive a save: leading - /// comments, an inner comment, and the spacing after `#` on a field. - #[test] - #[serial] - fn saving_a_provider_keeps_its_own_comments_and_their_spacing() { - let _agent = TestAgentDir::new(); - write_agent_file( - "models.yml", - "providers:\n demo: # key comment\n # inner note\n baseUrl: https://a.example # endpoint\n api: openai-completions\n apiKey: k # secret\n", - ); - let config = read_omp_native_provider("demo") - .expect("read demo") - .expect("demo present"); - let mut edited = config.clone(); - edited["apiKey"] = json!("rotated"); - let passthrough = read_omp_provider_entry_raw("demo") - .expect("read entry") - .expect("entry present"); - replace_omp_provider_if_present("demo", &config, &edited, Some(&passthrough)) - .expect("save demo"); - - let source = read_agent_file("models.yml"); - for expected in [ - "demo: # key comment", - "# inner note", - "https://a.example # endpoint", - "# secret", - ] { - assert!( - source.contains(expected), - "missing {expected:?} in:\n{source}" - ); - } - // The marker keeps the space the user typed: `# secret`, not `#secret`. - assert!(!source.contains("#secret"), "{source}"); - } - - /// Removing a provider takes the comments that belong to it and leaves the - /// ones that introduce the following provider. - #[test] - #[serial] - fn removal_takes_own_comments_and_keeps_the_siblings() { - let _agent = TestAgentDir::new(); - write_agent_file( - "models.yml", - "providers:\n # demo docs\n demo:\n baseUrl: https://a.example\n # inside demo\n # about other\n other:\n baseUrl: https://b.example\n", - ); - assert!(remove_omp_provider("demo").expect("remove").is_some()); - - let source = read_agent_file("models.yml"); - assert!(!source.contains("# demo docs"), "{source}"); - assert!(!source.contains("# inside demo"), "{source}"); - assert!( - source.contains(" # about other\n other:"), - "the surviving provider keeps its comment:\n{source}" - ); - } - - #[test] - #[serial] - fn provider_crud_round_trip() { - let _agent = TestAgentDir::new(); - let config = provider(); - - assert!(insert_omp_provider("example", &config).expect("insert")); - assert!(!insert_omp_provider("example", &config).expect("identical insert no-op")); - - let providers = read_omp_native_providers().expect("read providers"); - assert_eq!(providers.len(), 1); - assert_eq!(providers.get("example"), Some(&config)); - - let mut updated = config.clone(); - updated["apiKey"] = json!("new-secret"); - replace_omp_provider("example", &config, &updated).expect("replace"); - assert_eq!( - read_omp_native_provider("example").expect("read provider"), - Some(updated.clone()) - ); - - let removed = remove_omp_provider("example").expect("remove"); - assert_eq!(removed, Some(updated)); - assert!(!omp_provider_exists("example").expect("exists")); - } - - /// A key `serde_yaml` chooses to quote (`'true':`) must still be found, or - /// the removal silently no-ops and the update appends a duplicate key. - #[test] - #[serial] - fn quoted_provider_key_is_found_for_remove_and_replace() { - let _agent = TestAgentDir::new(); - // Written by an earlier insert: serde_yaml quotes a key whose plain - // form would not round-trip. - write_agent_file( - "models.yml", - "providers:\n peer:\n baseUrl: https://peer/v1\n api: openai-completions\n apiKey: p\n", - ); - let config = provider(); - write_agent_file( - "models.yml", - &format!( - "providers:\n{}\n peer:\n baseUrl: https://peer/v1\n api: openai-completions\n apiKey: p\n", - compose_provider_entry("true", &config, None).expect("compose") - ), - ); - assert!(read_omp_provider_entry_raw("true").expect("read").is_some()); - - let mut updated = config.clone(); - updated["apiKey"] = json!("rotated"); - replace_omp_provider("true", &config, &updated).expect("replace quoted key"); - assert_eq!( - read_omp_native_provider("true").expect("read"), - Some(updated.clone()) - ); - // The sibling must still be there: a text-prefix match used to append a - // duplicate key instead, which left the file unparsable for omp. - assert!(read_omp_native_provider("peer") - .expect("read peer") - .is_some()); - - assert_eq!( - remove_omp_provider("true").expect("remove quoted key"), - Some(updated) - ); - assert!(!omp_provider_exists("true").expect("exists")); - assert!(omp_provider_exists("peer").expect("peer survives")); - } - - /// An anchor defined on one provider and referenced by another cannot be - /// spliced: removing the definer would leave a dangling alias, and omp then - /// disables every custom provider. The edit must be refused, not written. - #[test] - #[serial] - fn anchored_provider_file_is_refused_instead_of_corrupted() { - let _agent = TestAgentDir::new(); - let raw = "providers:\n base:\n baseUrl: &b https://base/v1\n api: openai-completions\n apiKey: k\n other:\n baseUrl: *b\n api: openai-completions\n apiKey: k\n"; - write_agent_file("models.yml", raw); - let before = read_agent_file("models.yml"); - - let error = remove_omp_provider("base").expect_err("alias must be refused"); - assert!( - error.to_string().contains("base"), - "error should name the provider: {error}" - ); - assert_eq!( - read_agent_file("models.yml"), - before, - "a refused removal must leave the file untouched" - ); - assert!(omp_provider_exists("base").expect("base survives")); - assert!(omp_provider_exists("other").expect("other survives")); - } - - #[test] - #[serial] - fn override_only_provider_is_accepted() { - let _agent = TestAgentDir::new(); - let config = override_only_provider(); - assert!(insert_omp_provider("proxy", &config).expect("insert override-only")); - let providers = read_omp_native_providers().expect("read providers"); - assert_eq!(providers.get("proxy"), Some(&config)); - } - - #[test] - #[serial] - fn controlled_key_trailing_comment_survives_save() { - let _agent = TestAgentDir::new(); - write_agent_file( - "models.yml", - "providers:\n llama.cpp:\n api: openai-responses\n name: llama.cpp\n baseUrl: http://127.0.0.1:8080\n auth: none #ab\n discovery: #ab\n type: llama.cpp\n", - ); - let config = json!({ - "api": "openai-responses", - "name": "llama.cpp", - "baseUrl": "http://127.0.0.1:8080", - "auth": "none", - "discovery": {"type": "llama.cpp"} - }); - // Editor text mirrors the file plus a new trailing comment on `api`. - let passthrough = "api: openai-responses #asd\nname: llama.cpp\nbaseUrl: http://127.0.0.1:8080\nauth: none #ab\ndiscovery: #ab\n type: llama.cpp"; - assert!( - write_omp_provider_entry("llama.cpp", &config, Some(passthrough)).expect("write"), - "must not be a no-op" - ); - let source = read_agent_file("models.yml"); - assert!(source.contains("api: openai-responses #asd"), "{source}"); - assert!(source.contains("auth: none #ab"), "{source}"); - assert_eq!( - read_omp_native_provider("llama.cpp").expect("read"), - Some(config) - ); - } - - #[test] - #[serial] - fn key_line_comment_round_trips_through_raw_editor() { - let _agent = TestAgentDir::new(); - write_agent_file( - "models.yml", - "providers:\n example: #local\n baseUrl: https://a.example\n", - ); - - // The key line never reaches the editor text. - let raw = read_omp_provider_entry_raw("example") - .expect("read raw") - .expect("entry present"); - assert!(!raw.contains("example:"), "{raw}"); - assert_eq!(raw, "baseUrl: https://a.example", "{raw}"); - - // Saving edited fields keeps the key line comment from the file. - let config = json!({"baseUrl": "https://b.example"}); - let edited = format!("{raw}\n"); - assert!( - write_omp_provider_entry("example", &config, Some(&edited)).expect("text-level write") - ); - - let source = read_agent_file("models.yml"); - assert!(source.contains("example: #local"), "{source}"); - assert!(source.contains("https://b.example"), "{source}"); - assert_eq!( - read_omp_native_provider("example").expect("read"), - Some(config.clone()) - ); - - // Removing the comment in the file and saving again drops it. - write_agent_file( - "models.yml", - "providers:\n example:\n baseUrl: https://b.example\n", - ); - let changed_config = json!({"baseUrl": "https://c.example"}); - assert!( - write_omp_provider_entry("example", &changed_config, Some(&edited)) - .expect("write again") - ); - assert!( - !read_agent_file("models.yml").contains("example: #"), - "{}", - read_agent_file("models.yml") - ); - } - - #[test] - #[serial] - fn comment_only_edit_reaches_file_despite_unchanged_config() { - let _agent = TestAgentDir::new(); - let config = json!({"baseUrl": "https://a.example", "auth": "none"}); - insert_omp_provider("example", &config).expect("insert"); - - // User only adds a comment in the editor: parsed config is unchanged, - // but the new comment must reach the file. - let passthrough = "# my note\nauth: none\n"; - let previous = - replace_omp_provider_if_present("example", &config, &config, Some(passthrough)) - .expect("replace") - .expect("present"); - assert_eq!(previous, config); - - let source = read_agent_file("models.yml"); - assert!(source.contains("# my note"), "{source}"); - assert_eq!( - read_omp_native_provider("example").expect("read"), - Some(config) - ); - } - - #[test] - #[serial] - fn text_level_edit_preserves_comments_and_sibling_entries() { - let _agent = TestAgentDir::new(); - write_agent_file( - "models.yml", - "# top comment\nproviders:\n # sibling comment\n existing:\n baseUrl: https://a.example\n compat:\n supportsStore: true\n", - ); - - let mut config = provider(); - config["auth"] = json!("none"); - let passthrough = "# unmanaged key\nauth: none\n"; - assert!( - write_omp_provider_entry("example", &config, Some(passthrough)) - .expect("text-level write") - ); - - let source = read_agent_file("models.yml"); - assert!(source.contains("# top comment"), "{source}"); - assert!(source.contains("# sibling comment"), "{source}"); - assert!(source.contains("# unmanaged key"), "{source}"); - assert!(source.contains("supportsStore: true"), "{source}"); - assert!(source.contains("auth: none"), "{source}"); - - // Parsed content must round-trip the full config object. - let providers = read_omp_native_providers().expect("read providers"); - assert_eq!(providers.get("example"), Some(&config)); - assert!(providers.contains_key("existing")); - - // Raw entry (for the editor) keeps the comment. - let raw = read_omp_provider_entry_raw("example") - .expect("read raw entry") - .expect("entry present"); - assert!(raw.contains("# unmanaged key"), "{raw}"); - - // Re-writing with the same passthrough is a no-op. - assert!( - !write_omp_provider_entry("example", &config, Some(passthrough)) - .expect("idempotent write") - ); - } - - #[test] - #[serial] - fn read_default_model_and_selector_split() { - let _agent = TestAgentDir::new(); - write_agent_file("config.yml", "modelRoles:\n smol: openai/gpt-4o-mini\n"); - - assert_eq!(read_omp_default_model().expect("read default"), None); - assert_eq!(provider_from_selector("example/example-model"), "example"); - } - - #[test] - #[serial] - fn mcp_server_io() { - let _agent = TestAgentDir::new(); - let spec = json!({ - "type": "stdio", - "command": "npx", - "args": ["-y", "@modelcontextprotocol/server"], - "env": {"API_KEY": "secret"} - }); - set_omp_mcp_server("filesystem", &spec).expect("set server"); - let servers = read_omp_mcp_servers().expect("read servers"); - assert_eq!(servers.get("filesystem"), Some(&spec)); - - remove_omp_mcp_server("filesystem").expect("remove server"); - assert!(read_omp_mcp_servers().expect("read servers").is_empty()); - } - - #[test] - #[serial] - fn unmanaged_keys_preserved_after_provider_edit() { - let _agent = TestAgentDir::new(); - write_agent_file( - "models.yml", - "providers:\n existing:\n baseUrl: https://a.example\n compat:\n supportsStore: true\n", - ); - - let config = provider(); - insert_omp_provider("example", &config).expect("insert second provider"); - - let source = read_agent_file("models.yml"); - assert!(source.contains("supportsStore: true")); - assert!(source.contains("example")); - assert!(source.contains("existing")); - } - - #[test] - #[serial] - fn provider_base_url_falls_back_to_model_url() { - let config = json!({ - "api": "openai-completions", - "models": [{"id": "m", "baseUrl": "https://model.example"}] - }); - assert_eq!( - provider_base_url(&config).expect("base url"), - "https://model.example" - ); - } -} diff --git a/src-tauri/src/omp_config/validation.rs b/src-tauri/src/omp_config/validation.rs deleted file mode 100644 index 7edd56088..000000000 --- a/src-tauri/src/omp_config/validation.rs +++ /dev/null @@ -1,608 +0,0 @@ -//! Known `models.yml` fields and business rules from omp v18.8.7. -//! Unknown fields are intentionally retained for newer native releases. - -use crate::error::AppError; -use serde_json::{Map, Value}; - -const APIS: &[&str] = &[ - "openai-completions", - "openai-responses", - "openai-codex-responses", - "azure-openai-responses", - "anthropic-messages", - "bedrock-converse-stream", - "google-generative-ai", - "google-gemini-cli", - "google-vertex", - "openrouter-decisions", - "typesafe", -]; -const RUNNERS: &[&str] = &[ - "openai-images", - "openrouter-images", - "xai-tts", - "openai-speech", - "openai-embeddings", - "openrouter-rerank", - "openrouter-video", - "openai-transcriptions", -]; -const EFFORTS: &[&str] = &["minimal", "low", "medium", "high", "xhigh", "max"]; - -fn invalid(path: &str, expected: &str) -> AppError { - AppError::InvalidInput(format!("Oh My Pi {path}: expected {expected}")) -} - -fn object<'a>(value: &'a Value, path: &str) -> Result<&'a Map, AppError> { - value.as_object().ok_or_else(|| invalid(path, "an object")) -} - -fn fields( - value: &Map, - path: &str, - strings: &[&str], - bools: &[&str], - numbers: &[&str], -) -> Result<(), AppError> { - for (keys, predicate, expected) in [ - (strings, Value::is_string as fn(&Value) -> bool, "a string"), - (bools, Value::is_boolean as fn(&Value) -> bool, "a boolean"), - (numbers, Value::is_number as fn(&Value) -> bool, "a number"), - ] { - for key in keys { - if value.get(*key).is_some_and(|entry| !predicate(entry)) { - return Err(invalid(&format!("{path}.{key}"), expected)); - } - } - } - Ok(()) -} - -fn nonempty(value: &Map, path: &str, keys: &[&str]) -> Result<(), AppError> { - fields(value, path, keys, &[], &[])?; - for key in keys { - if value.get(*key).and_then(Value::as_str) == Some("") { - return Err(invalid(&format!("{path}.{key}"), "a non-empty string")); - } - } - Ok(()) -} - -fn enumeration( - value: &Map, - path: &str, - key: &str, - allowed: &[&str], -) -> Result<(), AppError> { - if let Some(entry) = value.get(key) { - if !entry.as_str().is_some_and(|text| allowed.contains(&text)) { - return Err(invalid(&format!("{path}.{key}"), &allowed.join(" | "))); - } - } - Ok(()) -} - -fn string_map(value: &Value, path: &str) -> Result<(), AppError> { - for (key, entry) in object(value, path)? { - if !entry.is_string() { - return Err(invalid(&format!("{path}.{key}"), "a string")); - } - } - Ok(()) -} - -fn string_array(value: &Value, path: &str, allowed: &[&str]) -> Result<(), AppError> { - let entries = value.as_array().ok_or_else(|| invalid(path, "an array"))?; - for entry in entries { - if !entry - .as_str() - .is_some_and(|text| allowed.is_empty() || allowed.contains(&text)) - { - return Err(invalid(path, "an array of allowed strings")); - } - } - Ok(()) -} - -fn compat(value: &Value, path: &str, when_thinking: bool) -> Result<(), AppError> { - let value = object(value, path)?; - fields( - value, - path, - &[], - &[ - "supportsStore", - "supportsDeveloperRole", - "supportsMultipleSystemMessages", - "supportsReasoningEffort", - "supportsUsageInStreaming", - "requiresToolResultName", - "requiresMistralToolIds", - "requiresAssistantAfterToolResult", - "requiresThinkingAsText", - "requiresReasoningContentForToolCalls", - "allowsSyntheticReasoningContentForToolCalls", - "requiresAssistantContentForToolCalls", - "supportsToolChoice", - "supportsForcedToolChoice", - "disableReasoningOnForcedToolChoice", - "disableReasoningOnToolChoice", - "disableReasoningWithTools", - "qwenTemplateReasoningEffort", - "supportsStrictMode", - "supportsLongPromptCacheRetention", - "supportsReasoningParams", - "supportsReasoningSummary", - "statefulResponses", - "alwaysSendMaxTokens", - "strictResponsesPairing", - "supportsImageDetailOriginal", - "supportsConfigurationUpdate", - "supportsSteering", - "stripImageInput", - "supportsContextManagement", - "supportsEagerToolInputStreaming", - "allowAnthropicHeaderOverrides", - "requiresToolResultId", - "replayUnsignedThinking", - "bedrockMessagesApi", - ], - &[ - "streamIdleTimeoutMs", - "promptCacheMinimumTokens", - "promptCacheMaximumCheckpoints", - ], - )?; - for (key, allowed) in [ - ( - "thinkingFormat", - &["openai", "openrouter", "zai", "qwen", "qwen-chat-template"][..], - ), - ( - "maxTokensField", - &["max_completion_tokens", "max_tokens"][..], - ), - ( - "reasoningContentField", - &["reasoning_content", "reasoning", "reasoning_text"][..], - ), - ("cacheControlFormat", &["anthropic"][..]), - ("toolStrictMode", &["all_strict", "none"][..]), - ( - "streamMarkupHealingPattern", - &["kimi", "dsml", "qwen", "thinking"][..], - ), - ("promptCacheMode", &["none", "automatic", "explicit"][..]), - ] { - enumeration(value, path, key, allowed)?; - } - for key in [ - "streamIdleTimeoutMs", - "promptCacheMinimumTokens", - "promptCacheMaximumCheckpoints", - ] { - if value - .get(key) - .and_then(Value::as_f64) - .is_some_and(|number| number < 0.0) - { - return Err(invalid(&format!("{path}.{key}"), "a non-negative number")); - } - } - if let Some(map) = value.get("reasoningEffortMap") { - fields(object(map, path)?, path, EFFORTS, &[], &[])?; - } - for key in ["openRouterRouting", "vercelGatewayRouting"] { - if let Some(routing) = value.get(key) { - for (field, entry) in object(routing, path)? { - if matches!(field.as_str(), "only" | "order") { - string_array(entry, path, &[])?; - } - } - } - } - if let Some(extra) = value.get("extraBody") { - object(extra, path)?; - } - if !when_thinking { - if let Some(thinking) = value.get("whenThinking") { - compat(thinking, &format!("{path}.whenThinking"), true)?; - } - } - Ok(()) -} - -fn thinking(value: &Value, path: &str) -> Result<(), AppError> { - let value = object(value, path)?; - if !value.contains_key("mode") { - return Err(invalid(path, "thinking.mode")); - } - enumeration( - value, - path, - "mode", - &[ - "effort", - "budget", - "google-level", - "anthropic-adaptive", - "anthropic-budget-effort", - ], - )?; - fields( - value, - path, - &[], - &["supportsDisplay", "requiresEffort"], - &[], - )?; - for key in ["defaultLevel", "minLevel", "maxLevel"] { - enumeration(value, path, key, EFFORTS)?; - } - for key in ["efforts", "levels"] { - if let Some(entry) = value.get(key) { - string_array(entry, &format!("{path}.{key}"), EFFORTS)?; - } - } - if let Some(map) = value.get("effortMap") { - fields(object(map, path)?, path, EFFORTS, &[], &[])?; - } - if !(value.contains_key("efforts") - || value.contains_key("levels") - || value.contains_key("minLevel") && value.contains_key("maxLevel")) - { - return Err(invalid(path, "efforts, levels, or minLevel and maxLevel")); - } - Ok(()) -} - -fn remote_compaction(value: &Value, path: &str) -> Result<(), AppError> { - let value = object(value, path)?; - fields(value, path, &[], &["enabled", "v2StreamingEnabled"], &[])?; - nonempty( - value, - path, - &["endpoint", "model", "v2Endpoint", "streamingEndpoint"], - )?; - enumeration(value, path, "api", APIS) -} - -fn kind_matches_api( - value: &Map, - inherited_api: Option<&str>, - path: &str, -) -> Result<(), AppError> { - let Some(kind) = value.get("kind").and_then(Value::as_str) else { - return Ok(()); - }; - let Some(api) = value.get("api").and_then(Value::as_str).or(inherited_api) else { - return Ok(()); - }; - let allowed = match api { - "typesafe" | "openrouter-decisions" => &["judge"][..], - "openai-images" | "openrouter-images" => &["image"][..], - "xai-tts" | "openai-speech" => &["tts"][..], - "openai-embeddings" => &["embedding"][..], - "openrouter-rerank" => &["rerank"][..], - "openrouter-video" => &["video"][..], - "openai-transcriptions" => &["stt"][..], - "google-generative-ai" - | "google-gemini-cli" - | "openai-responses" - | "openai-codex-responses" => &["chat", "tiny", "image"][..], - _ => &["chat", "tiny"][..], - }; - if !allowed.contains(&kind) { - return Err(invalid(path, "kind matching the selected api")); - } - Ok(()) -} - -fn model(value: &Value, path: &str, definition: bool) -> Result<(), AppError> { - let value = object(value, path)?; - nonempty( - value, - path, - &["name", "contextPromotionTarget", "compactionModel"], - )?; - fields( - value, - path, - &[], - &[ - "reasoning", - "supportsTools", - "omitMaxOutputTokens", - "preferWebsockets", - ], - &[ - "premiumMultiplier", - "contextWindow", - "maxContextWindow", - "maxTokens", - ], - )?; - let model_apis: Vec<_> = APIS.iter().chain(RUNNERS).copied().collect(); - enumeration(value, path, "api", &model_apis)?; - enumeration( - value, - path, - "kind", - &[ - "chat", - "tiny", - "image", - "tts", - "stt", - "judge", - "embedding", - "rerank", - "video", - ], - )?; - enumeration(value, path, "imageInputDecoder", &["stb"])?; - enumeration( - value, - path, - "tokenizer", - &[ - "claude-v3", - "claude-v47", - "claude-v5", - "claude-v5-sonnet", - "qwen3", - "deepseek-v3", - "kimi-k2", - "glm5", - ], - )?; - if definition { - if !value.contains_key("id") { - return Err(invalid(path, "a model id")); - } - nonempty(value, path, &["id", "baseUrl"])?; - for key in ["contextWindow", "maxTokens"] { - if value - .get(key) - .and_then(Value::as_f64) - .is_some_and(|number| number <= 0.0) - { - return Err(invalid(&format!("{path}.{key}"), "a positive number")); - } - } - } - if let Some(number) = value.get("maxContextWindow").and_then(Value::as_f64) { - if number <= 0.0 - || number.fract() != 0.0 - || number > 9_007_199_254_740_991.0 - || value - .get("contextWindow") - .and_then(Value::as_f64) - .is_some_and(|context| number < context) - { - return Err(invalid( - &format!("{path}.maxContextWindow"), - "a positive safe integer no smaller than contextWindow", - )); - } - } - for (key, entry) in value { - let nested = format!("{path}.{key}"); - match key.as_str() { - "input" => string_array(entry, &nested, &["text", "image"])?, - "thinking" => thinking(entry, &nested)?, - "headers" => string_map(entry, &nested)?, - "compat" => compat(entry, &nested, false)?, - "remoteCompaction" => remote_compaction(entry, &nested)?, - "cost" => { - let cost = object(entry, &nested)?; - let keys = &["input", "output", "cacheRead", "cacheWrite"]; - fields(cost, &nested, &[], &[], keys)?; - if definition && keys.iter().any(|key| !cost.contains_key(*key)) { - return Err(invalid( - &nested, - "input, output, cacheRead and cacheWrite prices", - )); - } - } - "promptCache" => fields( - object(entry, &nested)?, - &nested, - &[], - &[], - &["short", "long"], - )?, - _ => {} - } - } - Ok(()) -} - -pub(super) fn validate_provider_node(provider_key: &str, config: &Value) -> Result<(), AppError> { - if provider_key.trim().is_empty() { - return Err(invalid("provider key", "a non-empty string")); - } - let path = format!("providers.{provider_key}"); - let provider = object(config, &path)?; - nonempty(provider, &path, &["baseUrl", "apiKey"])?; - fields( - provider, - &path, - &["guardrailIdentifier", "guardrailVersion"], - &["authHeader", "disableStrictTools"], - &[], - )?; - enumeration(provider, &path, "api", APIS)?; - enumeration(provider, &path, "auth", &["apiKey", "none", "oauth"])?; - enumeration(provider, &path, "transport", &["pi-native"])?; - enumeration( - provider, - &path, - "guardrailTrace", - &["enabled", "disabled", "enabled_full"], - )?; - for (key, entry) in provider { - let nested = format!("{path}.{key}"); - match key.as_str() { - "headers" | "requestMetadata" => string_map(entry, &nested)?, - "compat" => compat(entry, &nested, false)?, - "remoteCompaction" => remote_compaction(entry, &nested)?, - "discovery" => { - let discovery = object(entry, &nested)?; - if !discovery.contains_key("type") { - return Err(invalid(&nested, "a discovery type")); - } - enumeration( - discovery, - &nested, - "type", - &[ - "ollama", - "llama.cpp", - "lm-studio", - "openai-models-list", - "proxy", - "litellm", - "apple-foundation-models", - ], - )?; - fields(discovery, &nested, &[], &["injectV1"], &["timeoutMs"])?; - if discovery - .get("timeoutMs") - .and_then(Value::as_f64) - .is_some_and(|number| number <= 0.0) - { - return Err(invalid(&nested, "a positive timeoutMs")); - } - if discovery.contains_key("injectV1") - && discovery.get("type").and_then(Value::as_str) != Some("openai-models-list") - { - return Err(invalid( - &nested, - "injectV1 only on openai-models-list discovery", - )); - } - if !provider.contains_key("api") - && discovery.get("type").and_then(Value::as_str) != Some("proxy") - { - return Err(invalid(&path, "api when discovery is enabled")); - } - } - _ => {} - } - } - let models = match provider.get("models") { - Some(value) => value - .as_array() - .ok_or_else(|| invalid(&format!("{path}.models"), "an array"))? - .as_slice(), - None => &[], - }; - let api = provider.get("api").and_then(Value::as_str); - for (index, entry) in models.iter().enumerate() { - let nested = format!("{path}.models[{index}]"); - model(entry, &nested, true)?; - let entry = object(entry, &nested)?; - if api.is_none() && !entry.contains_key("api") { - return Err(invalid(&nested, "api at provider or model level")); - } - kind_matches_api(entry, api, &nested)?; - } - let overrides = provider - .get("modelOverrides") - .map(|entry| object(entry, &format!("{path}.modelOverrides"))) - .transpose()?; - if let Some(overrides) = overrides { - for (id, entry) in overrides { - let nested = format!("{path}.modelOverrides.{id}"); - model(entry, &nested, false)?; - let declared = models - .iter() - .find(|entry| entry.get("id").and_then(Value::as_str) == Some(id)); - let declared_api = declared - .and_then(|entry| entry.get("api")) - .and_then(Value::as_str) - .or(if declared.is_some() { api } else { None }); - kind_matches_api(object(entry, &nested)?, declared_api, &nested)?; - } - } - if !models.is_empty() { - if !provider.contains_key("baseUrl") { - return Err(invalid(&path, "baseUrl when defining custom models")); - } - if !provider.contains_key("apiKey") - && !matches!( - provider.get("auth").and_then(Value::as_str), - Some("none" | "oauth") - ) - { - return Err(invalid(&path, "apiKey unless auth is none or oauth")); - } - } else { - let has_override = [ - "baseUrl", - "headers", - "compat", - "apiKey", - "requestMetadata", - "remoteCompaction", - "discovery", - ] - .iter() - .any(|key| provider.contains_key(*key)) - || provider.get("auth").and_then(Value::as_str) == Some("none") - || provider.get("disableStrictTools").and_then(Value::as_bool) == Some(true) - || provider - .get("guardrailIdentifier") - .and_then(Value::as_str) - .is_some_and(|text| !text.is_empty()) - || overrides.is_some_and(|entries| !entries.is_empty()); - if !has_override { - return Err(invalid( - &path, - "models, discovery, or a native provider override", - )); - } - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[test] - fn rejects_invalid_native_fields_without_rejecting_sparse_or_future_fields() { - for config in [ - json!({"name": "Display name"}), - json!({"baseUrl": "https://test", "compat": {"thinkingFormat": "deepseek"}}), - json!({"baseUrl": "https://test", "api": "invalid"}), - json!({"baseUrl": "https://test", "headers": {"x-header": 1}}), - json!({"baseUrl": "https://test", "modelOverrides": {"m": {"thinking": "high"}}}), - json!({"baseUrl": "https://test", "modelOverrides": {"m": {"thinking": {"mode": "effort"}}}}), - json!({"api": "openai-completions", "auth": "none", "models": [{"id": "m"}]}), - json!({"baseUrl": "https://test", "api": "openai-completions", "models": [{"id": "m"}]}), - json!({"baseUrl": "https://test", "api": "openai-completions", "auth": "none", "models": [{"id": "m", "cost": {"input": 0.15, "output": 0.25}}]}), - json!({"baseUrl": "https://test", "api": "openai-completions", "auth": "none", "models": [{"id": "m", "kind": "tts"}]}), - json!({"baseUrl": "https://test", "discovery": {"type": "ollama"}}), - ] { - assert!( - validate_provider_node("test", &config).is_err(), - "accepted {config}" - ); - } - for config in [ - json!({"baseUrl": "https://proxy", "apiKey": "test"}), - json!({"compat": {"supportsStore": false, "futureCompat": {"enabled": true}}, "futureProvider": [1,2]}), - json!({"modelOverrides": {"m": {"cost": {"input": 0.15}, "futureModel": true}}}), - json!({"baseUrl": "https://test", "auth": "none", "models": [{"id": "m", "api": "openai-images", "kind": "image", "cost": {"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0}, "thinking": {"mode": "effort", "minLevel": "low", "maxLevel": "high"}}]}), - json!({"discovery": {"type": "proxy"}}), - ] { - assert!( - validate_provider_node("test", &config).is_ok(), - "rejected {config}" - ); - } - } -} diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index 4263b24e2..8c68b4a0e 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -622,14 +622,6 @@ pub struct ProviderMeta { skip_serializing_if = "Option::is_none" )] pub stack_models: Option>, - /// Oh My Pi:编辑器里“其他字段(YAML,原样保留)”的原文(含注释与顺序)。 - /// 仅存于数据库,写 models.yml 时用于文本级定点替换,不参与其他应用。 - #[serde( - rename = "ompPassthroughYaml", - default, - skip_serializing_if = "Option::is_none" - )] - pub omp_passthrough_yaml: Option, } /// Stack 模式下 Claude Code 供应商发布的一个模型。 diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index d23fe2503..76d7ead43 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -12,7 +12,6 @@ pub mod mcp; pub mod model_fetch; pub mod model_pricing; pub mod omo; -pub(crate) mod omp_state; pub mod pi_prompt_files; pub(crate) mod pi_state; pub mod profile; diff --git a/src-tauri/src/services/omp_state.rs b/src-tauri/src/services/omp_state.rs deleted file mode 100644 index cc98d516e..000000000 --- a/src-tauri/src/services/omp_state.rs +++ /dev/null @@ -1,100 +0,0 @@ -//! Read-only Oh My Pi provider membership and global default reference. - -use crate::error::AppError; -use crate::omp_config::{ - provider_from_selector, read_omp_default_model, read_omp_native_providers, -}; -use crate::store::AppState; -use serde::Serialize; - -const OMP_APP: &str = "omp"; - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct OmpCurrentState { - pub enabled_provider_ids: Vec, - pub default_provider_id: Option, -} - -pub(crate) struct OmpStateService; - -impl OmpStateService { - pub(crate) fn current(state: &AppState) -> Result { - let _guard = futures::executor::block_on(state.proxy_service.lock_switch_for_app(OMP_APP)); - let native = read_omp_native_providers()?; - let enabled_provider_ids = native.keys().cloned().collect::>(); - // `modelRoles.default` is a `/` selector. The provider - // prefix is exposed even when the provider has no explicit node in - // `models.yml` (built-in model activation). - let default_provider_id = match read_omp_default_model() { - Ok(Some(selector)) => Some(provider_from_selector(&selector).to_string()), - Ok(None) => None, - Err(error) => { - log::warn!("Failed to read Oh My Pi default model: {error}"); - None - } - }; - Ok(OmpCurrentState { - enabled_provider_ids, - default_provider_id, - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::database::Database; - use crate::omp_config::test_support::TestAgentDir; - use serial_test::serial; - use std::fs; - use std::sync::Arc; - - #[test] - #[serial] - fn state_exposes_explicit_providers_and_default() { - let _agent = TestAgentDir::new(); - let state = AppState::new(Arc::new( - Database::memory().expect("create in-memory database"), - )); - let models_path = crate::omp_config::get_omp_models_path().expect("models path"); - fs::create_dir_all(models_path.parent().expect("models directory")) - .expect("create models directory"); - fs::write( - models_path, - "providers:\n managed:\n baseUrl: https://api.example.com/v1\n api: openai-completions\n models:\n - id: model-a\n native:\n baseUrl: https://native.example\n", - ) - .expect("write models"); - let settings_path = crate::omp_config::get_omp_settings_path().expect("settings path"); - fs::write(settings_path, "modelRoles:\n default: managed/model-a\n") - .expect("write settings"); - - let current = OmpStateService::current(&state).expect("read state"); - assert_eq!( - current.enabled_provider_ids, - vec!["managed".to_string(), "native".to_string()] - ); - assert_eq!(current.default_provider_id.as_deref(), Some("managed")); - } - - #[test] - #[serial] - fn built_in_default_without_explicit_node_is_tolerated() { - let _agent = TestAgentDir::new(); - let state = AppState::new(Arc::new( - Database::memory().expect("create in-memory database"), - )); - let models_path = crate::omp_config::get_omp_models_path().expect("models path"); - fs::create_dir_all(models_path.parent().expect("models directory")) - .expect("create models directory"); - fs::write(models_path, "providers: {}\n").expect("write empty models"); - let settings_path = crate::omp_config::get_omp_settings_path().expect("settings path"); - fs::write(settings_path, "modelRoles:\n default: openai/gpt-4o\n") - .expect("write settings"); - - let current = OmpStateService::current(&state).expect("read state"); - assert!(current.enabled_provider_ids.is_empty()); - // modelRoles.default references a built-in provider with no explicit node. - assert_eq!(current.default_provider_id.as_deref(), Some("openai")); - } -} diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index f6faad4ef..157f1818a 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -35,7 +35,7 @@ pub(crate) fn provider_exists_in_live_config( } AppType::Mcode => crate::mcode_config::get_providers() .map(|providers| providers.contains_key(provider_id)), - AppType::Omp => crate::omp_config::omp_provider_exists(provider_id), + AppType::Omp => Ok(crate::omp_config::get_providers()?.contains_key(provider_id)), _ => Ok(false), } } diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index cb3c79691..7b64d62a3 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -6476,18 +6476,11 @@ impl ProviderService { pub(crate) fn update_pi_usage_script( state: &AppState, + app_type: AppType, id: &str, script: crate::provider::UsageScript, ) -> Result { - pi::update_usage_script(state, id, script) - } - - pub(crate) fn update_omp_usage_script( - state: &AppState, - id: &str, - script: crate::provider::UsageScript, - ) -> Result { - omp::update_usage_script(state, id, script) + pi::update_usage_script(state, app_type, id, script) } /// Delete a provider @@ -8011,7 +8004,7 @@ impl ProviderService { crate::pi_config::validate_provider_node(&provider.id, &provider.settings_config)?; } AppType::Omp => { - crate::omp_config::validate_provider_node(&provider.id, &provider.settings_config)?; + crate::omp_config::validate_provider(&provider.id, &provider.settings_config)?; } } diff --git a/src-tauri/src/services/provider/omp.rs b/src-tauri/src/services/provider/omp.rs index fc242fc3c..590ed964a 100644 --- a/src-tauri/src/services/provider/omp.rs +++ b/src-tauri/src/services/provider/omp.rs @@ -1,325 +1,295 @@ use super::{ProviderService, SwitchResult}; -use crate::app_config::AppType; -use crate::error::AppError; -use crate::provider::{Provider, ProviderMeta, UsageScript}; -use crate::store::AppState; +use crate::{ + app_config::AppType, + error::AppError, + omp_config::{self, Change}, + provider::{Provider, ProviderMeta}, + store::AppState, +}; use indexmap::IndexMap; -use serde_json::Value; -const OMP_APP: &str = "omp"; +fn prepare(provider: &mut Provider) -> Result<(), AppError> { + omp_config::validate_provider(&provider.id, &provider.settings_config)?; + provider.in_failover_queue = false; + provider.meta = provider.meta.take().map(|meta| ProviderMeta { + usage_script: meta.usage_script, + is_partner: meta.is_partner, + partner_promotion_key: meta.partner_promotion_key, + ..Default::default() + }); + Ok(()) +} pub(super) fn list(state: &AppState) -> Result, AppError> { - let _guard = futures::executor::block_on(state.proxy_service.lock_switch_for_app(OMP_APP)); - match crate::omp_config::read_omp_native_providers() { - Ok(native) => { - if let Err(error) = sync_native_locked(state, &native) { - log::warn!("Failed to sync Oh My Pi providers from native config: {error}"); - } - } - Err(error) => { - log::warn!("Failed to read Oh My Pi providers; showing saved catalog: {error}"); - } + let _guard = futures::executor::block_on(state.proxy_service.lock_switch_for_app("omp")); + if let Err(error) = sync_native(state) { + log::warn!("Failed to read Oh My Pi providers; showing saved catalog: {error}"); } - let mut providers = state.db.get_all_providers(OMP_APP)?; - // Show the raw models.yml entry (comments included) in the YAML editor. - // models.yml is the source of truth: re-read on every list so external - // edits to an entry (comments, formatting, unmanaged fields) reach the - // editor; the stored snapshot is only a fallback when the file or the - // entry cannot be read. - let ids: Vec = providers.keys().cloned().collect(); - match crate::omp_config::read_omp_provider_entries_raw(&ids) { - Ok(entries) => { - for (id, provider) in providers.iter_mut() { - if let Some(text) = entries.get(id) { - let meta = provider.meta.get_or_insert_with(ProviderMeta::default); - meta.omp_passthrough_yaml = Some(text.clone()); - } - // Entry absent from models.yml (not yet written to live): - // keep the stored snapshot so pending editor text survives. - } + state.db.get_all_providers("omp") +} +fn sync_native(state: &AppState) -> Result { + let native = omp_config::get_providers()?; + let saved = state.db.get_all_providers("omp")?; + let mut changed = 0; + for (id, config) in native { + let mut provider = saved.get(&id).cloned().unwrap_or_else(|| { + let name = config + .get("name") + .and_then(serde_json::Value::as_str) + .unwrap_or(&id); + let mut provider = Provider::with_id(id.clone(), name.into(), config.clone(), None); + provider.category = Some("custom".into()); + provider.icon = Some("omp".into()); + provider + }); + if saved.contains_key(&id) && provider.settings_config == config { + continue; } - Err(error) => { - log::warn!("Failed to read Oh My Pi raw entries: {error}"); - } + provider.settings_config = config; + state.db.save_provider("omp", &provider)?; + changed += 1; } - Ok(providers) + Ok(changed) } - pub(super) fn import_from_live(state: &AppState) -> Result { - let _guard = futures::executor::block_on(state.proxy_service.lock_switch_for_app(OMP_APP)); - let native = crate::omp_config::read_omp_native_providers()?; - sync_native_locked(state, &native) + let _guard = futures::executor::block_on(state.proxy_service.lock_switch_for_app("omp")); + sync_native(state) } - pub(super) fn add( state: &AppState, mut provider: Provider, add_to_live: bool, ) -> Result { - let app_type = AppType::Omp; - let _guard = - futures::executor::block_on(state.proxy_service.lock_switch_for_app(app_type.as_str())); - strip_unsupported_omp_metadata(&mut provider); - ProviderService::validate_provider_settings(&app_type, &provider)?; - ProviderService::normalize_usage_script_credential_overrides(&app_type, &mut provider); - - if state - .db - .get_provider_by_id(&provider.id, app_type.as_str())? - .is_some() - { - return Err(AppError::InvalidInput(format!( - "Oh My Pi provider '{}' already exists", - provider.id - ))); + let _guard = futures::executor::block_on(state.proxy_service.lock_switch_for_app("omp")); + prepare(&mut provider)?; + if state.db.get_provider_by_id(&provider.id, "omp")?.is_some() { + return Err(AppError::Config("omp.errors.duplicateProvider".into())); } - - if !add_to_live && crate::omp_config::omp_provider_exists(&provider.id)? { - return Err(AppError::InvalidInput(format!( - "Oh My Pi provider key '{}' already exists in models.yml", - provider.id - ))); - } - - let passthrough_yaml = provider - .meta - .as_ref() - .and_then(|meta| meta.omp_passthrough_yaml.clone()); - let native_inserted = if add_to_live { - crate::omp_config::write_omp_provider_entry( - &provider.id, - &provider.settings_config, - passthrough_yaml.as_deref(), - )? - } else { - false + let commit = |_| { + state.db.save_provider("omp", &provider)?; + Ok(true) }; - - if let Err(error) = state.db.save_provider(app_type.as_str(), &provider) { - if native_inserted { - if let Err(rollback) = crate::omp_config::remove_omp_provider_if_matches( - &provider.id, - &provider.settings_config, - ) { - return Err(AppError::Config(format!( - "failed to save Oh My Pi provider: {error}; native rollback failed: {rollback}" - ))); - } + if add_to_live { + omp_config::change_and_commit( + &provider.id, + Some(&provider.settings_config), + Change::Insert, + commit, + ) + } else { + if omp_config::get_providers()?.contains_key(&provider.id) { + return Err(AppError::Config("omp.errors.duplicateProvider".into())); } - return Err(error); + commit(None) } - Ok(true) } - -pub(super) fn update_usage_script( - state: &AppState, - id: &str, - script: UsageScript, -) -> Result { - let app_type = AppType::Omp; - let _guard = - futures::executor::block_on(state.proxy_service.lock_switch_for_app(app_type.as_str())); - super::validate_usage_script(&script)?; - - let mut provider = state - .db - .get_provider_by_id(id, app_type.as_str())? - .ok_or_else(|| AppError::InvalidInput(format!("Oh My Pi provider '{id}' not found")))?; - provider - .meta - .get_or_insert_with(ProviderMeta::default) - .usage_script = Some(script); - strip_unsupported_omp_metadata(&mut provider); - ProviderService::normalize_usage_script_credential_overrides(&app_type, &mut provider); - state.db.save_provider(app_type.as_str(), &provider)?; - Ok(true) -} - pub(super) fn update( state: &AppState, original_id: Option<&str>, mut provider: Provider, ) -> Result { - let app_type = AppType::Omp; - let _guard = - futures::executor::block_on(state.proxy_service.lock_switch_for_app(app_type.as_str())); - let original_id = original_id.unwrap_or(&provider.id).to_string(); - if original_id != provider.id { - return Err(AppError::InvalidInput( - "Oh My Pi provider keys cannot be renamed".to_string(), - )); + let _guard = futures::executor::block_on(state.proxy_service.lock_switch_for_app("omp")); + if original_id.is_some_and(|id| id != provider.id) { + return Err(AppError::Config("omp.errors.renameProvider".into())); } - - let existing = state + state .db - .get_provider_by_id(&original_id, app_type.as_str())? - .ok_or_else(|| { - AppError::InvalidInput(format!("Oh My Pi provider '{original_id}' not found")) - })?; - strip_unsupported_omp_metadata(&mut provider); - ProviderService::validate_provider_settings(&app_type, &provider)?; - ProviderService::normalize_usage_script_credential_overrides(&app_type, &mut provider); - - let passthrough_yaml = provider - .meta - .as_ref() - .and_then(|meta| meta.omp_passthrough_yaml.clone()); - // 冲突检查的底是数据库那一行(list() 时与 models.yml 同步过),不是编辑器重组的 - // 新配置:表单控件对字段顺序/表示的归一化不应被当成外部修改。 - let previous_native = crate::omp_config::replace_omp_provider_if_present( - &original_id, - &existing.settings_config, - &provider.settings_config, - passthrough_yaml.as_deref(), - )?; - if let Err(error) = state.db.save_provider(app_type.as_str(), &provider) { - if let Some(previous_native) = previous_native.as_ref() { - if let Err(rollback) = crate::omp_config::replace_omp_provider( - &original_id, - &provider.settings_config, - previous_native, - ) { - return Err(AppError::Config(format!( - "failed to save Oh My Pi provider: {error}; native rollback failed: {rollback}" - ))); - } - } - return Err(error); - } - Ok(true) + .get_provider_by_id(&provider.id, "omp")? + .ok_or_else(|| AppError::Config("omp.errors.providerMissing".into()))?; + prepare(&mut provider)?; + omp_config::change_and_commit( + &provider.id, + Some(&provider.settings_config), + Change::ReplaceIfPresent, + |_| { + state.db.save_provider("omp", &provider)?; + Ok(true) + }, + ) } - pub(super) fn delete(state: &AppState, id: &str) -> Result<(), AppError> { - let app_type = AppType::Omp; - let _guard = - futures::executor::block_on(state.proxy_service.lock_switch_for_app(app_type.as_str())); - let Some(_) = state.db.get_provider_by_id(id, app_type.as_str())? else { - return Ok(()); - }; - let removed = crate::omp_config::remove_omp_provider(id)?; - - if let Err(error) = state.db.delete_provider(app_type.as_str(), id) { - if let Some(removed) = removed.as_ref() { - if let Err(rollback) = crate::omp_config::restore_omp_provider_if_missing(id, removed) { - return Err(AppError::Config(format!( - "failed to delete Oh My Pi provider: {error}; native rollback failed: {rollback}" - ))); - } - } - return Err(error); - } - Ok(()) + let _guard = futures::executor::block_on(state.proxy_service.lock_switch_for_app("omp")); + omp_config::change_and_commit(id, None, Change::Remove, |_| { + state.db.delete_provider("omp", id) + }) } - pub(super) fn remove(state: &AppState, id: &str) -> Result<(), AppError> { - let app_type = AppType::Omp; - let _guard = - futures::executor::block_on(state.proxy_service.lock_switch_for_app(app_type.as_str())); - let provider = state + let _guard = futures::executor::block_on(state.proxy_service.lock_switch_for_app("omp")); + let mut provider = state .db - .get_provider_by_id(id, app_type.as_str())? - .ok_or_else(|| AppError::InvalidInput(format!("Oh My Pi provider '{id}' not found")))?; - let Some(removed) = crate::omp_config::remove_omp_provider(id)? else { - return Ok(()); - }; - let mut synced = provider; - merge_native_config(&mut synced, removed.clone()); - if let Err(error) = state.db.save_provider(app_type.as_str(), &synced) { - if let Err(rollback) = crate::omp_config::restore_omp_provider_if_missing(id, &removed) { - return Err(AppError::Config(format!( - "failed to preserve Oh My Pi provider before removal: {error}; native rollback failed: {rollback}" - ))); + .get_provider_by_id(id, "omp")? + .ok_or_else(|| AppError::Config("omp.errors.providerMissing".into()))?; + omp_config::change_and_commit(id, None, Change::Remove, |old| { + if let Some(config) = old { + provider.settings_config = config; + state.db.save_provider("omp", &provider)?; } - return Err(error); - } - Ok(()) + Ok(()) + }) } - pub(super) fn enable(state: &AppState, id: &str) -> Result { - let app_type = AppType::Omp; - let _guard = - futures::executor::block_on(state.proxy_service.lock_switch_for_app(app_type.as_str())); - let provider = state + let _guard = futures::executor::block_on(state.proxy_service.lock_switch_for_app("omp")); + let mut provider = state .db - .get_provider_by_id(id, app_type.as_str())? - .ok_or_else(|| AppError::InvalidInput(format!("Oh My Pi provider '{id}' not found")))?; - - if let Some(native) = crate::omp_config::read_omp_native_provider(id)? { - let mut synced = provider; - merge_native_config(&mut synced, native); - // models.yml 是真相源:切换后刷新 passthrough 快照,编辑器读到的是 - // 当前文件内容,不会用旧 meta 文本把外部修改盖回去。 - if let Ok(Some(text)) = crate::omp_config::read_omp_provider_entry_raw(id) { - synced - .meta - .get_or_insert_with(ProviderMeta::default) - .omp_passthrough_yaml = Some(text); - } - state.db.save_provider(app_type.as_str(), &synced)?; + .get_provider_by_id(id, "omp")? + .ok_or_else(|| AppError::Config("omp.errors.providerMissing".into()))?; + if let Some(native) = omp_config::get_providers()?.get(id) { + provider.settings_config = native.clone(); + state.db.save_provider("omp", &provider)?; return Ok(SwitchResult::default()); } - - ProviderService::validate_provider_settings(&app_type, &provider)?; - crate::omp_config::insert_omp_provider(id, &provider.settings_config)?; - // D3: enable = add only. `modelRoles.default` is left unchanged; the - // read-only state service still shows the active provider, and a separate - // "set as default" action is future frontend work. - Ok(SwitchResult::default()) + ProviderService::validate_provider_settings(&AppType::Omp, &provider)?; + omp_config::change_and_commit(id, Some(&provider.settings_config), Change::Insert, |_| { + Ok(SwitchResult::default()) + }) } -fn sync_native_locked( - state: &AppState, - native: &IndexMap, -) -> Result { - let saved = state.db.get_all_providers(OMP_APP)?; - let mut changed = 0; - - for (id, config) in native { - let mut provider = saved.get(id).cloned().unwrap_or_else(|| { - let mut imported = Provider::with_id(id.clone(), id.clone(), config.clone(), None); - imported.category = Some("custom".to_string()); - imported - }); - let is_new = !saved.contains_key(id); - let previous_name = provider.name.clone(); - let previous_config = provider.settings_config.clone(); - merge_native_config(&mut provider, config.clone()); - if !is_new && provider.name == previous_name && provider.settings_config == previous_config - { - continue; - } - - state.db.save_provider(OMP_APP, &provider)?; - changed += 1; +#[cfg(test)] +mod tests { + use super::*; + use crate::{database::Database, omp_config::test_support::TestAgentDir}; + use serde_json::json; + use std::{fs, sync::Arc}; + fn provider() -> Provider { + Provider::with_id( + "cc-switch-test".into(), + "Test".into(), + json!({"baseUrl":"https://example.com/v1","apiKey":"!literal credential","api":"openai-completions","models":[{"id":"a"}]}), + None, + ) + } + #[test] + #[serial_test::serial] + fn written_round_trip_keeps_comments_and_unmanaged_nodes() { + let _agent = TestAgentDir::new(); + let state = AppState::new(Arc::new(Database::memory().unwrap())); + let path = omp_config::get_omp_models_path().unwrap(); + let before = "# user header\nnotes: 'verbatim'\nproviders:\n other:\n baseUrl: https://other.example # keep\n\n# user footer\n"; + fs::write(&path, before).unwrap(); + add(&state, provider(), true).unwrap(); + let mut edited = provider(); + edited.settings_config["models"][0]["id"] = json!("b"); + edited.settings_config["apiKey"] = json!("cc-switch-validation-key"); + update(&state, None, edited).unwrap(); + let written = fs::read_to_string(&path).unwrap(); + assert!(written.starts_with("# user header\nnotes: 'verbatim'\nproviders:\n other:\n baseUrl: https://other.example # keep\n")); + assert!(written.ends_with("\n# user footer\n")); + assert_eq!( + omp_config::get_providers().unwrap()["cc-switch-test"]["models"][0]["id"], + "b" + ); + remove(&state, "cc-switch-test").unwrap(); + assert_eq!(fs::read_to_string(path).unwrap(), before); } - Ok(changed) -} + #[test] + #[serial_test::serial] + fn usage_metadata_update_preserves_native_settings_and_pi_provider() { + let _agent = TestAgentDir::new(); + let state = AppState::new(Arc::new(Database::memory().unwrap())); + let baseline = provider(); + add(&state, baseline.clone(), true).unwrap(); + state.db.save_provider("pi", &baseline).unwrap(); -fn merge_native_config(provider: &mut Provider, config: Value) { - if let Some(name) = native_provider_name(&config) { - provider.name = name.to_string(); + let mut external = baseline.settings_config.clone(); + external["apiKey"] = json!("rotated-outside"); + omp_config::change_and_commit( + &baseline.id, + Some(&external), + Change::ReplaceIfPresent, + |_| Ok(()), + ) + .unwrap(); + let path = omp_config::get_omp_models_path().unwrap(); + let native_bytes = fs::read(&path).unwrap(); + let script = serde_json::from_value(json!({ + "enabled": true, + "language": "javascript", + "code": "return {}", + })) + .unwrap(); + + ProviderService::update_pi_usage_script(&state, AppType::Omp, &baseline.id, script) + .unwrap(); + assert_eq!(fs::read(&path).unwrap(), native_bytes); + let saved = list(&state).unwrap(); + let saved = &saved[&baseline.id]; + assert_eq!(saved.settings_config, external); + assert_eq!( + saved + .meta + .as_ref() + .unwrap() + .usage_script + .as_ref() + .unwrap() + .code, + "return {}" + ); + let pi = state + .db + .get_provider_by_id(&baseline.id, "pi") + .unwrap() + .unwrap(); + assert_eq!(pi.settings_config, baseline.settings_config); + assert!(pi.meta.and_then(|meta| meta.usage_script).is_none()); } - provider.settings_config = config; -} -fn native_provider_name(config: &Value) -> Option<&str> { - config - .get("name") - .and_then(Value::as_str) - .filter(|name| !name.trim().is_empty()) -} - -fn strip_unsupported_omp_metadata(provider: &mut Provider) { - provider.in_failover_queue = false; - let Some(meta) = provider.meta.take() else { - return; - }; - provider.meta = Some(ProviderMeta { - usage_script: meta.usage_script, - is_partner: meta.is_partner, - partner_promotion_key: meta.partner_promotion_key, - omp_passthrough_yaml: meta.omp_passthrough_yaml, - ..ProviderMeta::default() - }); + #[test] + #[serial_test::serial] + fn membership_add_update_remove_enable_delete_never_touches_defaults_or_login() { + let _agent = TestAgentDir::new(); + let dir = omp_config::get_omp_agent_dir().unwrap(); + let defaults = "# user's defaults\nmodelRoles:\n # null\n"; + fs::write(dir.join("config.yml"), defaults).unwrap(); + fs::write(dir.join("agent.db"), b"native login").unwrap(); + let state = AppState::new(Arc::new(Database::memory().unwrap())); + ProviderService::add(&state, AppType::Omp, provider(), true).unwrap(); + assert!(omp_config::get_providers() + .unwrap() + .contains_key("cc-switch-test")); + let mut changed = provider(); + changed.settings_config["models"][0]["id"] = json!("b"); + ProviderService::update(&state, AppType::Omp, None, changed).unwrap(); + assert_eq!( + omp_config::get_providers().unwrap()["cc-switch-test"]["models"][0]["id"], + "b" + ); + ProviderService::remove_from_live_config(&state, AppType::Omp, "cc-switch-test").unwrap(); + assert!(omp_config::get_providers().unwrap().is_empty()); + ProviderService::switch(&state, AppType::Omp, "cc-switch-test").unwrap(); + assert_eq!( + omp_config::get_providers().unwrap()["cc-switch-test"]["models"][0]["id"], + "b" + ); + ProviderService::delete(&state, AppType::Omp, "cc-switch-test").unwrap(); + assert!(omp_config::get_providers().unwrap().is_empty()); + assert_eq!( + fs::read_to_string(dir.join("config.yml")).unwrap(), + defaults + ); + assert_eq!(fs::read(dir.join("agent.db")).unwrap(), b"native login"); + } + #[test] + #[serial_test::serial] + fn db_failure_restores_native_bytes_and_catalog_only_add_does_not_write() { + let _agent = TestAgentDir::new(); + let state = AppState::new(Arc::new(Database::memory().unwrap())); + add(&state, provider(), false).unwrap(); + assert!(!omp_config::get_omp_models_path().unwrap().exists()); + enable(&state, "cc-switch-test").unwrap(); + let path = omp_config::get_omp_models_path().unwrap(); + let bytes = fs::read(&path).unwrap(); + state + .db + .conn + .lock() + .unwrap() + .execute_batch("PRAGMA query_only = ON") + .unwrap(); + assert!(remove(&state, "cc-switch-test").is_err()); + assert_eq!(fs::read(&path).unwrap(), bytes); + assert!(delete(&state, "cc-switch-test").is_err()); + assert_eq!(fs::read(&path).unwrap(), bytes); + } } diff --git a/src-tauri/src/services/provider/pi.rs b/src-tauri/src/services/provider/pi.rs index 4e978fb62..48c5a1bed 100644 --- a/src-tauri/src/services/provider/pi.rs +++ b/src-tauri/src/services/provider/pi.rs @@ -82,12 +82,18 @@ pub(super) fn add( Ok(true) } +// Pi、Omp 的用量脚本只更新数据库,避免用旧快照覆盖外部修改过的原生配置。 pub(super) fn update_usage_script( state: &AppState, + app_type: AppType, id: &str, script: UsageScript, ) -> Result { - let app_type = AppType::Pi; + if !matches!(app_type, AppType::Pi | AppType::Omp) { + return Err(AppError::InvalidInput( + "Usage metadata updates only support Pi and Omp".into(), + )); + } let _guard = futures::executor::block_on(state.proxy_service.lock_switch_for_app(app_type.as_str())); super::validate_usage_script(&script)?; @@ -95,7 +101,9 @@ pub(super) fn update_usage_script( let mut provider = state .db .get_provider_by_id(id, app_type.as_str())? - .ok_or_else(|| AppError::InvalidInput(format!("Pi provider '{id}' not found")))?; + .ok_or_else(|| { + AppError::InvalidInput(format!("{} provider '{id}' not found", app_type.as_str())) + })?; provider .meta .get_or_insert_with(ProviderMeta::default) @@ -688,8 +696,13 @@ mod tests { ) .expect("edit native provider"); - update_usage_script(&state, "cc-switch-test", usage_script("return {}")) - .expect("save usage metadata"); + ProviderService::update_pi_usage_script( + &state, + AppType::Pi, + "cc-switch-test", + usage_script("return {}"), + ) + .expect("save usage metadata"); assert_eq!( crate::pi_config::read_pi_native_provider("cc-switch-test") .expect("read native provider") diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs index 071757ace..9f041d402 100644 --- a/src-tauri/src/services/skill.rs +++ b/src-tauri/src/services/skill.rs @@ -691,15 +691,15 @@ impl SkillService { AppType::Pi => { return Ok(crate::pi_config::get_pi_agent_dir()?.join("skills")); } + AppType::Omp => { + return Ok(crate::omp_config::get_omp_agent_dir()?.join("skills")); + } AppType::Agy => { return Ok(crate::config::get_home_dir() .join(".gemini") .join("config") .join("skills")); } - AppType::Omp => { - return Ok(crate::omp_config::get_omp_agent_dir()?.join("skills")); - } } // 默认路径:回退到用户主目录下的标准位置。 @@ -719,8 +719,8 @@ impl SkillService { AppType::OpenClaw => home.join(".openclaw").join("skills"), AppType::Hermes => crate::hermes_config::get_hermes_dir().join("skills"), AppType::Pi => crate::pi_config::get_pi_agent_dir()?.join("skills"), - AppType::Agy => home.join(".gemini").join("config").join("skills"), AppType::Omp => crate::omp_config::get_omp_agent_dir()?.join("skills"), + AppType::Agy => home.join(".gemini").join("config").join("skills"), }) } @@ -794,6 +794,7 @@ impl SkillService { let mut skills = db.get_all_installed_skills()?; for skill in skills.values_mut() { skill.apps.pi = Self::skill_exists_in_app(&skill.directory, &AppType::Pi); + skill.apps.omp = Self::skill_exists_in_app(&skill.directory, &AppType::Omp); } Ok(skills.into_values().collect()) } @@ -1071,7 +1072,7 @@ impl SkillService { let ssot_dir = Self::get_ssot_dir()?; let source = ssot_dir.join(&directory); let mut managed_destinations = Vec::new(); - for app in [AppType::Mcode, AppType::Agy, AppType::Kimi, AppType::Omp] { + for app in [AppType::Mcode, AppType::Agy, AppType::Kimi] { if skill.apps.is_enabled_for(&app) { let destination = Self::get_distinct_app_skills_dir(&ssot_dir, &app)? .join(&directory); @@ -1085,66 +1086,69 @@ impl SkillService { managed_destinations.push(destination); } } - let mut preserved_pi_path: Option = None; + let mut preserved_paths = Vec::new(); let mut pi_cleanup_incomplete = false; - let mut pi_removal_path = None; + let mut removal_paths = Vec::new(); - match Self::get_app_skills_dir(&AppType::Pi) { - Ok(pi_dir) => { - let destination = pi_dir.join(&directory); - if Self::paths_alias(&ssot_dir, &pi_dir) { - // Pi 直接使用 SSOT 时没有第二份副本;后续删除 SSOT 即完成清理。 - log::debug!("Skill {id} 的 Pi Skills 目录与 SSOT 相同"); - } else { + for app in [AppType::Pi, AppType::Omp] { + match Self::get_app_skills_dir(&app) { + Ok(app_dir) => { + if Self::paths_alias(&ssot_dir, &app_dir) { + // 原生目录直接使用 SSOT 时没有第二份副本;后续删除 SSOT 即完成清理。 + log::debug!("Skill {id} 的 {app:?} Skills 目录与 SSOT 相同"); + continue; + } // 即使当前不存在也保留目标路径;备份期间可能有另一进程 // 同步该 Skill,删除 SSOT 前必须重新检查一次。 - pi_removal_path = Some(destination.clone()); - if destination.exists() || Self::is_symlink(&destination) { - if let Err(err) = Self::inspect_pi_skill_destination( + let destination = app_dir.join(&directory); + if (destination.exists() || Self::is_symlink(&destination)) + && Self::inspect_pi_skill_destination( &source, &destination, &directory, - ) { + ) + .inspect_err(|err| { log::warn!( - "Skill {id} 卸载时跳过 Pi 清理,保留 {}: {err}", + "Skill {id} 卸载时跳过 {app:?} 清理,保留 {}: {err}", destination.display() - ); - preserved_pi_path = Some(destination); - pi_removal_path = None; - pi_cleanup_incomplete = true; - } + ) + }) + .is_err() + { + preserved_paths.push(destination); + pi_cleanup_incomplete = true; + } else { + removal_paths.push((app, destination)); } } - } - Err(err) => { - log::warn!( - "Skill {id} 卸载时无法解析 Pi Skills 目录,跳过 Pi 清理: {err}" - ); - pi_cleanup_incomplete = true; + Err(err) => { + log::warn!( + "Skill {id} 卸载时无法解析 {app:?} Skills 目录,跳过清理: {err}" + ); + pi_cleanup_incomplete = true; + } } } - let backup_path = Self::create_uninstall_backup_excluding( - &skill, - preserved_pi_path.as_deref(), - )? - .map(|path| path.to_string_lossy().to_string()); + let backup_path = + Self::create_uninstall_backup_excluding(&skill, &preserved_paths)? + .map(|path| path.to_string_lossy().to_string()); for destination in managed_destinations { Self::remove_verified_pi_destination(&source, &destination, &directory) .context("Managed Skill could not be removed; uninstall cancelled")?; } - // Pi 目录可能包含用户自己维护的同名 Skill。删除 SSOT 前仅移除 + // Pi、Omp 目录可能包含用户自己维护的同名 Skill。删除 SSOT 前仅移除 // 能验证为 CC Switch 部署的副本;其余路径保留并返回警告。 - if let Some(destination) = pi_removal_path { - let removal = - Self::remove_verified_pi_destination(&source, &destination, &directory); - if let Err(err) = removal { - log::warn!("Skill {id} 卸载时 Pi 清理失败,将继续卸载: {err}"); + for (app, destination) in removal_paths { + if let Err(err) = + Self::remove_verified_pi_destination(&source, &destination, &directory) + { + log::warn!("Skill {id} 卸载时 {app:?} 清理失败,将继续卸载: {err}"); pi_cleanup_incomplete = true; if destination.exists() || Self::is_symlink(&destination) { - preserved_pi_path = Some(destination); + preserved_paths.push(destination); } } } @@ -1154,31 +1158,32 @@ impl SkillService { if matches!( app, AppType::Pi + | AppType::Omp | AppType::Mcode | AppType::Agy | AppType::Kimi - | AppType::Omp ) { continue; } - let _ = Self::remove_from_app_preserving( - &directory, - &app, - preserved_pi_path.as_deref(), - ); + let _ = + Self::remove_from_app_preserving(&directory, &app, &preserved_paths); } // 从 SSOT 删除 let skill_path = ssot_dir.join(&directory); - let overlaps_preserved_pi = preserved_pi_path - .as_deref() - .is_some_and(|path| Self::paths_overlap(&skill_path, path)); - if overlaps_preserved_pi { - log::warn!("Skill {id} 的 SSOT 路径与保留的 Pi 副本重叠,跳过文件删除"); + if preserved_paths + .iter() + .any(|path| Self::paths_overlap(&skill_path, path)) + { + log::warn!("Skill {id} 的 SSOT 路径与保留的原生副本重叠,跳过文件删除"); } else if skill_path.exists() { fs::remove_dir_all(&skill_path)?; } - (backup_path, preserved_pi_path, pi_cleanup_incomplete) + ( + backup_path, + preserved_paths.into_iter().next(), + pi_cleanup_incomplete, + ) } Err(err) => { log::warn!( @@ -1520,6 +1525,7 @@ impl SkillService { .get_installed_skill(skill_id)? .ok_or_else(|| anyhow!("Skill not found: {skill_id}"))?; skill.apps.pi = Self::skill_exists_in_app(&skill.directory, &AppType::Pi); + skill.apps.omp = Self::skill_exists_in_app(&skill.directory, &AppType::Omp); // 本函数后续三种危险操作都用 directory 拼路径:备份源(把任意目录复制进 // 备份区并在界面列出)、remove_dir_all(删任意目录)、copy_dir_recursive @@ -1549,6 +1555,9 @@ impl SkillService { if skill.apps.pi { Self::get_distinct_app_skills_dir(&ssot_dir, &AppType::Pi)?; } + if skill.apps.omp { + Self::get_distinct_app_skills_dir(&ssot_dir, &AppType::Omp)?; + } // 下载仓库 let (temp_guard, used_branch) = timeout( @@ -1620,16 +1629,17 @@ impl SkillService { } Self::require_valid_directory(¤t_skill.directory)?; current_skill.apps.pi = Self::skill_exists_in_app(¤t_skill.directory, &AppType::Pi); + current_skill.apps.omp = Self::skill_exists_in_app(¤t_skill.directory, &AppType::Omp); let skill = current_skill; let dest = ssot_dir.join(&skill.directory); let mut deployments = Vec::new(); for app in [ AppType::Pi, + AppType::Omp, AppType::Mcode, AppType::Agy, AppType::Kimi, - AppType::Omp, ] { if skill.apps.is_enabled_for(&app) { let destination = @@ -1645,11 +1655,7 @@ impl SkillService { // 备份旧文件 let _ = Self::create_uninstall_backup(&skill); - let owned_apps: Vec<_> = [AppType::Mcode, AppType::Agy, AppType::Kimi, AppType::Omp] - .into_iter() - .filter(|app| skill.apps.is_enabled_for(app)) - .collect(); - if owned_apps.is_empty() { + if !(skill.apps.mcode || skill.apps.agy || skill.apps.kimi) { if dest.exists() { fs::remove_dir_all(&dest)?; } @@ -1657,7 +1663,7 @@ impl SkillService { } // 计算新哈希 + 解析新元数据 - let metadata_source = if !owned_apps.is_empty() { + let metadata_source = if skill.apps.mcode || skill.apps.agy || skill.apps.kimi { &source } else { &dest @@ -1689,14 +1695,15 @@ impl SkillService { updated_at: chrono::Utc::now().timestamp(), }; - let mut updated_skill = if !owned_apps.is_empty() { + let mut updated_skill = if skill.apps.mcode || skill.apps.agy || skill.apps.kimi { Self::update_managed_skill_files( &source, &dest, &skill.directory, &deployments, - &owned_apps + &[AppType::Mcode, AppType::Agy, AppType::Kimi] .iter() + .filter(|app| skill.apps.is_enabled_for(app)) .map(|app| Self::get_app_skills_dir(app).map(|dir| dir.join(&skill.directory))) .collect::>>()?, || { @@ -1718,12 +1725,13 @@ impl SkillService { Self::persist_updated_skill_metadata(db, &updated_metadata)? }; updated_skill.apps.pi = Self::skill_exists_in_app(&updated_skill.directory, &AppType::Pi); + updated_skill.apps.omp = Self::skill_exists_in_app(&updated_skill.directory, &AppType::Omp); // 同步到所有已启用的应用目录 for app in updated_skill.apps.enabled_apps() { if matches!( app, - AppType::Pi | AppType::Mcode | AppType::Agy | AppType::Kimi | AppType::Omp + AppType::Pi | AppType::Omp | AppType::Mcode | AppType::Agy | AppType::Kimi ) { continue; } @@ -1739,12 +1747,11 @@ impl SkillService { // Keep the old SSOT and native copies until metadata is committed. In particular, // a failed native write must not make the old copy appear to be a user edit. #[cfg(test)] - fn update_owned_skill_files( + fn update_mcode_skill_files( source: &Path, ssot: &Path, directory: &str, deployments: &[(PathBuf, PiSkillDeployment)], - native_apps: &[AppType], commit: impl FnOnce() -> Result, ) -> Result { Self::update_managed_skill_files( @@ -1752,10 +1759,7 @@ impl SkillService { ssot, directory, deployments, - &native_apps - .iter() - .map(|app| Self::get_app_skills_dir(app).map(|dir| dir.join(directory))) - .collect::>>()?, + &[Self::get_app_skills_dir(&AppType::Mcode)?.join(directory)], commit, ) } @@ -1837,7 +1841,7 @@ impl SkillService { if !rollback_errors.is_empty() { let backups: Vec<_> = staged.into_iter().map(|(_, stage)| stage.keep()).collect(); return Err(anyhow!( - "{error}; Native Skill rollback failed: {}; backups: {backups:?}", + "{error}; MiniMax Code Skill rollback failed: {}; backups: {backups:?}", rollback_errors.join("; ") )); } @@ -1970,6 +1974,15 @@ impl SkillService { } } } + // Omp 没有开关列,是否部署只看原生目录里有没有 CC Switch 的副本。 + let omp_destination = Self::get_app_skills_dir(&AppType::Omp)?.join(&directory); + if let Some(deployment) = + Self::inspect_pi_skill_destination(&src, &omp_destination, &directory) + .ok() + .flatten() + { + mcode_deployments.push((directory.clone(), omp_destination, deployment)); + } // 优先 rename(同文件系统原子操作),失败则 copy+delete match fs::rename(&src, &dst) { Ok(()) => { @@ -2231,7 +2244,7 @@ impl SkillService { // Pi follows its native exists=active rule; other apps keep their // established persisted desired-state flags. - if !matches!(app, AppType::Pi) { + if !matches!(app, AppType::Pi | AppType::Omp) { db.update_skill_apps(id, &skill.apps)?; } @@ -2315,7 +2328,7 @@ impl SkillService { let ssot_dir = Self::get_ssot_dir()?; let agents_lock = parse_agents_lock(); let mut imported = Vec::new(); - let mut skipped_native = Vec::new(); + let mut skipped_mcode = Vec::new(); let mut not_enabled = Vec::new(); // 将 lock 文件中发现的仓库保存到 skill_repos @@ -2384,49 +2397,48 @@ impl SkillService { let skill_md = dest.join("SKILL.md"); let (name, description) = Self::read_skill_name_desc(&skill_md, &dir_name); - // Pi follows directory presence; OMP native imports are claimed after ownership checks. + // 其他应用保存用户选择;Pi 的 exists=active 必须直接来自原生目录。 let mut apps = selection.apps; apps.pi = Self::skill_exists_in_app(&dir_name, &AppType::Pi); - apps.omp = Self::skill_exists_in_app(&dir_name, &AppType::Omp) || apps.omp; + apps.omp = Self::skill_exists_in_app(&dir_name, &AppType::Omp); - // An explicitly imported native link must point at the managed copy + // An explicitly imported MCode link must point at the managed copy // so subsequent toggles and updates can verify its ownership. - for app in [AppType::Mcode, AppType::Agy, AppType::Kimi, AppType::Omp] { - if !apps.is_enabled_for(&app) { - continue; - } - let projection = (|| -> Result<()> { - let native = - Self::get_distinct_app_skills_dir(&ssot_dir, &app)?.join(&dir_name); - if Self::is_symlink(&native) && !Self::paths_alias(&native, &dest) { - if Self::compute_pi_deployment_hash(&native)? - != Self::compute_pi_deployment_hash(&dest)? - { - return Err(anyhow!( - "{app:?} Skill 与托管副本内容不同,拒绝替换链接: {dir_name}" - )); + for app in [AppType::Mcode, AppType::Agy, AppType::Kimi] { + if apps.is_enabled_for(&app) { + let projection = (|| -> Result<()> { + let native = + Self::get_distinct_app_skills_dir(&ssot_dir, &app)?.join(&dir_name); + if Self::is_symlink(&native) && !Self::paths_alias(&native, &dest) { + if Self::compute_pi_deployment_hash(&native)? + != Self::compute_pi_deployment_hash(&dest)? + { + return Err(anyhow!( + "Skill 与托管副本内容不同,拒绝替换链接: {dir_name}" + )); + } + if let Some(deployment) = + Self::inspect_pi_skill_destination(&native, &native, &dir_name)? + { + Self::refresh_pi_skill_destination( + &dest, + &native, + &dir_name, + &deployment, + )?; + } + } else { + Self::preflight_install_destination(&dest, &dir_name, &app)?; } - if let Some(deployment) = - Self::inspect_pi_skill_destination(&native, &native, &dir_name)? - { - Self::refresh_pi_skill_destination( - &dest, - &native, - &dir_name, - &deployment, - )?; + if !native.exists() { + Self::sync_to_app_dir(&dir_name, &app)?; } - } else { - Self::preflight_install_destination(&dest, &dir_name, &app)?; + Ok(()) + })(); + if let Err(error) = projection { + skipped_mcode.push(format!("{dir_name}: {error}")); + continue 'imports; } - if !native.exists() { - Self::sync_to_app_dir(&dir_name, &app)?; - } - Ok(()) - })(); - if let Err(error) = projection { - skipped_native.push(format!("{dir_name} ({app:?}): {error}")); - continue 'imports; } } // 勾选的应用里还没有这个 Skill(它是在别处发现的):导入时就投影过去。 @@ -2436,7 +2448,7 @@ impl SkillService { for app in AppType::all() { if matches!( app, - AppType::Pi | AppType::Mcode | AppType::Agy | AppType::Kimi + AppType::Pi | AppType::Omp | AppType::Mcode | AppType::Agy | AppType::Kimi ) || !apps.is_enabled_for(&app) || Self::skill_exists_in_app(&dir_name, &app) { @@ -2482,10 +2494,10 @@ impl SkillService { log::info!("成功导入 {} 个 Skills", imported.len()); let mut problems = Vec::new(); - if !skipped_native.is_empty() { + if !skipped_mcode.is_empty() { problems.push(format!( - "skipped native entries: {}", - skipped_native.join("; ") + "skipped MiniMax Code entries: {}", + skipped_mcode.join("; ") )); } if !not_enabled.is_empty() { @@ -2543,7 +2555,7 @@ impl SkillService { let app_dir = Self::get_distinct_app_skills_dir(&ssot_dir, app)?; if !matches!( app, - AppType::Pi | AppType::Mcode | AppType::Agy | AppType::Kimi | AppType::Omp + AppType::Pi | AppType::Omp | AppType::Mcode | AppType::Agy | AppType::Kimi ) { return Ok(()); } @@ -2623,7 +2635,7 @@ impl SkillService { } Err(anyhow!( - "原生应用中已存在同名但内容不同的 Skill,拒绝覆盖或删除: {directory}" + "Pi 中已存在同名但内容不同的 Skill,拒绝覆盖或删除: {directory}" )) } @@ -2650,7 +2662,7 @@ impl SkillService { PiSkillDeployment::Symlink { expected_target } => { if !Self::is_symlink(destination) { return Err(anyhow!( - "原生应用中的 Skill 已在操作期间发生变化,拒绝覆盖: {directory}" + "Pi 中的 Skill 已在操作期间发生变化,拒绝覆盖: {directory}" )); } let target = fs::read_link(destination)?; @@ -2664,7 +2676,7 @@ impl SkillService { }; if &resolved != expected_target { return Err(anyhow!( - "原生应用中的 Skill 已在操作期间发生变化,拒绝覆盖: {directory}" + "Pi 中的 Skill 已在操作期间发生变化,拒绝覆盖: {directory}" )); } Self::remove_path(destination)?; @@ -2679,7 +2691,7 @@ impl SkillService { ) { return Err(anyhow!( - "原生应用中的 Skill 已在操作期间发生变化,拒绝覆盖: {directory}" + "Pi 中的 Skill 已在操作期间发生变化,拒绝覆盖: {directory}" )); } Self::replace_dest_with_copy(source, destination, directory)?; @@ -2720,7 +2732,7 @@ impl SkillService { if matches!( app, - AppType::Pi | AppType::Mcode | AppType::Agy | AppType::Kimi | AppType::Omp + AppType::Pi | AppType::Omp | AppType::Mcode | AppType::Agy | AppType::Kimi ) && (dest.exists() || Self::is_symlink(&dest)) { Self::ensure_pi_skill_destination_matches(&source, &dest, &directory)?; @@ -2892,13 +2904,13 @@ impl SkillService { /// 从应用目录删除 Skill(支持 symlink 和真实目录) pub fn remove_from_app(directory: &str, app: &AppType) -> Result<()> { - Self::remove_from_app_preserving(directory, app, None) + Self::remove_from_app_preserving(directory, app, &[]) } fn remove_from_app_preserving( directory: &str, app: &AppType, - preserved_path: Option<&Path>, + preserved_paths: &[PathBuf], ) -> Result<()> { if matches!(app, AppType::ClaudeDesktop) { return Ok(()); @@ -2912,15 +2924,18 @@ impl SkillService { let app_dir = Self::get_distinct_app_skills_dir(&ssot_dir, app)?; let skill_path = app_dir.join(&directory); - if preserved_path.is_some_and(|path| Self::paths_overlap(&skill_path, path)) { - log::debug!("Skill {directory} 的 {app:?} 路径与保留的 Pi 副本相同,跳过删除"); + if preserved_paths + .iter() + .any(|path| Self::paths_overlap(&skill_path, path)) + { + log::debug!("Skill {directory} 的 {app:?} 路径与保留的原生副本相同,跳过删除"); return Ok(()); } if skill_path.exists() || Self::is_symlink(&skill_path) { if matches!( app, - AppType::Pi | AppType::Mcode | AppType::Agy | AppType::Kimi | AppType::Omp + AppType::Pi | AppType::Omp | AppType::Mcode | AppType::Agy | AppType::Kimi ) { let source = ssot_dir.join(&directory); Self::ensure_pi_skill_destination_matches(&source, &skill_path, &directory)?; @@ -2945,12 +2960,12 @@ impl SkillService { fn is_sync_managed_app(app: &AppType) -> bool { !matches!( app, - AppType::ClaudeDesktop | AppType::OpenClaw | AppType::Pi + AppType::ClaudeDesktop | AppType::OpenClaw | AppType::Pi | AppType::Omp ) } /// 「立即重新同步」:按数据库里的开关和当前同步方式,把 Skill 重新投影到各应用目录, - /// 逐应用报告结果(Claude Desktop、OpenClaw、Pi 不由这里同步,不在结果里)。 + /// 逐应用报告结果(Claude Desktop、OpenClaw、Pi、Omp 不由这里同步,不在结果里)。 pub fn resync_all_apps(db: &Arc) -> Vec { let _state_guard = skill_state_read_guard(); AppType::all() @@ -2992,12 +3007,7 @@ impl SkillService { // Unselected MCode, Agy and Kimi directories may have been installed outside CC Switch. // Explicit disable/uninstall handles removal of managed deployments. - if app_dir.exists() - && !matches!( - app, - AppType::Mcode | AppType::Agy | AppType::Kimi | AppType::Omp - ) - { + if app_dir.exists() && !matches!(app, AppType::Mcode | AppType::Agy | AppType::Kimi) { for entry in fs::read_dir(&app_dir)? { let entry = entry?; let path = entry.path(); @@ -4055,7 +4065,7 @@ impl SkillService { fn resolve_uninstall_backup_source_excluding( skill: &InstalledSkill, - excluded_path: Option<&Path>, + excluded_paths: &[PathBuf], ) -> Result> { // 返回值会被整目录复制进 ~/.cc-switch/skill-backups/ 并由 get_skill_backups // 在界面上列出——脏 directory 在这里等于任意文件读取 + 外泄通道。 @@ -4063,7 +4073,9 @@ impl SkillService { let ssot_path = Self::get_ssot_dir()?.join(&directory); if ssot_path.is_dir() - && !excluded_path.is_some_and(|path| Self::paths_overlap(&ssot_path, path)) + && !excluded_paths + .iter() + .any(|path| Self::paths_overlap(&ssot_path, path)) { return Ok(Some(ssot_path)); } @@ -4080,7 +4092,9 @@ impl SkillService { }; let candidate = app_dir.join(&directory); if candidate.is_dir() - && !excluded_path.is_some_and(|path| Self::paths_overlap(&candidate, path)) + && !excluded_paths + .iter() + .any(|path| Self::paths_overlap(&candidate, path)) { return Ok(Some(candidate)); } @@ -4154,15 +4168,15 @@ impl SkillService { } fn create_uninstall_backup(skill: &InstalledSkill) -> Result> { - Self::create_uninstall_backup_excluding(skill, None) + Self::create_uninstall_backup_excluding(skill, &[]) } fn create_uninstall_backup_excluding( skill: &InstalledSkill, - excluded_path: Option<&Path>, + excluded_paths: &[PathBuf], ) -> Result> { let Some(source_path) = - Self::resolve_uninstall_backup_source_excluding(skill, excluded_path)? + Self::resolve_uninstall_backup_source_excluding(skill, excluded_paths)? else { log::warn!( "Skill {} 卸载前未找到可备份的目录,将跳过备份", @@ -5745,54 +5759,6 @@ mod tests { assert!(!SkillService::get_all_installed(&db).unwrap()[0].apps.pi); } - #[test] - #[serial_test::serial] - fn omp_skill_state_preserves_managed_desired_flags() { - let temp = tempdir().expect("tempdir"); - let _home = TestHomeGuard::set(temp.path()); - let _omp_dir = crate::omp_config::test_support::TestAgentDir::new(); - - let db = std::sync::Arc::new(Database::memory().expect("memory db")); - - // DB 标志保持权威:enabled_omp=true 且无原生目录时仍应视为启用 - let mut managed = poisoned_skill("owner/repo:skill", "managed-skill"); - managed.apps.omp = true; - db.save_skill(&managed).expect("save skill"); - - // Independently authored native directories do not claim the managed switch. - let native = poisoned_skill("owner/repo:native", "native-skill"); - db.save_skill(&native).expect("save skill"); - write_skill( - &SkillService::get_app_skills_dir(&AppType::Omp) - .unwrap() - .join("native-skill"), - "native", - ); - - // 无 DB 记录也无原生目录的技能不得被误启用 - let absent = poisoned_skill("owner/repo:absent", "absent-skill"); - db.save_skill(&absent).expect("save skill"); - - let installed = SkillService::get_all_installed(&db).expect("read skills"); - let state = |directory: &str| { - installed - .iter() - .find(|s| s.directory == directory) - .expect("skill present") - .apps - .omp - }; - assert!(state("managed-skill"), "a DB flag must stay authoritative"); - assert!( - !state("native-skill"), - "an unclaimed native directory must not activate the managed OMP switch" - ); - assert!( - !state("absent-skill"), - "no DB flag and no native presence must stay disabled" - ); - } - #[test] #[serial_test::serial] fn pi_skill_toggle_preserves_a_same_name_external_directory() { @@ -6414,12 +6380,11 @@ mod tests { .unwrap() .execute_batch("PRAGMA query_only = ON") .unwrap(); - assert!(SkillService::update_owned_skill_files( + assert!(SkillService::update_mcode_skill_files( &source, &ssot, &skill.directory, &deployments, - &[AppType::Mcode], || SkillService::persist_updated_skill_metadata(&db, &updated), ) .is_err()); @@ -6440,12 +6405,11 @@ mod tests { .unwrap() .execute_batch("PRAGMA query_only = OFF") .unwrap(); - SkillService::update_owned_skill_files( + SkillService::update_mcode_skill_files( &source, &ssot, &skill.directory, &deployments, - &[AppType::Mcode], || SkillService::persist_updated_skill_metadata(&db, &updated), ) .unwrap(); @@ -6487,12 +6451,11 @@ mod tests { let parent = native.parent().unwrap(); let original_permissions = fs::metadata(parent).unwrap().permissions(); fs::set_permissions(parent, fs::Permissions::from_mode(0o555)).unwrap(); - let result: Result<()> = SkillService::update_owned_skill_files( + let result: Result<()> = SkillService::update_mcode_skill_files( &source, &ssot, "test-skill", &deployments, - &[AppType::Mcode], || panic!("failed native staging must not commit metadata"), ); fs::set_permissions(parent, original_permissions).unwrap(); @@ -6501,14 +6464,9 @@ mod tests { .unwrap() .contains("name: old")); SkillService::ensure_pi_skill_destination_matches(&ssot, &native, "test-skill").unwrap(); - SkillService::update_owned_skill_files( - &source, - &ssot, - "test-skill", - &deployments, - &[AppType::Mcode], - || Ok(()), - ) + SkillService::update_mcode_skill_files(&source, &ssot, "test-skill", &deployments, || { + Ok(()) + }) .unwrap(); assert!(fs::read_to_string(native.join("SKILL.md")) .unwrap() @@ -6601,202 +6559,6 @@ mod tests { assert!(SkillService::get_all_installed(&db).unwrap()[0].apps.pi); } - #[test] - #[cfg(unix)] - #[serial_test::serial] - fn omp_imported_external_symlink_adopts_only_matching_contents() { - let temp = tempdir().unwrap(); - let _home = TestHomeGuard::set(temp.path()); - let _omp = crate::omp_config::test_support::TestAgentDir::new(); - let db = Arc::new(Database::memory().unwrap()); - let external = temp.path().join("external"); - write_skill(&external, "native"); - let native = SkillService::get_app_skills_dir(&AppType::Omp) - .unwrap() - .join("native-skill"); - fs::create_dir_all(native.parent().unwrap()).unwrap(); - std::os::unix::fs::symlink(&external, &native).unwrap(); - let imported = SkillService::import_from_apps( - &db, - vec![ImportSkillSelection { - directory: "native-skill".into(), - apps: SkillApps::default(), - }], - ) - .unwrap(); - let ssot = SkillService::get_ssot_dir().unwrap().join("native-skill"); - assert_eq!(fs::read_link(&native).unwrap(), ssot); - SkillService::toggle_app(&db, &imported[0].id, &AppType::Omp, false).unwrap(); - assert!(!native.exists()); - assert!(external.join("SKILL.md").exists()); - let conflicting = SkillService::get_app_skills_dir(&AppType::Omp) - .unwrap() - .join("different"); - write_skill(&conflicting, "authored"); - write_skill( - &SkillService::get_ssot_dir().unwrap().join("different"), - "managed", - ); - assert!(SkillService::import_from_apps( - &db, - vec![ImportSkillSelection { - directory: "different".into(), - apps: SkillApps::default(), - }] - ) - .is_err()); - assert!(!db - .get_all_installed_skills() - .unwrap() - .values() - .any(|skill| skill.directory == "different")); - assert!(fs::read_to_string(conflicting.join("SKILL.md")) - .unwrap() - .contains("name: authored")); - } - - #[test] - #[serial_test::serial] - fn omp_unclaimed_skill_survives_toggle_resync_and_uninstall() { - let temp = tempdir().unwrap(); - let _home = TestHomeGuard::set(temp.path()); - let _omp = crate::omp_config::test_support::TestAgentDir::new(); - let db = Arc::new(Database::memory().unwrap()); - let skill = poisoned_skill("owner/repo:skill", "same-name"); - db.save_skill(&skill).unwrap(); - let ssot = SkillService::get_ssot_dir().unwrap().join(&skill.directory); - let native = SkillService::get_app_skills_dir(&AppType::Omp) - .unwrap() - .join(&skill.directory); - write_skill(&ssot, "managed"); - write_skill(&native, "authored"); - let original = fs::read(native.join("SKILL.md")).unwrap(); - assert!(!SkillService::get_all_installed(&db).unwrap()[0].apps.omp); - for enabled in [true, false] { - assert!(SkillService::toggle_app(&db, &skill.id, &AppType::Omp, enabled).is_err()); - assert_eq!(fs::read(native.join("SKILL.md")).unwrap(), original); - } - SkillService::sync_to_app(&db, &AppType::Omp).unwrap(); - assert_eq!(fs::read(native.join("SKILL.md")).unwrap(), original); - SkillService::uninstall(&db, &skill.id).unwrap(); - assert!(!ssot.exists()); - assert_eq!(fs::read(native.join("SKILL.md")).unwrap(), original); - } - - #[test] - #[serial_test::serial] - fn omp_modified_managed_skill_rejects_resync_and_uninstall() { - let temp = tempdir().unwrap(); - let _home = TestHomeGuard::set(temp.path()); - let _omp = crate::omp_config::test_support::TestAgentDir::new(); - let db = Arc::new(Database::memory().unwrap()); - let mut skill = poisoned_skill("owner/repo:skill", "same-name"); - skill.apps.omp = true; - db.save_skill(&skill).unwrap(); - let ssot = SkillService::get_ssot_dir().unwrap().join(&skill.directory); - let native = SkillService::get_app_skills_dir(&AppType::Omp) - .unwrap() - .join(&skill.directory); - write_skill(&ssot, "managed"); - write_skill(&native, "edited"); - let outcome = SkillService::resync_all_apps(&db) - .into_iter() - .find(|item| item.app == "omp") - .unwrap(); - assert!(!outcome.ok); - assert!(SkillService::uninstall(&db, &skill.id).is_err()); - assert!(db.get_installed_skill(&skill.id).unwrap().is_some()); - assert!(ssot.exists()); - assert!(fs::read_to_string(native.join("SKILL.md")) - .unwrap() - .contains("name: edited")); - write_skill(&native, "managed"); - SkillService::toggle_app(&db, &skill.id, &AppType::Omp, false).unwrap(); - assert!(!native.exists()); - assert!(!db.get_installed_skill(&skill.id).unwrap().unwrap().apps.omp); - SkillService::toggle_app(&db, &skill.id, &AppType::Omp, true).unwrap(); - assert!(native.exists()); - SkillService::uninstall(&db, &skill.id).unwrap(); - assert!(!native.exists()); - } - - #[test] - #[serial_test::serial] - fn omp_copy_update_keeps_ownership_after_ssot_replacement() { - let temp = tempdir().unwrap(); - let _home = TestHomeGuard::set(temp.path()); - let _omp = crate::omp_config::test_support::TestAgentDir::new(); - let ssot = SkillService::get_ssot_dir().unwrap().join("test-skill"); - let source = temp.path().join("download"); - let native = SkillService::get_app_skills_dir(&AppType::Omp) - .unwrap() - .join("test-skill"); - write_skill(&ssot, "old"); - write_skill(&source, "new"); - SkillService::copy_dir_recursive(&ssot, &native).unwrap(); - let deployment = SkillService::inspect_pi_skill_destination(&ssot, &native, "test-skill") - .unwrap() - .unwrap(); - let result: Result<()> = SkillService::update_owned_skill_files( - &source, - &ssot, - "test-skill", - &[(native.clone(), deployment)], - &[AppType::Omp], - || Err(anyhow!("metadata failure")), - ); - assert!(result.is_err()); - assert!(fs::read_to_string(ssot.join("SKILL.md")) - .unwrap() - .contains("name: old")); - SkillService::ensure_pi_skill_destination_matches(&ssot, &native, "test-skill").unwrap(); - let deployment = SkillService::inspect_pi_skill_destination(&ssot, &native, "test-skill") - .unwrap() - .unwrap(); - SkillService::update_owned_skill_files( - &source, - &ssot, - "test-skill", - &[(native.clone(), deployment)], - &[AppType::Omp], - || Ok(()), - ) - .unwrap(); - assert!(fs::read_to_string(native.join("SKILL.md")) - .unwrap() - .contains("name: new")); - assert!(!SkillService::get_app_skills_dir(&AppType::Mcode) - .unwrap() - .join("test-skill") - .exists()); - } - - #[test] - #[serial_test::serial] - fn importing_a_native_omp_skill_returns_its_derived_active_state() { - let temp = tempdir().expect("tempdir"); - let _home = TestHomeGuard::set(temp.path()); - let _omp_dir = crate::omp_config::test_support::TestAgentDir::new(); - let db = std::sync::Arc::new(Database::memory().expect("memory db")); - let omp_skill = SkillService::get_app_skills_dir(&AppType::Omp) - .unwrap() - .join("native-skill"); - write_skill(&omp_skill, "native"); - - let imported = SkillService::import_from_apps( - &db, - vec![ImportSkillSelection { - directory: "native-skill".to_string(), - apps: SkillApps::default(), - }], - ) - .expect("import native Omp skill"); - - assert_eq!(imported.len(), 1); - assert!(imported[0].apps.omp); - assert!(SkillService::get_all_installed(&db).unwrap()[0].apps.omp); - } - #[test] #[serial_test::serial] fn uninstall_preserves_an_external_pi_skill_and_removes_the_managed_record() { @@ -7609,7 +7371,7 @@ mod tests { fs::write(secrets.join("id_rsa"), b"PRIVATE").expect("write secret"); let skill = poisoned_skill("owner/repo:evil", "../../secrets"); - let result = SkillService::resolve_uninstall_backup_source_excluding(&skill, None); + let result = SkillService::resolve_uninstall_backup_source_excluding(&skill, &[]); assert!( result.is_err(), diff --git a/src-tauri/tests/provider_service.rs b/src-tauri/tests/provider_service.rs index bdfb83cf7..1f01f749d 100644 --- a/src-tauri/tests/provider_service.rs +++ b/src-tauri/tests/provider_service.rs @@ -3702,7 +3702,7 @@ fn codex_editor_official_auth_shows_disk_login_and_keeps_row_auth() { } #[test] -fn omp_edit_save_preserves_comments_without_false_conflict() { +fn omp_edit_save_keeps_every_byte_outside_the_edited_entry() { use cc_switch_lib::update_settings; use std::fs; @@ -3712,9 +3712,10 @@ fn omp_edit_save_preserves_comments_without_false_conflict() { let agent_dir = home.join(".omp/agent"); fs::create_dir_all(&agent_dir).unwrap(); let models_path = agent_dir.join("models.yml"); + let sibling = " # hand-written sibling\n other:\n baseUrl: https://other.test/v1 # keep\n"; fs::write( &models_path, - "# managed by me\nproviders:\n duolalab:\n # proxy endpoint\n baseUrl: https://api.duolalab.test/v1\n api: openai-completions\n apiKey: sk-live\n # extra unmanaged field\n auth: none\n models:\n - id: glm-4.7\n name: GLM 4.7\n", + format!("# managed by me\nproviders:\n duolalab:\n baseUrl: https://api.duolalab.test/v1\n api: openai-completions\n apiKey: sk-live\n models:\n - id: glm-4.7\n{sibling}"), ) .unwrap(); update_settings(cc_switch_lib::AppSettings { @@ -3724,53 +3725,20 @@ fn omp_edit_save_preserves_comments_without_false_conflict() { .unwrap(); let state = create_test_state().expect("create test state"); - // list() syncs the DB row from models.yml and captures the raw entry. - let providers = ProviderService::list(&state, AppType::Omp).expect("list"); - let original = providers.get("duolalab").expect("provider").clone(); - assert!(original - .meta - .as_ref() - .and_then(|meta| meta.omp_passthrough_yaml.as_deref()) - .expect("raw entry captured") - .contains("# proxy endpoint")); - - // Edit: the form rebuilds settingsConfig (same semantics) and keeps the - // edited passthrough text; user only tweaked a comment. - let raw = original - .meta - .as_ref() - .and_then(|meta| meta.omp_passthrough_yaml.clone()) - .unwrap(); - let edited_raw = raw.replace("# proxy endpoint", "# edited comment"); - let mut edited = original.clone(); - edited.settings_config["name"] = json!("Duolalab"); - edited - .meta - .get_or_insert_with(ProviderMeta::default) - .omp_passthrough_yaml = Some(edited_raw); - - ProviderService::update(&state, AppType::Omp, Some("duolalab"), edited) - .expect("update without false conflict"); - - let after = fs::read_to_string(&models_path).unwrap(); - assert!(after.contains("# edited comment"), "{after}"); - assert!(after.contains("# extra unmanaged field"), "{after}"); - assert!(after.contains("# managed by me"), "{after}"); - assert!(after.contains("name: Duolalab"), "{after}"); - - // External edit after the DB sync still conflicts. - let synced = ProviderService::list(&state, AppType::Omp) - .expect("list again") + let mut edited = ProviderService::list(&state, AppType::Omp) + .expect("list") .get("duolalab") .cloned() - .expect("provider"); - fs::write(&models_path, after.replace("sk-live", "sk-external")).unwrap(); - let mut stale = synced.clone(); - stale.settings_config["name"] = json!("Stale"); - let err = ProviderService::update(&state, AppType::Omp, Some("duolalab"), stale) - .expect_err("external edit must conflict"); + .expect("provider imported from models.yml"); + edited.settings_config["name"] = json!("Duolalab"); + ProviderService::update(&state, AppType::Omp, Some("duolalab"), edited) + .expect("edit save must not conflict"); + + let after = fs::read_to_string(&models_path).unwrap(); assert!( - err.to_string().contains("changed outside CC Switch"), - "{err}" + after.starts_with("# managed by me\nproviders:\n duolalab:\n"), + "{after}" ); + assert!(after.contains("name: Duolalab"), "{after}"); + assert!(after.ends_with(sibling), "{after}"); }