Files
cc-switch/src-tauri/src/database/dao/skills.rs
T
Jasonandallenxu09 3d7cf6aa32 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>
2026-10-24 15:30:32 +08:00

418 lines
16 KiB
Rust

//! Skills 数据访问对象
//!
//! 提供 Skills 和 Skill Repos 的 CRUD 操作。
//!
//! v3.10.0+ 统一管理架构:
//! - Skills 使用统一的 id 主键,支持四应用启用标志
//! - 实际文件存储在 ~/.cc-switch/skills/,同步到各应用目录
use crate::app_config::{InstalledSkill, SkillApps};
use crate::database::{lock_conn, Database};
use crate::error::AppError;
use crate::services::skill::SkillRepo;
use indexmap::IndexMap;
use rusqlite::params;
impl Database {
// ========== InstalledSkill CRUD ==========
/// 获取所有已安装的 Skills
pub fn get_all_installed_skills(&self) -> Result<IndexMap<String, InstalledSkill>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn
.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, 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()))?;
let skill_iter = stmt
.query_map([], |row| {
Ok(InstalledSkill {
id: row.get(0)?,
name: row.get(1)?,
description: row.get(2)?,
directory: row.get(3)?,
repo_owner: row.get(4)?,
repo_name: row.get(5)?,
repo_branch: row.get(6)?,
readme_url: row.get(7)?,
apps: SkillApps {
claude: row.get(8)?,
codex: row.get(9)?,
gemini: row.get(10)?,
grokbuild: row.get(11)?,
opencode: row.get(12)?,
hermes: row.get(13)?,
pi: false,
omp: false,
mcode: row.get(17)?,
agy: row.get(18)?,
kimi: row.get(19)?,
},
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()))?;
let mut skills = IndexMap::new();
for skill_res in skill_iter {
let skill = skill_res.map_err(|e| AppError::Database(e.to_string()))?;
skills.insert(skill.id.clone(), skill);
}
Ok(skills)
}
/// 获取单个已安装的 Skill
pub fn get_installed_skill(&self, id: &str) -> Result<Option<InstalledSkill>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn
.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, 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()))?;
let result = stmt.query_row([id], |row| {
Ok(InstalledSkill {
id: row.get(0)?,
name: row.get(1)?,
description: row.get(2)?,
directory: row.get(3)?,
repo_owner: row.get(4)?,
repo_name: row.get(5)?,
repo_branch: row.get(6)?,
readme_url: row.get(7)?,
apps: SkillApps {
claude: row.get(8)?,
codex: row.get(9)?,
gemini: row.get(10)?,
grokbuild: row.get(11)?,
opencode: row.get(12)?,
hermes: row.get(13)?,
pi: false,
omp: false,
mcode: row.get(17)?,
agy: row.get(18)?,
kimi: row.get(19)?,
},
installed_at: row.get(14)?,
content_hash: row.get(15)?,
updated_at: row.get::<_, i64>(16).unwrap_or(0),
})
});
match result {
Ok(skill) => Ok(Some(skill)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(AppError::Database(e.to_string())),
}
}
/// 保存 Skill(添加或更新)
pub fn save_skill(&self, skill: &InstalledSkill) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
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,
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,
skill.description,
skill.directory,
skill.repo_owner,
skill.repo_name,
skill.repo_branch,
skill.readme_url,
skill.apps.claude,
skill.apps.codex,
skill.apps.gemini,
skill.apps.grokbuild,
skill.apps.opencode,
skill.apps.hermes,
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()))?;
Ok(())
}
/// 仅更新已安装 Skill 的元数据,不修改各应用的启用状态。
///
/// 与 [`Self::save_skill`] 不同,本方法不会插入缺失记录。更新操作可能在网络
/// 下载期间与启用状态切换或卸载并发发生,因此调用方必须保留数据库中的
/// `enabled_*` 字段,并在记录已被删除时停止后续处理。
pub fn update_skill_metadata(&self, skill: &InstalledSkill) -> Result<bool, AppError> {
let conn = lock_conn!(self.conn);
let affected = conn
.execute(
"UPDATE skills
SET name = ?1,
description = ?2,
directory = ?3,
repo_owner = ?4,
repo_name = ?5,
repo_branch = ?6,
readme_url = ?7,
installed_at = ?8,
content_hash = ?9,
updated_at = ?10
WHERE id = ?11 AND installed_at = ?12",
params![
skill.name,
skill.description,
skill.directory,
skill.repo_owner,
skill.repo_name,
skill.repo_branch,
skill.readme_url,
skill.installed_at,
skill.content_hash,
skill.updated_at,
skill.id,
skill.installed_at,
],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(affected > 0)
}
/// 删除 Skill
pub fn delete_skill(&self, id: &str) -> Result<bool, AppError> {
let conn = lock_conn!(self.conn);
let affected = conn
.execute("DELETE FROM skills WHERE id = ?1", params![id])
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(affected > 0)
}
/// 清空所有 Skills(用于迁移)
pub fn clear_skills(&self) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute("DELETE FROM skills", [])
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
/// 更新 Skill 的应用启用状态
pub fn update_skill_apps(&self, id: &str, apps: &SkillApps) -> Result<bool, AppError> {
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_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)
}
/// 更新 Skill 的内容哈希和更新时间
pub fn update_skill_hash(
&self,
id: &str,
content_hash: &str,
updated_at: i64,
) -> Result<bool, AppError> {
let conn = lock_conn!(self.conn);
let affected = conn
.execute(
"UPDATE skills SET content_hash = ?1, updated_at = ?2 WHERE id = ?3",
params![content_hash, updated_at, id],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(affected > 0)
}
// ========== SkillRepo CRUD(保持原有) ==========
/// 获取所有 Skill 仓库
pub fn get_skill_repos(&self) -> Result<Vec<SkillRepo>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn
.prepare(
"SELECT owner, name, branch, enabled FROM skill_repos ORDER BY owner ASC, name ASC",
)
.map_err(|e| AppError::Database(e.to_string()))?;
let repo_iter = stmt
.query_map([], |row| {
Ok(SkillRepo {
owner: row.get(0)?,
name: row.get(1)?,
branch: row.get(2)?,
enabled: row.get(3)?,
})
})
.map_err(|e| AppError::Database(e.to_string()))?;
let mut repos = Vec::new();
for repo_res in repo_iter {
repos.push(repo_res.map_err(|e| AppError::Database(e.to_string()))?);
}
Ok(repos)
}
/// 保存 Skill 仓库
pub fn save_skill_repo(&self, repo: &SkillRepo) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"INSERT OR REPLACE INTO skill_repos (owner, name, branch, enabled) VALUES (?1, ?2, ?3, ?4)",
params![repo.owner, repo.name, repo.branch, repo.enabled],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
/// 删除 Skill 仓库
pub fn delete_skill_repo(&self, owner: &str, name: &str) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"DELETE FROM skill_repos WHERE owner = ?1 AND name = ?2",
params![owner, name],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
/// 初始化默认的 Skill 仓库(启动时调用,每个数据库仅执行一次)
pub fn init_default_skill_repos(&self) -> Result<usize, AppError> {
const INITIALIZED_KEY: &str = "default_skill_repos_initialized";
if self.get_bool_flag(INITIALIZED_KEY)? {
return Ok(0);
}
// 兼容升级前已经存在的用户选择,并记录初始化状态,避免以后删空后恢复默认值。
if !self.get_skill_repos()?.is_empty() {
self.set_setting(INITIALIZED_KEY, "true")?;
return Ok(0);
}
let default_store = crate::services::skill::SkillStore::default();
let mut count = 0;
for repo in &default_store.repos {
self.save_skill_repo(repo)?;
count += 1;
log::info!("初始化默认 Skill 仓库: {}/{}", repo.owner, repo.name);
}
self.set_setting(INITIALIZED_KEY, "true")?;
Ok(count)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app_config::AppType;
fn skill(id: &str, name: &str, apps: SkillApps) -> InstalledSkill {
InstalledSkill {
id: id.to_string(),
name: name.to_string(),
description: Some(format!("{name} description")),
directory: format!("{name}-directory"),
repo_owner: Some("owner".to_string()),
repo_name: Some("repo".to_string()),
repo_branch: Some("main".to_string()),
readme_url: Some(format!("https://example.com/{name}")),
apps,
installed_at: 1,
content_hash: Some(format!("{name}-hash")),
updated_at: 2,
}
}
#[test]
fn update_skill_metadata_preserves_enabled_apps() {
let db = Database::memory().expect("memory db");
let installed_apps = SkillApps::only(&AppType::Codex);
let original = skill("owner/repo:skill", "original", installed_apps.clone());
db.save_skill(&original).expect("seed skill");
let mut candidate = skill(&original.id, "updated", SkillApps::only(&AppType::Claude));
candidate.repo_branch = Some("next".to_string());
candidate.updated_at = 42;
assert!(db
.update_skill_metadata(&candidate)
.expect("update metadata"));
let stored = db
.get_installed_skill(&original.id)
.expect("query skill")
.expect("skill remains installed");
assert_eq!(stored.name, candidate.name);
assert_eq!(stored.description, candidate.description);
assert_eq!(stored.directory, candidate.directory);
assert_eq!(stored.repo_branch, candidate.repo_branch);
assert_eq!(stored.readme_url, candidate.readme_url);
assert_eq!(stored.content_hash, candidate.content_hash);
assert_eq!(stored.updated_at, candidate.updated_at);
assert_eq!(stored.apps, installed_apps);
}
#[test]
fn update_skill_metadata_does_not_insert_missing_skill() {
let db = Database::memory().expect("memory db");
let candidate = skill(
"owner/repo:missing",
"missing",
SkillApps::only(&AppType::Claude),
);
assert!(!db
.update_skill_metadata(&candidate)
.expect("missing update is not an error"));
assert!(db
.get_installed_skill(&candidate.id)
.expect("query skill")
.is_none());
}
#[test]
fn update_skill_metadata_does_not_touch_reinstalled_generation() {
let db = Database::memory().expect("memory db");
let stale_update = skill(
"owner/repo:skill",
"stale-update",
SkillApps::only(&AppType::Claude),
);
let mut reinstalled = skill(
&stale_update.id,
"reinstalled",
SkillApps::only(&AppType::Gemini),
);
reinstalled.installed_at = stale_update.installed_at + 1;
db.save_skill(&reinstalled).expect("seed reinstalled skill");
assert!(!db
.update_skill_metadata(&stale_update)
.expect("stale generation update is not an error"));
let stored = db
.get_installed_skill(&reinstalled.id)
.expect("query skill")
.expect("reinstalled generation remains");
assert_eq!(stored.name, reinstalled.name);
assert_eq!(stored.installed_at, reinstalled.installed_at);
assert_eq!(stored.apps, reinstalled.apps);
}
}