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 <agent>/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 <s23372.xu@stu.scie.com.cn>
This commit is contained in:
Jasonandallenxu09 authored and Jason Young committed 2026-10-24 15:30:32 +08:00
1 parent 2afe8cdbef
commit 3d7cf6aa32
23 files changed
+1680 -3583

No files matched your search

-2
View File
@@ -29,5 +29,3 @@ copilot-api
.history
CODEBUDDY.md
mainWindow.js
openspec
.*/
+4 -5
View File
@@ -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::<AppType>().unwrap(), AppType::Omp);
assert_eq!("oh-my-pi".parse::<AppType>().unwrap(), AppType::Omp);
assert_eq!("oh_my_pi".parse::<AppType>().unwrap(), AppType::Omp);
assert_eq!("omp".parse::<AppType>().unwrap(), AppType::Omp);
assert_eq!("ohmypi".parse::<AppType>().unwrap(), AppType::Omp);
assert_eq!("OMP".parse::<AppType>().unwrap(), AppType::Omp);
assert_eq!(AppType::Omp.as_str(), "omp");
}
+60 -13
View File
@@ -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<OmpCurrentState, String> {
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<PiCurrentState, AppError> {
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<bool, String> {
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<PiCurrentState, String> {
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"));
}
}
+5 -1
View File
@@ -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<PiCurre
#[tauri::command]
pub(crate) fn update_pi_provider_usage_script(
state: State<'_, AppState>,
app: String,
id: String,
#[allow(non_snake_case)] usageScript: UsageScript,
) -> Result<bool, String> {
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())
}
+24 -25
View File
@@ -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)
+1 -1
View File
@@ -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<T: Serialize>(value: &T) -> Result<String, AppError> {
+12 -10
View File
@@ -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}"
+15 -51
View File
@@ -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));
}
-1
View File
@@ -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,
+2 -21
View File
@@ -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<usize, AppError> {
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
}
+3 -2
View File
@@ -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<usize, AppError> {
}
}
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()));
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
-608
View File
@@ -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<String, Value>, AppError> {
value.as_object().ok_or_else(|| invalid(path, "an object"))
}
fn fields(
value: &Map<String, Value>,
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<String, Value>, 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<String, Value>,
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<String, Value>,
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}"
);
}
}
}
-8
View File
@@ -622,14 +622,6 @@ pub struct ProviderMeta {
skip_serializing_if = "Option::is_none"
)]
pub stack_models: Option<Vec<ClaudeStackModel>>,
/// Oh My Pi:编辑器里“其他字段(YAML,原样保留)”的原文(含注释与顺序)。
/// 仅存于数据库,写 models.yml 时用于文本级定点替换,不参与其他应用。
#[serde(
rename = "ompPassthroughYaml",
default,
skip_serializing_if = "Option::is_none"
)]
pub omp_passthrough_yaml: Option<String>,
}
/// Stack 模式下 Claude Code 供应商发布的一个模型。
-1
View File
@@ -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;
-100
View File
@@ -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<String>,
pub default_provider_id: Option<String>,
}
pub(crate) struct OmpStateService;
impl OmpStateService {
pub(crate) fn current(state: &AppState) -> Result<OmpCurrentState, AppError> {
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::<Vec<_>>();
// `modelRoles.default` is a `<provider>/<model>` 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"));
}
}
+1 -1
View File
@@ -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),
}
}
+3 -10
View File
@@ -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<bool, AppError> {
pi::update_usage_script(state, id, script)
}
pub(crate) fn update_omp_usage_script(
state: &AppState,
id: &str,
script: crate::provider::UsageScript,
) -> Result<bool, AppError> {
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)?;
}
}
+248 -278
View File
@@ -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<IndexMap<String, Provider>, 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<String> = 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<usize, AppError> {
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<usize, AppError> {
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<bool, AppError> {
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<bool, AppError> {
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<bool, AppError> {
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<SwitchResult, 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")))?;
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<String, Value>,
) -> Result<usize, AppError> {
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);
}
}
+17 -4
View File
@@ -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<bool, AppError> {
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")
+159 -397
View File
@@ -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<PathBuf> = 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(&current_skill.directory)?;
current_skill.apps.pi = Self::skill_exists_in_app(&current_skill.directory, &AppType::Pi);
current_skill.apps.omp = Self::skill_exists_in_app(&current_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::<Result<Vec<_>>>()?,
|| {
@@ -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<T>(
fn update_mcode_skill_files<T>(
source: &Path,
ssot: &Path,
directory: &str,
deployments: &[(PathBuf, PiSkillDeployment)],
native_apps: &[AppType],
commit: impl FnOnce() -> Result<T>,
) -> Result<T> {
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::<Result<Vec<_>>>()?,
&[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<Database>) -> Vec<SkillAppSyncOutcome> {
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<Option<PathBuf>> {
// 返回值会被整目录复制进 ~/.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<Option<PathBuf>> {
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<Option<PathBuf>> {
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(),
+15 -47
View File
@@ -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}");
}