mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-10-25 17:22:27 +08:00
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:
1 parent
2afe8cdbef
commit
3d7cf6aa32
23 files changed
+1680
-3583
No files matched your search
@@ -29,5 +29,3 @@ copilot-api
|
||||
.history
|
||||
CODEBUDDY.md
|
||||
mainWindow.js
|
||||
openspec
|
||||
.*/
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 供应商发布的一个模型。
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)?;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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(¤t_skill.directory)?;
|
||||
current_skill.apps.pi = Self::skill_exists_in_app(¤t_skill.directory, &AppType::Pi);
|
||||
current_skill.apps.omp = Self::skill_exists_in_app(¤t_skill.directory, &AppType::Omp);
|
||||
let skill = current_skill;
|
||||
|
||||
let dest = ssot_dir.join(&skill.directory);
|
||||
let mut deployments = Vec::new();
|
||||
for app in [
|
||||
AppType::Pi,
|
||||
AppType::Omp,
|
||||
AppType::Mcode,
|
||||
AppType::Agy,
|
||||
AppType::Kimi,
|
||||
AppType::Omp,
|
||||
] {
|
||||
if skill.apps.is_enabled_for(&app) {
|
||||
let destination =
|
||||
@@ -1645,11 +1655,7 @@ impl SkillService {
|
||||
// 备份旧文件
|
||||
let _ = Self::create_uninstall_backup(&skill);
|
||||
|
||||
let owned_apps: Vec<_> = [AppType::Mcode, AppType::Agy, AppType::Kimi, AppType::Omp]
|
||||
.into_iter()
|
||||
.filter(|app| skill.apps.is_enabled_for(app))
|
||||
.collect();
|
||||
if owned_apps.is_empty() {
|
||||
if !(skill.apps.mcode || skill.apps.agy || skill.apps.kimi) {
|
||||
if dest.exists() {
|
||||
fs::remove_dir_all(&dest)?;
|
||||
}
|
||||
@@ -1657,7 +1663,7 @@ impl SkillService {
|
||||
}
|
||||
|
||||
// 计算新哈希 + 解析新元数据
|
||||
let metadata_source = if !owned_apps.is_empty() {
|
||||
let metadata_source = if skill.apps.mcode || skill.apps.agy || skill.apps.kimi {
|
||||
&source
|
||||
} else {
|
||||
&dest
|
||||
@@ -1689,14 +1695,15 @@ impl SkillService {
|
||||
updated_at: chrono::Utc::now().timestamp(),
|
||||
};
|
||||
|
||||
let mut updated_skill = if !owned_apps.is_empty() {
|
||||
let mut updated_skill = if skill.apps.mcode || skill.apps.agy || skill.apps.kimi {
|
||||
Self::update_managed_skill_files(
|
||||
&source,
|
||||
&dest,
|
||||
&skill.directory,
|
||||
&deployments,
|
||||
&owned_apps
|
||||
&[AppType::Mcode, AppType::Agy, AppType::Kimi]
|
||||
.iter()
|
||||
.filter(|app| skill.apps.is_enabled_for(app))
|
||||
.map(|app| Self::get_app_skills_dir(app).map(|dir| dir.join(&skill.directory)))
|
||||
.collect::<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(),
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
Reference in new issue
Block a user