mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-26 23:56:02 +08:00
feat(templates): add Claude Code Templates marketplace
- Add template repository management and component discovery - Implement template installation for agents, commands, hooks, MCPs, skills, settings - Support multi-app installation (Claude/Codex/Gemini) - Add frontend components for browsing and installing templates - Include i18n translations for zh/en/ja
This commit is contained in:
Generated
+1
@@ -721,6 +721,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"serial_test",
|
||||
"sha2",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-deep-link",
|
||||
|
||||
@@ -61,6 +61,7 @@ rusqlite = { version = "0.31", features = ["bundled", "backup"] }
|
||||
indexmap = { version = "2", features = ["serde"] }
|
||||
rust_decimal = "1.33"
|
||||
uuid = { version = "1.11", features = ["v4"] }
|
||||
sha2 = "0.10"
|
||||
|
||||
[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies]
|
||||
tauri-plugin-single-instance = "2"
|
||||
|
||||
@@ -14,6 +14,7 @@ mod proxy;
|
||||
mod settings;
|
||||
pub mod skill;
|
||||
mod stream_check;
|
||||
mod template;
|
||||
mod usage;
|
||||
|
||||
pub use config::*;
|
||||
@@ -30,4 +31,5 @@ pub use proxy::*;
|
||||
pub use settings::*;
|
||||
pub use skill::*;
|
||||
pub use stream_check::*;
|
||||
pub use template::*;
|
||||
pub use usage::*;
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
use tauri::State;
|
||||
|
||||
use crate::database::lock_conn;
|
||||
use crate::error::AppError;
|
||||
use crate::services::{
|
||||
BatchInstallResult, ComponentDetail, InstalledComponent, PaginatedResult, TemplateComponent,
|
||||
TemplateRepo, TemplateService,
|
||||
};
|
||||
use crate::store::AppState;
|
||||
|
||||
/// 刷新模板索引
|
||||
#[tauri::command]
|
||||
pub async fn refresh_template_index(state: State<'_, AppState>) -> Result<(), String> {
|
||||
let service = TemplateService::new().map_err(|e| e.to_string())?;
|
||||
let db = state.db.clone();
|
||||
|
||||
// 使用 spawn_blocking 在后台线程中执行数据库操作
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let conn = lock_conn!(db.conn);
|
||||
let rt = tokio::runtime::Handle::current();
|
||||
rt.block_on(async {
|
||||
service
|
||||
.refresh_index(&conn)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("任务执行失败: {e}"))??;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取模板组件列表
|
||||
#[tauri::command]
|
||||
pub fn list_template_components(
|
||||
state: State<'_, AppState>,
|
||||
component_type: Option<String>,
|
||||
category: Option<String>,
|
||||
search: Option<String>,
|
||||
page: u32,
|
||||
page_size: u32,
|
||||
app_type: Option<String>,
|
||||
) -> Result<PaginatedResult<TemplateComponent>, AppError> {
|
||||
let (mut components, total) = state.db.list_components(
|
||||
component_type.as_deref(),
|
||||
category.as_deref(),
|
||||
search.as_deref(),
|
||||
page,
|
||||
page_size,
|
||||
)?;
|
||||
|
||||
// 填充 installed 字段
|
||||
if let Some(app) = &app_type {
|
||||
let installed_ids = state.db.get_installed_component_ids(app)?;
|
||||
for component in &mut components {
|
||||
if let Some(id) = component.id {
|
||||
component.installed = installed_ids.contains(&id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(PaginatedResult {
|
||||
items: components,
|
||||
total: total as i64,
|
||||
page,
|
||||
page_size,
|
||||
})
|
||||
}
|
||||
|
||||
/// 获取组件详情
|
||||
#[tauri::command]
|
||||
pub async fn get_template_component(
|
||||
state: State<'_, AppState>,
|
||||
id: i64,
|
||||
) -> Result<ComponentDetail, String> {
|
||||
let service = TemplateService::new().map_err(|e| e.to_string())?;
|
||||
let db = state.db.clone();
|
||||
|
||||
let detail = tokio::task::spawn_blocking(move || {
|
||||
let conn = lock_conn!(db.conn);
|
||||
let rt = tokio::runtime::Handle::current();
|
||||
rt.block_on(async {
|
||||
service
|
||||
.get_component(&conn, id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("任务执行失败: {e}"))??;
|
||||
|
||||
Ok(detail)
|
||||
}
|
||||
|
||||
/// 安装组件
|
||||
#[tauri::command]
|
||||
pub async fn install_template_component(
|
||||
state: State<'_, AppState>,
|
||||
id: i64,
|
||||
app_type: String,
|
||||
) -> Result<(), String> {
|
||||
let service = TemplateService::new().map_err(|e| e.to_string())?;
|
||||
let db = state.db.clone();
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let conn = lock_conn!(db.conn);
|
||||
let rt = tokio::runtime::Handle::current();
|
||||
rt.block_on(async {
|
||||
service
|
||||
.install_component(&conn, id, &app_type)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("任务执行失败: {e}"))??;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 卸载组件
|
||||
#[tauri::command]
|
||||
pub fn uninstall_template_component(
|
||||
state: State<'_, AppState>,
|
||||
id: i64,
|
||||
app_type: String,
|
||||
) -> Result<(), AppError> {
|
||||
let service = TemplateService::new().map_err(|e| AppError::Config(e.to_string()))?;
|
||||
let conn = lock_conn!(state.db.conn);
|
||||
|
||||
service
|
||||
.uninstall_component(&conn, id, &app_type)
|
||||
.map_err(|e| AppError::Config(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 批量安装组件
|
||||
#[tauri::command]
|
||||
pub async fn batch_install_template_components(
|
||||
state: State<'_, AppState>,
|
||||
ids: Vec<i64>,
|
||||
app_type: String,
|
||||
) -> Result<BatchInstallResult, String> {
|
||||
let service = TemplateService::new().map_err(|e| e.to_string())?;
|
||||
let db = state.db.clone();
|
||||
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let conn = lock_conn!(db.conn);
|
||||
let rt = tokio::runtime::Handle::current();
|
||||
rt.block_on(async {
|
||||
service
|
||||
.batch_install(&conn, ids, &app_type)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("任务执行失败: {e}"))??;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// 获取模板仓库列表
|
||||
#[tauri::command]
|
||||
pub fn list_template_repos(state: State<'_, AppState>) -> Result<Vec<TemplateRepo>, AppError> {
|
||||
state.db.list_repos()
|
||||
}
|
||||
|
||||
/// 添加模板仓库
|
||||
#[tauri::command]
|
||||
pub fn add_template_repo(
|
||||
state: State<'_, AppState>,
|
||||
owner: String,
|
||||
name: String,
|
||||
branch: String,
|
||||
) -> Result<i64, AppError> {
|
||||
let repo = TemplateRepo::new(owner, name, branch);
|
||||
state.db.insert_repo(&repo)
|
||||
}
|
||||
|
||||
/// 删除模板仓库
|
||||
#[tauri::command]
|
||||
pub fn remove_template_repo(state: State<'_, AppState>, id: i64) -> Result<(), AppError> {
|
||||
state.db.delete_repo(id)
|
||||
}
|
||||
|
||||
/// 切换仓库启用状态
|
||||
#[tauri::command]
|
||||
pub fn toggle_template_repo(
|
||||
state: State<'_, AppState>,
|
||||
id: i64,
|
||||
enabled: bool,
|
||||
) -> Result<(), AppError> {
|
||||
state.db.toggle_repo_enabled(id, enabled)
|
||||
}
|
||||
|
||||
/// 获取组件分类列表
|
||||
#[tauri::command]
|
||||
pub fn list_template_categories(
|
||||
state: State<'_, AppState>,
|
||||
component_type: Option<String>,
|
||||
) -> Result<Vec<String>, AppError> {
|
||||
let conn = lock_conn!(state.db.conn);
|
||||
|
||||
// 构建查询语句
|
||||
let sql = if let Some(ct) = component_type {
|
||||
format!(
|
||||
"SELECT DISTINCT category FROM template_components WHERE component_type = '{ct}' AND category IS NOT NULL ORDER BY category"
|
||||
)
|
||||
} else {
|
||||
"SELECT DISTINCT category FROM template_components WHERE category IS NOT NULL ORDER BY category".to_string()
|
||||
};
|
||||
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let categories = stmt
|
||||
.query_map([], |row| row.get::<_, String>(0))?
|
||||
.collect::<Result<Vec<String>, _>>()?;
|
||||
|
||||
Ok(categories)
|
||||
}
|
||||
|
||||
/// 获取已安装组件列表
|
||||
#[tauri::command]
|
||||
pub fn list_installed_components(
|
||||
state: State<'_, AppState>,
|
||||
app_type: Option<String>,
|
||||
component_type: Option<String>,
|
||||
) -> Result<Vec<InstalledComponent>, AppError> {
|
||||
state
|
||||
.db
|
||||
.list_installed_components(app_type.as_deref(), component_type.as_deref())
|
||||
}
|
||||
|
||||
/// 预览组件内容
|
||||
#[tauri::command]
|
||||
pub async fn preview_component_content(
|
||||
state: State<'_, AppState>,
|
||||
id: i64,
|
||||
) -> Result<String, String> {
|
||||
let service = TemplateService::new().map_err(|e| e.to_string())?;
|
||||
let db = state.db.clone();
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let conn = lock_conn!(db.conn);
|
||||
let rt = tokio::runtime::Handle::current();
|
||||
rt.block_on(async {
|
||||
service
|
||||
.preview_content(&conn, id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("任务执行失败: {e}"))?
|
||||
}
|
||||
|
||||
/// 获取市场组合列表
|
||||
#[tauri::command]
|
||||
pub async fn list_marketplace_bundles(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<crate::services::MarketplaceBundle>, String> {
|
||||
let service = TemplateService::new().map_err(|e| e.to_string())?;
|
||||
let db = state.db.clone();
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let conn = lock_conn!(db.conn);
|
||||
let rt = tokio::runtime::Handle::current();
|
||||
rt.block_on(async {
|
||||
service
|
||||
.fetch_marketplace_bundles(&conn)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("任务执行失败: {e}"))?
|
||||
}
|
||||
@@ -10,6 +10,7 @@ pub mod proxy;
|
||||
pub mod settings;
|
||||
pub mod skills;
|
||||
pub mod stream_check;
|
||||
pub mod template;
|
||||
|
||||
// 所有 DAO 方法都通过 Database impl 提供,无需单独导出
|
||||
// 导出 FailoverQueueItem 供外部使用
|
||||
|
||||
@@ -0,0 +1,595 @@
|
||||
//! Template 数据访问对象
|
||||
//!
|
||||
//! 提供 Template Repos、Template Components 和 Installed Components 的 CRUD 操作。
|
||||
|
||||
use crate::database::{lock_conn, Database};
|
||||
use crate::error::AppError;
|
||||
use crate::services::template::{
|
||||
ComponentType, InstalledComponent, TemplateComponent, TemplateRepo,
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use rusqlite::{params, OptionalExtension};
|
||||
|
||||
impl Database {
|
||||
// ==================== TemplateRepo 相关 ====================
|
||||
|
||||
/// 插入模板仓库
|
||||
pub fn insert_repo(&self, repo: &TemplateRepo) -> Result<i64, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO template_repos (owner, name, branch, enabled, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
params![repo.owner, repo.name, repo.branch, repo.enabled, now, now],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("插入模板仓库失败: {e}")))?;
|
||||
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
/// 获取单个模板仓库
|
||||
pub fn get_repo(&self, id: i64) -> Result<Option<TemplateRepo>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, owner, name, branch, enabled, created_at, updated_at
|
||||
FROM template_repos
|
||||
WHERE id = ?1",
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("准备查询模板仓库失败: {e}")))?;
|
||||
|
||||
let repo = stmt
|
||||
.query_row(params![id], |row| {
|
||||
Ok(TemplateRepo {
|
||||
id: Some(row.get(0)?),
|
||||
owner: row.get(1)?,
|
||||
name: row.get(2)?,
|
||||
branch: row.get(3)?,
|
||||
enabled: row.get(4)?,
|
||||
created_at: row
|
||||
.get::<_, String>(5)
|
||||
.ok()
|
||||
.and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
|
||||
.map(|dt| dt.with_timezone(&Utc)),
|
||||
updated_at: row
|
||||
.get::<_, String>(6)
|
||||
.ok()
|
||||
.and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
|
||||
.map(|dt| dt.with_timezone(&Utc)),
|
||||
})
|
||||
})
|
||||
.optional()
|
||||
.map_err(|e| AppError::Database(format!("查询模板仓库失败: {e}")))?;
|
||||
|
||||
Ok(repo)
|
||||
}
|
||||
|
||||
/// 获取所有模板仓库
|
||||
pub fn list_repos(&self) -> Result<Vec<TemplateRepo>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, owner, name, branch, enabled, created_at, updated_at
|
||||
FROM template_repos
|
||||
ORDER BY created_at DESC",
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("准备查询模板仓库列表失败: {e}")))?;
|
||||
|
||||
let repo_iter = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(TemplateRepo {
|
||||
id: Some(row.get(0)?),
|
||||
owner: row.get(1)?,
|
||||
name: row.get(2)?,
|
||||
branch: row.get(3)?,
|
||||
enabled: row.get(4)?,
|
||||
created_at: row
|
||||
.get::<_, String>(5)
|
||||
.ok()
|
||||
.and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
|
||||
.map(|dt| dt.with_timezone(&Utc)),
|
||||
updated_at: row
|
||||
.get::<_, String>(6)
|
||||
.ok()
|
||||
.and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
|
||||
.map(|dt| dt.with_timezone(&Utc)),
|
||||
})
|
||||
})
|
||||
.map_err(|e| AppError::Database(format!("查询模板仓库列表失败: {e}")))?;
|
||||
|
||||
let mut repos = Vec::new();
|
||||
for repo_res in repo_iter {
|
||||
repos.push(repo_res.map_err(|e| AppError::Database(format!("解析模板仓库失败: {e}")))?);
|
||||
}
|
||||
|
||||
Ok(repos)
|
||||
}
|
||||
|
||||
/// 更新模板仓库
|
||||
pub fn update_repo(&self, repo: &TemplateRepo) -> Result<(), AppError> {
|
||||
let repo_id = repo
|
||||
.id
|
||||
.ok_or_else(|| AppError::Database("仓库 ID 不能为空".to_string()))?;
|
||||
let conn = lock_conn!(self.conn);
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
conn.execute(
|
||||
"UPDATE template_repos
|
||||
SET owner = ?1, name = ?2, branch = ?3, enabled = ?4, updated_at = ?5
|
||||
WHERE id = ?6",
|
||||
params![
|
||||
repo.owner,
|
||||
repo.name,
|
||||
repo.branch,
|
||||
repo.enabled,
|
||||
now,
|
||||
repo_id
|
||||
],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("更新模板仓库失败: {e}")))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 删除模板仓库
|
||||
pub fn delete_repo(&self, id: i64) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
conn.execute("DELETE FROM template_repos WHERE id = ?1", params![id])
|
||||
.map_err(|e| AppError::Database(format!("删除模板仓库失败: {e}")))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 切换仓库启用状态
|
||||
pub fn toggle_repo_enabled(&self, id: i64, enabled: bool) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
conn.execute(
|
||||
"UPDATE template_repos SET enabled = ?1, updated_at = ?2 WHERE id = ?3",
|
||||
params![enabled, now, id],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("切换仓库启用状态失败: {e}")))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ==================== TemplateComponent 相关 ====================
|
||||
|
||||
/// 插入模板组件
|
||||
pub fn insert_component(&self, component: &TemplateComponent) -> Result<i64, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO template_components
|
||||
(repo_id, component_type, category, name, path, description, content_hash, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||
params![
|
||||
component.repo_id,
|
||||
component.component_type.as_str(),
|
||||
component.category,
|
||||
component.name,
|
||||
component.path,
|
||||
component.description,
|
||||
component.content_hash,
|
||||
now,
|
||||
now
|
||||
],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("插入模板组件失败: {e}")))?;
|
||||
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
/// 获取单个模板组件
|
||||
pub fn get_component(&self, id: i64) -> Result<Option<TemplateComponent>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, repo_id, component_type, category, name, path, description, content_hash
|
||||
FROM template_components
|
||||
WHERE id = ?1",
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("准备查询模板组件失败: {e}")))?;
|
||||
|
||||
let component = stmt
|
||||
.query_row(params![id], |row| {
|
||||
let component_type_str: String = row.get(2)?;
|
||||
let component_type = ComponentType::from_str(&component_type_str)
|
||||
.ok_or_else(|| rusqlite::Error::InvalidQuery)?;
|
||||
|
||||
Ok(TemplateComponent {
|
||||
id: Some(row.get(0)?),
|
||||
repo_id: row.get(1)?,
|
||||
component_type,
|
||||
category: row.get(3)?,
|
||||
name: row.get(4)?,
|
||||
path: row.get(5)?,
|
||||
description: row.get(6)?,
|
||||
content_hash: row.get(7)?,
|
||||
installed: false, // 需要单独查询
|
||||
})
|
||||
})
|
||||
.optional()
|
||||
.map_err(|e| AppError::Database(format!("查询模板组件失败: {e}")))?;
|
||||
|
||||
Ok(component)
|
||||
}
|
||||
|
||||
/// 获取组件列表(支持过滤和分页)
|
||||
pub fn list_components(
|
||||
&self,
|
||||
component_type: Option<&str>,
|
||||
category: Option<&str>,
|
||||
search: Option<&str>,
|
||||
page: u32,
|
||||
page_size: u32,
|
||||
) -> Result<(Vec<TemplateComponent>, u32), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
// 构建 WHERE 子句
|
||||
let mut where_clauses = Vec::new();
|
||||
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
||||
|
||||
if let Some(ct) = component_type {
|
||||
where_clauses.push("component_type = ?");
|
||||
params_vec.push(Box::new(ct.to_string()));
|
||||
}
|
||||
|
||||
if let Some(cat) = category {
|
||||
where_clauses.push("category = ?");
|
||||
params_vec.push(Box::new(cat.to_string()));
|
||||
}
|
||||
|
||||
if let Some(s) = search {
|
||||
where_clauses.push("(name LIKE ? OR description LIKE ?)");
|
||||
let pattern = format!("%{s}%");
|
||||
params_vec.push(Box::new(pattern.clone()));
|
||||
params_vec.push(Box::new(pattern));
|
||||
}
|
||||
|
||||
let where_sql = if where_clauses.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("WHERE {}", where_clauses.join(" AND "))
|
||||
};
|
||||
|
||||
// 查询总数
|
||||
let count_sql = format!("SELECT COUNT(*) FROM template_components {where_sql}");
|
||||
|
||||
let total: u32 = {
|
||||
let mut stmt = conn
|
||||
.prepare(&count_sql)
|
||||
.map_err(|e| AppError::Database(format!("准备统计组件数量失败: {e}")))?;
|
||||
|
||||
let params_refs: Vec<&dyn rusqlite::ToSql> =
|
||||
params_vec.iter().map(|p| p.as_ref()).collect();
|
||||
|
||||
stmt.query_row(¶ms_refs[..], |row| row.get(0))
|
||||
.map_err(|e| AppError::Database(format!("统计组件数量失败: {e}")))?
|
||||
};
|
||||
|
||||
// 查询数据
|
||||
let offset = (page.saturating_sub(1)) * page_size;
|
||||
let query_sql = format!(
|
||||
"SELECT id, repo_id, component_type, category, name, path, description, content_hash
|
||||
FROM template_components
|
||||
{where_sql}
|
||||
ORDER BY name ASC
|
||||
LIMIT ? OFFSET ?"
|
||||
);
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare(&query_sql)
|
||||
.map_err(|e| AppError::Database(format!("准备查询组件列表失败: {e}")))?;
|
||||
|
||||
params_vec.push(Box::new(page_size));
|
||||
params_vec.push(Box::new(offset));
|
||||
|
||||
let params_refs: Vec<&dyn rusqlite::ToSql> =
|
||||
params_vec.iter().map(|p| p.as_ref()).collect();
|
||||
|
||||
let component_iter = stmt
|
||||
.query_map(¶ms_refs[..], |row| {
|
||||
let component_type_str: String = row.get(2)?;
|
||||
let component_type = ComponentType::from_str(&component_type_str)
|
||||
.ok_or_else(|| rusqlite::Error::InvalidQuery)?;
|
||||
|
||||
Ok(TemplateComponent {
|
||||
id: Some(row.get(0)?),
|
||||
repo_id: row.get(1)?,
|
||||
component_type,
|
||||
category: row.get(3)?,
|
||||
name: row.get(4)?,
|
||||
path: row.get(5)?,
|
||||
description: row.get(6)?,
|
||||
content_hash: row.get(7)?,
|
||||
installed: false, // 需要单独查询
|
||||
})
|
||||
})
|
||||
.map_err(|e| AppError::Database(format!("查询组件列表失败: {e}")))?;
|
||||
|
||||
let mut components = Vec::new();
|
||||
for component_res in component_iter {
|
||||
components
|
||||
.push(component_res.map_err(|e| AppError::Database(format!("解析组件失败: {e}")))?);
|
||||
}
|
||||
|
||||
Ok((components, total))
|
||||
}
|
||||
|
||||
/// 删除仓库的所有组件
|
||||
pub fn delete_components_by_repo(&self, repo_id: i64) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
conn.execute(
|
||||
"DELETE FROM template_components WHERE repo_id = ?1",
|
||||
params![repo_id],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("删除仓库组件失败: {e}")))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Upsert 模板组件(根据 repo_id + component_type + path 判断是否已存在)
|
||||
pub fn upsert_component(&self, component: &TemplateComponent) -> Result<i64, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO template_components
|
||||
(repo_id, component_type, category, name, path, description, content_hash, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
|
||||
ON CONFLICT(repo_id, component_type, path) DO UPDATE SET
|
||||
category = excluded.category,
|
||||
name = excluded.name,
|
||||
description = excluded.description,
|
||||
content_hash = excluded.content_hash,
|
||||
updated_at = excluded.updated_at",
|
||||
params![
|
||||
component.repo_id,
|
||||
component.component_type.as_str(),
|
||||
component.category,
|
||||
component.name,
|
||||
component.path,
|
||||
component.description,
|
||||
component.content_hash,
|
||||
now,
|
||||
now
|
||||
],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("Upsert 模板组件失败: {e}")))?;
|
||||
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
// ==================== InstalledComponent 相关 ====================
|
||||
|
||||
/// 插入已安装组件
|
||||
pub fn insert_installed(&self, installed: &InstalledComponent) -> Result<i64, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let installed_at = installed.installed_at.to_rfc3339();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO installed_components
|
||||
(component_id, component_type, name, path, app_type, installed_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
params![
|
||||
installed.component_id,
|
||||
installed.component_type.as_str(),
|
||||
installed.name,
|
||||
installed.path,
|
||||
installed.app_type,
|
||||
installed_at
|
||||
],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("插入已安装组件失败: {e}")))?;
|
||||
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
/// 删除已安装组件
|
||||
pub fn delete_installed(
|
||||
&self,
|
||||
component_type: &str,
|
||||
path: &str,
|
||||
app_type: &str,
|
||||
) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
conn.execute(
|
||||
"DELETE FROM installed_components
|
||||
WHERE component_type = ?1 AND path = ?2 AND app_type = ?3",
|
||||
params![component_type, path, app_type],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("删除已安装组件失败: {e}")))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取已安装组件列表
|
||||
pub fn list_installed(
|
||||
&self,
|
||||
app_type: Option<&str>,
|
||||
) -> Result<Vec<InstalledComponent>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
let (sql, params_vec): (String, Vec<Box<dyn rusqlite::ToSql>>) = if let Some(at) = app_type
|
||||
{
|
||||
(
|
||||
"SELECT id, component_id, component_type, name, path, app_type, installed_at
|
||||
FROM installed_components
|
||||
WHERE app_type = ?
|
||||
ORDER BY installed_at DESC"
|
||||
.to_string(),
|
||||
vec![Box::new(at.to_string())],
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"SELECT id, component_id, component_type, name, path, app_type, installed_at
|
||||
FROM installed_components
|
||||
ORDER BY installed_at DESC"
|
||||
.to_string(),
|
||||
vec![],
|
||||
)
|
||||
};
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare(&sql)
|
||||
.map_err(|e| AppError::Database(format!("准备查询已安装组件失败: {e}")))?;
|
||||
|
||||
let params_refs: Vec<&dyn rusqlite::ToSql> =
|
||||
params_vec.iter().map(|p| p.as_ref()).collect();
|
||||
|
||||
let installed_iter = stmt
|
||||
.query_map(¶ms_refs[..], |row| {
|
||||
let component_type_str: String = row.get(2)?;
|
||||
let component_type = ComponentType::from_str(&component_type_str)
|
||||
.ok_or_else(|| rusqlite::Error::InvalidQuery)?;
|
||||
|
||||
let installed_at_str: String = row.get(6)?;
|
||||
let installed_at = DateTime::parse_from_rfc3339(&installed_at_str)
|
||||
.map(|dt| dt.with_timezone(&Utc))
|
||||
.map_err(|_| rusqlite::Error::InvalidQuery)?;
|
||||
|
||||
Ok(InstalledComponent {
|
||||
id: Some(row.get(0)?),
|
||||
component_id: row.get(1)?,
|
||||
component_type,
|
||||
name: row.get(3)?,
|
||||
path: row.get(4)?,
|
||||
app_type: row.get(5)?,
|
||||
installed_at,
|
||||
})
|
||||
})
|
||||
.map_err(|e| AppError::Database(format!("查询已安装组件失败: {e}")))?;
|
||||
|
||||
let mut installed = Vec::new();
|
||||
for installed_res in installed_iter {
|
||||
installed.push(
|
||||
installed_res
|
||||
.map_err(|e| AppError::Database(format!("解析已安装组件失败: {e}")))?,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(installed)
|
||||
}
|
||||
|
||||
/// 获取已安装组件列表(支持 app_type 和 component_type 过滤)
|
||||
pub fn list_installed_components(
|
||||
&self,
|
||||
app_type: Option<&str>,
|
||||
component_type: Option<&str>,
|
||||
) -> Result<Vec<InstalledComponent>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
// 构建 WHERE 子句
|
||||
let mut where_clauses = Vec::new();
|
||||
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
||||
|
||||
if let Some(at) = app_type {
|
||||
where_clauses.push("app_type = ?");
|
||||
params_vec.push(Box::new(at.to_string()));
|
||||
}
|
||||
|
||||
if let Some(ct) = component_type {
|
||||
where_clauses.push("component_type = ?");
|
||||
params_vec.push(Box::new(ct.to_string()));
|
||||
}
|
||||
|
||||
let where_sql = if where_clauses.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("WHERE {}", where_clauses.join(" AND "))
|
||||
};
|
||||
|
||||
let sql = format!(
|
||||
"SELECT id, component_id, component_type, name, path, app_type, installed_at
|
||||
FROM installed_components
|
||||
{where_sql}
|
||||
ORDER BY installed_at DESC"
|
||||
);
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare(&sql)
|
||||
.map_err(|e| AppError::Database(format!("准备查询已安装组件失败: {e}")))?;
|
||||
|
||||
let params_refs: Vec<&dyn rusqlite::ToSql> =
|
||||
params_vec.iter().map(|p| p.as_ref()).collect();
|
||||
|
||||
let installed_iter = stmt
|
||||
.query_map(¶ms_refs[..], |row| {
|
||||
let component_type_str: String = row.get(2)?;
|
||||
let component_type = ComponentType::from_str(&component_type_str)
|
||||
.ok_or_else(|| rusqlite::Error::InvalidQuery)?;
|
||||
|
||||
let installed_at_str: String = row.get(6)?;
|
||||
let installed_at = DateTime::parse_from_rfc3339(&installed_at_str)
|
||||
.map(|dt| dt.with_timezone(&Utc))
|
||||
.map_err(|_| rusqlite::Error::InvalidQuery)?;
|
||||
|
||||
Ok(InstalledComponent {
|
||||
id: Some(row.get(0)?),
|
||||
component_id: row.get(1)?,
|
||||
component_type,
|
||||
name: row.get(3)?,
|
||||
path: row.get(4)?,
|
||||
app_type: row.get(5)?,
|
||||
installed_at,
|
||||
})
|
||||
})
|
||||
.map_err(|e| AppError::Database(format!("查询已安装组件失败: {e}")))?;
|
||||
|
||||
let mut installed = Vec::new();
|
||||
for installed_res in installed_iter {
|
||||
installed.push(
|
||||
installed_res
|
||||
.map_err(|e| AppError::Database(format!("解析已安装组件失败: {e}")))?,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(installed)
|
||||
}
|
||||
|
||||
/// 检查组件是否已安装
|
||||
pub fn is_installed(
|
||||
&self,
|
||||
component_type: &str,
|
||||
path: &str,
|
||||
app_type: &str,
|
||||
) -> Result<bool, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
let count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM installed_components
|
||||
WHERE component_type = ?1 AND path = ?2 AND app_type = ?3",
|
||||
params![component_type, path, app_type],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("检查组件安装状态失败: {e}")))?;
|
||||
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
/// 获取指定应用已安装的组件 ID 列表
|
||||
pub fn get_installed_component_ids(&self, app_type: &str) -> Result<Vec<i64>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT component_id FROM installed_components WHERE app_type = ?1 AND component_id IS NOT NULL",
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("准备查询已安装组件失败: {e}")))?;
|
||||
|
||||
let ids: Vec<i64> = stmt
|
||||
.query_map(params![app_type], |row| row.get(0))
|
||||
.map_err(|e| AppError::Database(format!("查询已安装组件失败: {e}")))?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
Ok(ids)
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,7 @@ const DB_BACKUP_RETAIN: usize = 10;
|
||||
|
||||
/// 当前 Schema 版本号
|
||||
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
|
||||
pub(crate) const SCHEMA_VERSION: i32 = 2;
|
||||
pub(crate) const SCHEMA_VERSION: i32 = 3;
|
||||
|
||||
/// 安全地序列化 JSON,避免 unwrap panic
|
||||
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
|
||||
|
||||
@@ -336,6 +336,90 @@ impl Database {
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// 15. Template Repos 表 (模板仓库)
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS template_repos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
owner TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
branch TEXT NOT NULL DEFAULT 'main',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(owner, name)
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// 插入默认模板仓库
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO template_repos (owner, name, branch, enabled)
|
||||
VALUES ('yovinchen', 'claude-code-templates', 'main', 1)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// 16. Template Components 表 (模板组件)
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS template_components (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
repo_id INTEGER NOT NULL,
|
||||
component_type TEXT NOT NULL,
|
||||
category TEXT,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
description TEXT,
|
||||
content_hash TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (repo_id) REFERENCES template_repos(id) ON DELETE CASCADE,
|
||||
UNIQUE(repo_id, component_type, path)
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// 为 template_components 创建索引
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_template_components_type
|
||||
ON template_components(component_type)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_template_components_category
|
||||
ON template_components(category)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// 17. Installed Components 表 (已安装组件)
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS installed_components (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
component_id INTEGER,
|
||||
component_type TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
app_type TEXT NOT NULL,
|
||||
installed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (component_id) REFERENCES template_components(id) ON DELETE SET NULL,
|
||||
UNIQUE(component_type, path, app_type)
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// 为 installed_components 创建索引
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_installed_components_app
|
||||
ON installed_components(app_type)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -375,6 +459,13 @@ impl Database {
|
||||
Self::migrate_v1_to_v2(conn)?;
|
||||
Self::set_user_version(conn, 2)?;
|
||||
}
|
||||
2 => {
|
||||
log::info!(
|
||||
"迁移数据库从 v2 到 v3(添加 Claude Code Templates 功能相关表)"
|
||||
);
|
||||
Self::migrate_v2_to_v3(conn)?;
|
||||
Self::set_user_version(conn, 3)?;
|
||||
}
|
||||
_ => {
|
||||
return Err(AppError::Database(format!(
|
||||
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
|
||||
@@ -617,6 +708,96 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// v2 -> v3 迁移:添加 Claude Code Templates 功能相关表
|
||||
fn migrate_v2_to_v3(conn: &Connection) -> Result<(), AppError> {
|
||||
// 1. template_repos 表 - 存储模板仓库信息
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS template_repos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
owner TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
branch TEXT NOT NULL DEFAULT 'main',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(owner, name)
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("创建 template_repos 表失败: {e}")))?;
|
||||
|
||||
// 2. template_components 表 - 存储从仓库中发现的模板组件
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS template_components (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
repo_id INTEGER NOT NULL,
|
||||
component_type TEXT NOT NULL,
|
||||
category TEXT,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
description TEXT,
|
||||
content_hash TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (repo_id) REFERENCES template_repos(id) ON DELETE CASCADE,
|
||||
UNIQUE(repo_id, component_type, path)
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("创建 template_components 表失败: {e}")))?;
|
||||
|
||||
// 为 template_components 创建索引
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_template_components_type
|
||||
ON template_components(component_type)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("创建 template_components 类型索引失败: {e}")))?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_template_components_category
|
||||
ON template_components(category)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("创建 template_components 分类索引失败: {e}")))?;
|
||||
|
||||
// 3. installed_components 表 - 存储已安装的组件
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS installed_components (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
component_id INTEGER,
|
||||
component_type TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
app_type TEXT NOT NULL,
|
||||
installed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (component_id) REFERENCES template_components(id) ON DELETE SET NULL,
|
||||
UNIQUE(component_type, path, app_type)
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("创建 installed_components 表失败: {e}")))?;
|
||||
|
||||
// 为 installed_components 创建索引
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_installed_components_app
|
||||
ON installed_components(app_type)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("创建 installed_components 应用索引失败: {e}")))?;
|
||||
|
||||
// 4. 插入默认模板仓库
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO template_repos (owner, name, branch, enabled)
|
||||
VALUES ('yovinchen', 'claude-code-templates', 'main', 1)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("插入默认模板仓库失败: {e}")))?;
|
||||
|
||||
log::info!("已创建 Claude Code Templates 相关表并插入默认仓库");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 插入默认模型定价数据
|
||||
/// 格式: (model_id, display_name, input, output, cache_read, cache_creation)
|
||||
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
|
||||
|
||||
@@ -285,8 +285,7 @@ fn model_pricing_is_seeded_on_init() {
|
||||
|
||||
assert!(
|
||||
count > 0,
|
||||
"模型定价数据应该在初始化时自动填充,实际数量: {}",
|
||||
count
|
||||
"模型定价数据应该在初始化时自动填充,实际数量: {count}"
|
||||
);
|
||||
|
||||
// 验证包含 Claude 模型
|
||||
@@ -299,8 +298,7 @@ fn model_pricing_is_seeded_on_init() {
|
||||
.expect("check claude");
|
||||
assert!(
|
||||
claude_count > 0,
|
||||
"应该包含 Claude 模型定价,实际数量: {}",
|
||||
claude_count
|
||||
"应该包含 Claude 模型定价,实际数量: {claude_count}"
|
||||
);
|
||||
|
||||
// 验证包含 GPT 模型
|
||||
@@ -313,8 +311,7 @@ fn model_pricing_is_seeded_on_init() {
|
||||
.expect("check gpt");
|
||||
assert!(
|
||||
gpt_count > 0,
|
||||
"应该包含 GPT 模型定价,实际数量: {}",
|
||||
gpt_count
|
||||
"应该包含 GPT 模型定价,实际数量: {gpt_count}"
|
||||
);
|
||||
|
||||
// 验证包含 Gemini 模型
|
||||
@@ -327,7 +324,90 @@ fn model_pricing_is_seeded_on_init() {
|
||||
.expect("check gemini");
|
||||
assert!(
|
||||
gemini_count > 0,
|
||||
"应该包含 Gemini 模型定价,实际数量: {}",
|
||||
gemini_count
|
||||
"应该包含 Gemini 模型定价,实际数量: {gemini_count}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_v2_to_v3_migration_creates_template_tables() {
|
||||
let conn = Connection::open_in_memory().expect("open memory db");
|
||||
|
||||
// 创建 v2 schema(即当前完整的 schema)
|
||||
Database::create_tables_on_conn(&conn).expect("create tables");
|
||||
Database::set_user_version(&conn, 2).expect("set v2 version");
|
||||
|
||||
// 应用迁移到 v3
|
||||
Database::apply_schema_migrations_on_conn(&conn).expect("migrate to v3");
|
||||
|
||||
// 验证版本号已更新
|
||||
assert_eq!(
|
||||
Database::get_user_version(&conn).expect("read version"),
|
||||
3,
|
||||
"版本应该更新为 3"
|
||||
);
|
||||
|
||||
// 验证 template_repos 表存在且包含默认仓库
|
||||
let count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM template_repos", [], |row| row.get(0))
|
||||
.expect("count template_repos");
|
||||
assert_eq!(count, 1, "应该有 1 个默认模板仓库");
|
||||
|
||||
let (owner, name, branch): (String, String, String) = conn
|
||||
.query_row(
|
||||
"SELECT owner, name, branch FROM template_repos WHERE id = 1",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
||||
)
|
||||
.expect("read default repo");
|
||||
assert_eq!(owner, "yovinchen", "默认仓库 owner 应该是 yovinchen");
|
||||
assert_eq!(
|
||||
name, "claude-code-templates",
|
||||
"默认仓库 name 应该是 claude-code-templates"
|
||||
);
|
||||
assert_eq!(branch, "main", "默认仓库 branch 应该是 main");
|
||||
|
||||
// 验证 template_components 表存在
|
||||
assert!(
|
||||
Database::table_exists(&conn, "template_components").expect("check table"),
|
||||
"template_components 表应该存在"
|
||||
);
|
||||
|
||||
// 验证 installed_components 表存在
|
||||
assert!(
|
||||
Database::table_exists(&conn, "installed_components").expect("check table"),
|
||||
"installed_components 表应该存在"
|
||||
);
|
||||
|
||||
// 验证索引存在
|
||||
let index_count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND (
|
||||
name = 'idx_template_components_type' OR
|
||||
name = 'idx_template_components_category' OR
|
||||
name = 'idx_installed_components_app'
|
||||
)",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("count indexes");
|
||||
assert_eq!(index_count, 3, "应该创建 3 个索引");
|
||||
|
||||
// 验证外键约束
|
||||
let fk_count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM pragma_foreign_key_list('template_components')",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("count fk");
|
||||
assert_eq!(fk_count, 1, "template_components 应该有 1 个外键约束");
|
||||
|
||||
let fk_count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM pragma_foreign_key_list('installed_components')",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("count fk");
|
||||
assert_eq!(fk_count, 1, "installed_components 应该有 1 个外键约束");
|
||||
}
|
||||
|
||||
@@ -365,8 +365,7 @@ fn test_parse_prompt_deeplink() {
|
||||
let content = "Hello World";
|
||||
let content_b64 = BASE64_STANDARD.encode(content);
|
||||
let url = format!(
|
||||
"ccswitch://v1/import?resource=prompt&app=claude&name=test&content={}&description=desc&enabled=true",
|
||||
content_b64
|
||||
"ccswitch://v1/import?resource=prompt&app=claude&name=test&content={content_b64}&description=desc&enabled=true"
|
||||
);
|
||||
|
||||
let request = parse_deeplink_url(&url).unwrap();
|
||||
@@ -375,7 +374,7 @@ fn test_parse_prompt_deeplink() {
|
||||
assert_eq!(request.name.unwrap(), "test");
|
||||
assert_eq!(request.content.unwrap(), content_b64);
|
||||
assert_eq!(request.description.unwrap(), "desc");
|
||||
assert_eq!(request.enabled.unwrap(), true);
|
||||
assert!(request.enabled.unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -383,21 +382,20 @@ fn test_parse_mcp_deeplink() {
|
||||
let config = r#"{"mcpServers":{"test":{"command":"echo"}}}"#;
|
||||
let config_b64 = BASE64_STANDARD.encode(config);
|
||||
let url = format!(
|
||||
"ccswitch://v1/import?resource=mcp&apps=claude,codex&config={}&enabled=true",
|
||||
config_b64
|
||||
"ccswitch://v1/import?resource=mcp&apps=claude,codex&config={config_b64}&enabled=true"
|
||||
);
|
||||
|
||||
let request = parse_deeplink_url(&url).unwrap();
|
||||
assert_eq!(request.resource, "mcp");
|
||||
assert_eq!(request.apps.unwrap(), "claude,codex");
|
||||
assert_eq!(request.config.unwrap(), config_b64);
|
||||
assert_eq!(request.enabled.unwrap(), true);
|
||||
assert!(request.enabled.unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_skill_deeplink() {
|
||||
let url = "ccswitch://v1/import?resource=skill&repo=owner/repo&directory=skills&branch=dev";
|
||||
let request = parse_deeplink_url(&url).unwrap();
|
||||
let request = parse_deeplink_url(url).unwrap();
|
||||
|
||||
assert_eq!(request.resource, "skill");
|
||||
assert_eq!(request.repo.unwrap(), "owner/repo");
|
||||
|
||||
@@ -730,6 +730,21 @@ pub fn run() {
|
||||
commands::get_stream_check_config,
|
||||
commands::save_stream_check_config,
|
||||
commands::get_tool_versions,
|
||||
// Template management
|
||||
commands::refresh_template_index,
|
||||
commands::list_template_components,
|
||||
commands::get_template_component,
|
||||
commands::install_template_component,
|
||||
commands::uninstall_template_component,
|
||||
commands::batch_install_template_components,
|
||||
commands::list_template_repos,
|
||||
commands::add_template_repo,
|
||||
commands::remove_template_repo,
|
||||
commands::toggle_template_repo,
|
||||
commands::list_template_categories,
|
||||
commands::list_installed_components,
|
||||
commands::preview_component_content,
|
||||
commands::list_marketplace_bundles,
|
||||
]);
|
||||
|
||||
let app = builder
|
||||
|
||||
@@ -8,6 +8,7 @@ pub mod proxy;
|
||||
pub mod skill;
|
||||
pub mod speedtest;
|
||||
pub mod stream_check;
|
||||
pub mod template;
|
||||
pub mod usage_stats;
|
||||
|
||||
pub use config::ConfigService;
|
||||
@@ -18,6 +19,12 @@ pub use proxy::ProxyService;
|
||||
pub use skill::{Skill, SkillRepo, SkillService};
|
||||
pub use speedtest::{EndpointLatency, SpeedtestService};
|
||||
#[allow(unused_imports)]
|
||||
pub use template::{
|
||||
BatchInstallResult, ComponentDetail, ComponentMetadata, ComponentType, InstalledComponent,
|
||||
MarketplaceBundle, MarketplaceBundleItem, PaginatedResult, TemplateComponent, TemplateRepo,
|
||||
TemplateService,
|
||||
};
|
||||
#[allow(unused_imports)]
|
||||
pub use usage_stats::{
|
||||
DailyStats, LogFilters, ModelStats, PaginatedLogs, ProviderLimitStatus, ProviderStats,
|
||||
RequestLogDetail, UsageSummary,
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
//! Claude 应用适配器
|
||||
//!
|
||||
//! 完整支持所有组件类型:
|
||||
//! - Agent → `~/.claude/agents/{name}.md`
|
||||
//! - Command → `~/.claude/commands/{name}.md`
|
||||
//! - MCP → 合并到 `~/.claude.json` 的 mcpServers 字段
|
||||
//! - Setting → 合并到 `~/.claude/settings.json` 的 permissions 字段
|
||||
//! - Hook → 合并到 `~/.claude/settings.json` 的 hooks 字段
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde_json::Value;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::AppAdapter;
|
||||
use crate::config::{atomic_write, get_claude_config_dir, get_claude_mcp_path};
|
||||
|
||||
/// Claude 应用适配器
|
||||
pub struct ClaudeAdapter {
|
||||
config_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl ClaudeAdapter {
|
||||
/// 创建新的 Claude 适配器实例
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
config_dir: get_claude_config_dir(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 读取 JSON 配置文件
|
||||
fn read_json_file(path: &PathBuf) -> Result<Value> {
|
||||
if !path.exists() {
|
||||
return Ok(serde_json::json!({}));
|
||||
}
|
||||
let content = fs::read_to_string(path)
|
||||
.with_context(|| format!("读取配置文件失败: {}", path.display()))?;
|
||||
let value: Value = serde_json::from_str(&content)
|
||||
.with_context(|| format!("解析 JSON 失败: {}", path.display()))?;
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
/// 写入 JSON 配置文件(原子写入)
|
||||
fn write_json_file(path: &Path, value: &Value) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.with_context(|| format!("创建目录失败: {}", parent.display()))?;
|
||||
}
|
||||
|
||||
let json = serde_json::to_string_pretty(value).context("序列化 JSON 失败")?;
|
||||
|
||||
atomic_write(path, json.as_bytes())
|
||||
.with_context(|| format!("写入配置文件失败: {}", path.display()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 合并两个 JSON 对象(深度合并)
|
||||
fn merge_json(base: &mut Value, overlay: &Value) {
|
||||
if let (Some(base_obj), Some(overlay_obj)) = (base.as_object_mut(), overlay.as_object()) {
|
||||
for (key, value) in overlay_obj {
|
||||
if let Some(base_value) = base_obj.get_mut(key) {
|
||||
// 如果两边都是对象,递归合并
|
||||
if base_value.is_object() && value.is_object() {
|
||||
Self::merge_json(base_value, value);
|
||||
} else {
|
||||
// 否则直接覆盖
|
||||
*base_value = value.clone();
|
||||
}
|
||||
} else {
|
||||
// 键不存在,直接插入
|
||||
base_obj.insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 安装 Markdown 文件(通用)
|
||||
fn install_markdown_file(&self, content: &str, subdir: &str, name: &str) -> Result<PathBuf> {
|
||||
let dir = self.config_dir.join(subdir);
|
||||
fs::create_dir_all(&dir).with_context(|| format!("创建目录失败: {}", dir.display()))?;
|
||||
|
||||
let filename = if name.ends_with(".md") {
|
||||
name.to_string()
|
||||
} else {
|
||||
format!("{name}.md")
|
||||
};
|
||||
|
||||
let file_path = dir.join(&filename);
|
||||
|
||||
atomic_write(&file_path, content.as_bytes())
|
||||
.with_context(|| format!("写入文件失败: {}", file_path.display()))?;
|
||||
|
||||
log::info!("已安装 Claude {}: {}", subdir, file_path.display());
|
||||
Ok(file_path)
|
||||
}
|
||||
|
||||
/// 获取 Claude settings.json 路径
|
||||
fn get_settings_path(&self) -> PathBuf {
|
||||
crate::config::get_claude_settings_path()
|
||||
}
|
||||
}
|
||||
|
||||
impl AppAdapter for ClaudeAdapter {
|
||||
fn install_agent(&self, content: &str, name: &str) -> Result<PathBuf> {
|
||||
self.install_markdown_file(content, "agents", name)
|
||||
}
|
||||
|
||||
fn install_command(&self, content: &str, name: &str) -> Result<PathBuf> {
|
||||
self.install_markdown_file(content, "commands", name)
|
||||
}
|
||||
|
||||
fn install_mcp(&self, mcp_config: &Value) -> Result<()> {
|
||||
let mcp_path = get_claude_mcp_path();
|
||||
|
||||
// 读取现有 MCP 配置
|
||||
let mut current = Self::read_json_file(&mcp_path)?;
|
||||
|
||||
// 确保 mcpServers 字段存在
|
||||
if !current.is_object() {
|
||||
current = serde_json::json!({});
|
||||
}
|
||||
if current.get("mcpServers").is_none() {
|
||||
current["mcpServers"] = serde_json::json!({});
|
||||
}
|
||||
|
||||
// 合并新的 MCP 服务器配置
|
||||
if let Some(mcp_servers) = current.get_mut("mcpServers") {
|
||||
Self::merge_json(mcp_servers, mcp_config);
|
||||
}
|
||||
|
||||
// 写回配置文件
|
||||
Self::write_json_file(&mcp_path, ¤t)?;
|
||||
|
||||
log::info!("已安装 Claude MCP 配置到: {}", mcp_path.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn install_setting(&self, setting_config: &Value) -> Result<()> {
|
||||
let settings_path = self.get_settings_path();
|
||||
|
||||
// 读取现有配置
|
||||
let mut current = Self::read_json_file(&settings_path)?;
|
||||
|
||||
// 确保 permissions 字段存在
|
||||
if !current.is_object() {
|
||||
current = serde_json::json!({});
|
||||
}
|
||||
if current.get("permissions").is_none() {
|
||||
current["permissions"] = serde_json::json!({});
|
||||
}
|
||||
|
||||
// 合并新的 permissions 配置
|
||||
if let Some(permissions) = current.get_mut("permissions") {
|
||||
Self::merge_json(permissions, setting_config);
|
||||
}
|
||||
|
||||
// 写回配置文件
|
||||
Self::write_json_file(&settings_path, ¤t)?;
|
||||
|
||||
log::info!("已安装 Claude Setting 配置到: {}", settings_path.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn install_hook(&self, hook_config: &Value) -> Result<()> {
|
||||
let settings_path = self.get_settings_path();
|
||||
|
||||
// 读取现有配置
|
||||
let mut current = Self::read_json_file(&settings_path)?;
|
||||
|
||||
// 确保 hooks 字段存在
|
||||
if !current.is_object() {
|
||||
current = serde_json::json!({});
|
||||
}
|
||||
if current.get("hooks").is_none() {
|
||||
current["hooks"] = serde_json::json!({});
|
||||
}
|
||||
|
||||
// 合并新的 hooks 配置
|
||||
if let Some(hooks) = current.get_mut("hooks") {
|
||||
Self::merge_json(hooks, hook_config);
|
||||
}
|
||||
|
||||
// 写回配置文件
|
||||
Self::write_json_file(&settings_path, ¤t)?;
|
||||
|
||||
log::info!("已安装 Claude Hook 配置到: {}", settings_path.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn uninstall(&self, component_type: &str, name: &str) -> Result<()> {
|
||||
match component_type.to_lowercase().as_str() {
|
||||
"agent" => {
|
||||
let path = self.config_dir.join("agents").join(format!("{name}.md"));
|
||||
if path.exists() {
|
||||
fs::remove_file(&path)
|
||||
.with_context(|| format!("删除 Agent 文件失败: {}", path.display()))?;
|
||||
log::info!("已卸载 Claude Agent: {}", path.display());
|
||||
}
|
||||
}
|
||||
"command" => {
|
||||
let path = self.config_dir.join("commands").join(format!("{name}.md"));
|
||||
if path.exists() {
|
||||
fs::remove_file(&path)
|
||||
.with_context(|| format!("删除 Command 文件失败: {}", path.display()))?;
|
||||
log::info!("已卸载 Claude Command: {}", path.display());
|
||||
}
|
||||
}
|
||||
"mcp" => {
|
||||
let mcp_path = get_claude_mcp_path();
|
||||
let mut current = Self::read_json_file(&mcp_path)?;
|
||||
|
||||
if let Some(mcp_servers) = current
|
||||
.get_mut("mcpServers")
|
||||
.and_then(|v| v.as_object_mut())
|
||||
{
|
||||
mcp_servers.remove(name);
|
||||
Self::write_json_file(&mcp_path, ¤t)?;
|
||||
log::info!("已卸载 Claude MCP 服务器: {name}");
|
||||
}
|
||||
}
|
||||
"setting" => {
|
||||
let settings_path = self.get_settings_path();
|
||||
let mut current = Self::read_json_file(&settings_path)?;
|
||||
|
||||
if let Some(permissions) = current
|
||||
.get_mut("permissions")
|
||||
.and_then(|v| v.as_object_mut())
|
||||
{
|
||||
permissions.remove(name);
|
||||
Self::write_json_file(&settings_path, ¤t)?;
|
||||
log::info!("已卸载 Claude Setting: {name}");
|
||||
}
|
||||
}
|
||||
"hook" => {
|
||||
let settings_path = self.get_settings_path();
|
||||
let mut current = Self::read_json_file(&settings_path)?;
|
||||
|
||||
if let Some(hooks) = current.get_mut("hooks").and_then(|v| v.as_object_mut()) {
|
||||
hooks.remove(name);
|
||||
Self::write_json_file(&settings_path, ¤t)?;
|
||||
log::info!("已卸载 Claude Hook: {name}");
|
||||
}
|
||||
}
|
||||
_ => anyhow::bail!("不支持的组件类型: {component_type}"),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn config_dir(&self) -> PathBuf {
|
||||
self.config_dir.clone()
|
||||
}
|
||||
|
||||
fn supports_component_type(&self, component_type: &str) -> bool {
|
||||
matches!(
|
||||
component_type.to_lowercase().as_str(),
|
||||
"agent" | "command" | "mcp" | "setting" | "hook"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ClaudeAdapter {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
//! Codex 应用适配器
|
||||
//!
|
||||
//! 部分支持:
|
||||
//! - Agent → `~/.codex/agents/{name}.md`
|
||||
//! - Command → `~/.codex/commands/{name}.md`
|
||||
//! - MCP → 合并到 `~/.codex/config.toml` 的 [mcp_servers] 表
|
||||
//! - Setting/Hook → 不支持(Codex 不支持这些功能)
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use serde_json::Value;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::AppAdapter;
|
||||
use crate::codex_config::get_codex_config_dir;
|
||||
use crate::config::{atomic_write, write_text_file};
|
||||
|
||||
/// Codex 应用适配器
|
||||
pub struct CodexAdapter {
|
||||
config_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl CodexAdapter {
|
||||
/// 创建新的 Codex 适配器实例
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
config_dir: get_codex_config_dir(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 安装 Markdown 文件(通用)
|
||||
fn install_markdown_file(&self, content: &str, subdir: &str, name: &str) -> Result<PathBuf> {
|
||||
let dir = self.config_dir.join(subdir);
|
||||
fs::create_dir_all(&dir).with_context(|| format!("创建目录失败: {}", dir.display()))?;
|
||||
|
||||
let filename = if name.ends_with(".md") {
|
||||
name.to_string()
|
||||
} else {
|
||||
format!("{name}.md")
|
||||
};
|
||||
|
||||
let file_path = dir.join(&filename);
|
||||
|
||||
atomic_write(&file_path, content.as_bytes())
|
||||
.with_context(|| format!("写入文件失败: {}", file_path.display()))?;
|
||||
|
||||
log::info!("已安装 Codex {}: {}", subdir, file_path.display());
|
||||
Ok(file_path)
|
||||
}
|
||||
|
||||
/// 获取 Codex config.toml 路径
|
||||
fn get_config_toml_path(&self) -> PathBuf {
|
||||
crate::codex_config::get_codex_config_path()
|
||||
}
|
||||
|
||||
/// 读取 TOML 配置文件
|
||||
fn read_toml_file(path: &PathBuf) -> Result<toml::Table> {
|
||||
if !path.exists() {
|
||||
return Ok(toml::Table::new());
|
||||
}
|
||||
let content = fs::read_to_string(path)
|
||||
.with_context(|| format!("读取配置文件失败: {}", path.display()))?;
|
||||
let table: toml::Table = toml::from_str(&content)
|
||||
.with_context(|| format!("解析 TOML 失败: {}", path.display()))?;
|
||||
Ok(table)
|
||||
}
|
||||
|
||||
/// 写入 TOML 配置文件(原子写入)
|
||||
fn write_toml_file(path: &Path, table: &toml::Table) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.with_context(|| format!("创建目录失败: {}", parent.display()))?;
|
||||
}
|
||||
|
||||
let toml_string = toml::to_string_pretty(table).context("序列化 TOML 失败")?;
|
||||
|
||||
write_text_file(path, &toml_string)
|
||||
.with_context(|| format!("写入配置文件失败: {}", path.display()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 将 JSON MCP 配置转换为 TOML 格式
|
||||
fn json_mcp_to_toml(json_config: &Value) -> Result<toml::Table> {
|
||||
let mut mcp_servers = toml::Table::new();
|
||||
|
||||
if let Some(obj) = json_config.as_object() {
|
||||
for (server_id, server_spec) in obj {
|
||||
let mut server_table = toml::Table::new();
|
||||
|
||||
if let Some(spec_obj) = server_spec.as_object() {
|
||||
// type 字段(默认 stdio)
|
||||
let server_type = spec_obj
|
||||
.get("type")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("stdio");
|
||||
server_table.insert(
|
||||
"type".to_string(),
|
||||
toml::Value::String(server_type.to_string()),
|
||||
);
|
||||
|
||||
match server_type {
|
||||
"stdio" => {
|
||||
// command 字段(必需)
|
||||
if let Some(cmd) = spec_obj.get("command").and_then(|v| v.as_str()) {
|
||||
server_table.insert(
|
||||
"command".to_string(),
|
||||
toml::Value::String(cmd.to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
// args 字段(可选)
|
||||
if let Some(args) = spec_obj.get("args").and_then(|v| v.as_array()) {
|
||||
let toml_args: Vec<toml::Value> = args
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.map(|s| toml::Value::String(s.to_string()))
|
||||
.collect();
|
||||
if !toml_args.is_empty() {
|
||||
server_table
|
||||
.insert("args".to_string(), toml::Value::Array(toml_args));
|
||||
}
|
||||
}
|
||||
|
||||
// env 字段(可选)
|
||||
if let Some(env) = spec_obj.get("env").and_then(|v| v.as_object()) {
|
||||
let mut env_table = toml::Table::new();
|
||||
for (key, value) in env {
|
||||
if let Some(val_str) = value.as_str() {
|
||||
env_table.insert(
|
||||
key.clone(),
|
||||
toml::Value::String(val_str.to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
if !env_table.is_empty() {
|
||||
server_table
|
||||
.insert("env".to_string(), toml::Value::Table(env_table));
|
||||
}
|
||||
}
|
||||
|
||||
// cwd 字段(可选)
|
||||
if let Some(cwd) = spec_obj.get("cwd").and_then(|v| v.as_str()) {
|
||||
server_table.insert(
|
||||
"cwd".to_string(),
|
||||
toml::Value::String(cwd.to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
"http" | "sse" => {
|
||||
// url 字段(必需)
|
||||
if let Some(url) = spec_obj.get("url").and_then(|v| v.as_str()) {
|
||||
server_table.insert(
|
||||
"url".to_string(),
|
||||
toml::Value::String(url.to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
// http_headers 字段(可选)
|
||||
if let Some(headers) =
|
||||
spec_obj.get("http_headers").and_then(|v| v.as_object())
|
||||
{
|
||||
let mut headers_table = toml::Table::new();
|
||||
for (key, value) in headers {
|
||||
if let Some(val_str) = value.as_str() {
|
||||
headers_table.insert(
|
||||
key.clone(),
|
||||
toml::Value::String(val_str.to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
if !headers_table.is_empty() {
|
||||
server_table.insert(
|
||||
"http_headers".to_string(),
|
||||
toml::Value::Table(headers_table),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
mcp_servers.insert(server_id.clone(), toml::Value::Table(server_table));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(mcp_servers)
|
||||
}
|
||||
}
|
||||
|
||||
impl AppAdapter for CodexAdapter {
|
||||
fn install_agent(&self, content: &str, name: &str) -> Result<PathBuf> {
|
||||
self.install_markdown_file(content, "agents", name)
|
||||
}
|
||||
|
||||
fn install_command(&self, content: &str, name: &str) -> Result<PathBuf> {
|
||||
self.install_markdown_file(content, "commands", name)
|
||||
}
|
||||
|
||||
fn install_mcp(&self, mcp_config: &Value) -> Result<()> {
|
||||
let config_path = self.get_config_toml_path();
|
||||
|
||||
// 读取现有 TOML 配置
|
||||
let mut current = Self::read_toml_file(&config_path)?;
|
||||
|
||||
// 确保 mcp_servers 表存在
|
||||
if !current.contains_key("mcp_servers") {
|
||||
current.insert(
|
||||
"mcp_servers".to_string(),
|
||||
toml::Value::Table(toml::Table::new()),
|
||||
);
|
||||
}
|
||||
|
||||
// 转换 JSON MCP 配置到 TOML
|
||||
let new_mcp_servers = Self::json_mcp_to_toml(mcp_config)?;
|
||||
|
||||
// 合并 MCP 服务器配置
|
||||
if let Some(mcp_servers) = current
|
||||
.get_mut("mcp_servers")
|
||||
.and_then(|v| v.as_table_mut())
|
||||
{
|
||||
for (server_id, server_config) in new_mcp_servers {
|
||||
mcp_servers.insert(server_id, server_config);
|
||||
}
|
||||
}
|
||||
|
||||
// 写回配置文件
|
||||
Self::write_toml_file(&config_path, ¤t)?;
|
||||
|
||||
log::info!("已安装 Codex MCP 配置到: {}", config_path.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn install_setting(&self, _setting_config: &Value) -> Result<()> {
|
||||
bail!("Codex 不支持 Setting 配置")
|
||||
}
|
||||
|
||||
fn install_hook(&self, _hook_config: &Value) -> Result<()> {
|
||||
bail!("Codex 不支持 Hook 配置")
|
||||
}
|
||||
|
||||
fn uninstall(&self, component_type: &str, name: &str) -> Result<()> {
|
||||
match component_type.to_lowercase().as_str() {
|
||||
"agent" => {
|
||||
let path = self.config_dir.join("agents").join(format!("{name}.md"));
|
||||
if path.exists() {
|
||||
fs::remove_file(&path)
|
||||
.with_context(|| format!("删除 Agent 文件失败: {}", path.display()))?;
|
||||
log::info!("已卸载 Codex Agent: {}", path.display());
|
||||
}
|
||||
}
|
||||
"command" => {
|
||||
let path = self.config_dir.join("commands").join(format!("{name}.md"));
|
||||
if path.exists() {
|
||||
fs::remove_file(&path)
|
||||
.with_context(|| format!("删除 Command 文件失败: {}", path.display()))?;
|
||||
log::info!("已卸载 Codex Command: {}", path.display());
|
||||
}
|
||||
}
|
||||
"mcp" => {
|
||||
let config_path = self.get_config_toml_path();
|
||||
let mut current = Self::read_toml_file(&config_path)?;
|
||||
|
||||
if let Some(mcp_servers) = current
|
||||
.get_mut("mcp_servers")
|
||||
.and_then(|v| v.as_table_mut())
|
||||
{
|
||||
mcp_servers.remove(name);
|
||||
Self::write_toml_file(&config_path, ¤t)?;
|
||||
log::info!("已卸载 Codex MCP 服务器: {name}");
|
||||
}
|
||||
}
|
||||
"setting" | "hook" => {
|
||||
bail!("Codex 不支持 {component_type} 组件类型")
|
||||
}
|
||||
_ => bail!("不支持的组件类型: {component_type}"),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn config_dir(&self) -> PathBuf {
|
||||
self.config_dir.clone()
|
||||
}
|
||||
|
||||
fn supports_component_type(&self, component_type: &str) -> bool {
|
||||
matches!(
|
||||
component_type.to_lowercase().as_str(),
|
||||
"agent" | "command" | "mcp"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CodexAdapter {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
//! Gemini 应用适配器
|
||||
//!
|
||||
//! 部分支持:
|
||||
//! - Agent → `~/.gemini/agents/{name}.md`
|
||||
//! - Command → `~/.gemini/commands/{name}.md`
|
||||
//! - MCP → 合并到 `~/.gemini/settings.json` 的 mcpServers 字段
|
||||
//! - Setting/Hook → 不支持(Gemini 不支持这些功能)
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use serde_json::Value;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::AppAdapter;
|
||||
use crate::config::atomic_write;
|
||||
use crate::gemini_config::{get_gemini_dir, get_gemini_settings_path};
|
||||
|
||||
/// Gemini 应用适配器
|
||||
pub struct GeminiAdapter {
|
||||
config_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl GeminiAdapter {
|
||||
/// 创建新的 Gemini 适配器实例
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
config_dir: get_gemini_dir(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 读取 JSON 配置文件
|
||||
fn read_json_file(path: &PathBuf) -> Result<Value> {
|
||||
if !path.exists() {
|
||||
return Ok(serde_json::json!({}));
|
||||
}
|
||||
let content = fs::read_to_string(path)
|
||||
.with_context(|| format!("读取配置文件失败: {}", path.display()))?;
|
||||
let value: Value = serde_json::from_str(&content)
|
||||
.with_context(|| format!("解析 JSON 失败: {}", path.display()))?;
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
/// 写入 JSON 配置文件(原子写入)
|
||||
fn write_json_file(path: &Path, value: &Value) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.with_context(|| format!("创建目录失败: {}", parent.display()))?;
|
||||
}
|
||||
|
||||
let json = serde_json::to_string_pretty(value).context("序列化 JSON 失败")?;
|
||||
|
||||
atomic_write(path, json.as_bytes())
|
||||
.with_context(|| format!("写入配置文件失败: {}", path.display()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 合并两个 JSON 对象(深度合并)
|
||||
fn merge_json(base: &mut Value, overlay: &Value) {
|
||||
if let (Some(base_obj), Some(overlay_obj)) = (base.as_object_mut(), overlay.as_object()) {
|
||||
for (key, value) in overlay_obj {
|
||||
if let Some(base_value) = base_obj.get_mut(key) {
|
||||
// 如果两边都是对象,递归合并
|
||||
if base_value.is_object() && value.is_object() {
|
||||
Self::merge_json(base_value, value);
|
||||
} else {
|
||||
// 否则直接覆盖
|
||||
*base_value = value.clone();
|
||||
}
|
||||
} else {
|
||||
// 键不存在,直接插入
|
||||
base_obj.insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 安装 Markdown 文件(通用)
|
||||
fn install_markdown_file(&self, content: &str, subdir: &str, name: &str) -> Result<PathBuf> {
|
||||
let dir = self.config_dir.join(subdir);
|
||||
fs::create_dir_all(&dir).with_context(|| format!("创建目录失败: {}", dir.display()))?;
|
||||
|
||||
let filename = if name.ends_with(".md") {
|
||||
name.to_string()
|
||||
} else {
|
||||
format!("{name}.md")
|
||||
};
|
||||
|
||||
let file_path = dir.join(&filename);
|
||||
|
||||
atomic_write(&file_path, content.as_bytes())
|
||||
.with_context(|| format!("写入文件失败: {}", file_path.display()))?;
|
||||
|
||||
log::info!("已安装 Gemini {}: {}", subdir, file_path.display());
|
||||
Ok(file_path)
|
||||
}
|
||||
|
||||
/// 获取 Gemini settings.json 路径
|
||||
fn get_settings_path(&self) -> PathBuf {
|
||||
get_gemini_settings_path()
|
||||
}
|
||||
|
||||
/// 转换 MCP 配置为 Gemini 格式
|
||||
///
|
||||
/// Gemini 使用特殊格式:
|
||||
/// - HTTP 类型:使用 `httpUrl` 而不是 `url` + `type: "http"`
|
||||
/// - SSE/stdio 类型:保持标准格式
|
||||
fn transform_mcp_to_gemini(mcp_config: &Value) -> Result<Value> {
|
||||
let mut transformed = mcp_config.clone();
|
||||
|
||||
if let Some(obj) = transformed.as_object_mut() {
|
||||
for (_server_id, server_spec) in obj.iter_mut() {
|
||||
if let Some(spec_obj) = server_spec.as_object_mut() {
|
||||
// 检查是否为 HTTP 类型
|
||||
let is_http = spec_obj
|
||||
.get("type")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|t| t == "http")
|
||||
.unwrap_or(false);
|
||||
|
||||
if is_http {
|
||||
// 将 url 字段转换为 httpUrl
|
||||
if let Some(url) = spec_obj.remove("url") {
|
||||
spec_obj.insert("httpUrl".to_string(), url);
|
||||
}
|
||||
// 移除 type 字段(Gemini 不需要显式指定 type)
|
||||
spec_obj.remove("type");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(transformed)
|
||||
}
|
||||
}
|
||||
|
||||
impl AppAdapter for GeminiAdapter {
|
||||
fn install_agent(&self, content: &str, name: &str) -> Result<PathBuf> {
|
||||
self.install_markdown_file(content, "agents", name)
|
||||
}
|
||||
|
||||
fn install_command(&self, content: &str, name: &str) -> Result<PathBuf> {
|
||||
self.install_markdown_file(content, "commands", name)
|
||||
}
|
||||
|
||||
fn install_mcp(&self, mcp_config: &Value) -> Result<()> {
|
||||
let settings_path = self.get_settings_path();
|
||||
|
||||
// 读取现有配置
|
||||
let mut current = Self::read_json_file(&settings_path)?;
|
||||
|
||||
// 确保 mcpServers 字段存在
|
||||
if !current.is_object() {
|
||||
current = serde_json::json!({});
|
||||
}
|
||||
if current.get("mcpServers").is_none() {
|
||||
current["mcpServers"] = serde_json::json!({});
|
||||
}
|
||||
|
||||
// 转换 MCP 配置为 Gemini 格式
|
||||
let transformed = Self::transform_mcp_to_gemini(mcp_config)?;
|
||||
|
||||
// 合并新的 MCP 服务器配置
|
||||
if let Some(mcp_servers) = current.get_mut("mcpServers") {
|
||||
Self::merge_json(mcp_servers, &transformed);
|
||||
}
|
||||
|
||||
// 写回配置文件
|
||||
Self::write_json_file(&settings_path, ¤t)?;
|
||||
|
||||
log::info!("已安装 Gemini MCP 配置到: {}", settings_path.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn install_setting(&self, _setting_config: &Value) -> Result<()> {
|
||||
bail!("Gemini 不支持 Setting 配置")
|
||||
}
|
||||
|
||||
fn install_hook(&self, _hook_config: &Value) -> Result<()> {
|
||||
bail!("Gemini 不支持 Hook 配置")
|
||||
}
|
||||
|
||||
fn uninstall(&self, component_type: &str, name: &str) -> Result<()> {
|
||||
match component_type.to_lowercase().as_str() {
|
||||
"agent" => {
|
||||
let path = self.config_dir.join("agents").join(format!("{name}.md"));
|
||||
if path.exists() {
|
||||
fs::remove_file(&path)
|
||||
.with_context(|| format!("删除 Agent 文件失败: {}", path.display()))?;
|
||||
log::info!("已卸载 Gemini Agent: {}", path.display());
|
||||
}
|
||||
}
|
||||
"command" => {
|
||||
let path = self.config_dir.join("commands").join(format!("{name}.md"));
|
||||
if path.exists() {
|
||||
fs::remove_file(&path)
|
||||
.with_context(|| format!("删除 Command 文件失败: {}", path.display()))?;
|
||||
log::info!("已卸载 Gemini Command: {}", path.display());
|
||||
}
|
||||
}
|
||||
"mcp" => {
|
||||
let settings_path = self.get_settings_path();
|
||||
let mut current = Self::read_json_file(&settings_path)?;
|
||||
|
||||
if let Some(mcp_servers) = current
|
||||
.get_mut("mcpServers")
|
||||
.and_then(|v| v.as_object_mut())
|
||||
{
|
||||
mcp_servers.remove(name);
|
||||
Self::write_json_file(&settings_path, ¤t)?;
|
||||
log::info!("已卸载 Gemini MCP 服务器: {name}");
|
||||
}
|
||||
}
|
||||
"setting" | "hook" => {
|
||||
bail!("Gemini 不支持 {component_type} 组件类型")
|
||||
}
|
||||
_ => bail!("不支持的组件类型: {component_type}"),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn config_dir(&self) -> PathBuf {
|
||||
self.config_dir.clone()
|
||||
}
|
||||
|
||||
fn supports_component_type(&self, component_type: &str) -> bool {
|
||||
matches!(
|
||||
component_type.to_lowercase().as_str(),
|
||||
"agent" | "command" | "mcp"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for GeminiAdapter {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
//! 应用适配器模块
|
||||
//!
|
||||
//! 负责将 Template 组件安装到不同应用的配置目录中。
|
||||
//! 每个应用有独立的适配器实现,处理各自的配置格式和目录结构。
|
||||
|
||||
mod claude;
|
||||
mod codex;
|
||||
mod gemini;
|
||||
|
||||
use anyhow::Result;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub use claude::ClaudeAdapter;
|
||||
pub use codex::CodexAdapter;
|
||||
pub use gemini::GeminiAdapter;
|
||||
|
||||
use crate::app_config::AppType;
|
||||
|
||||
/// 应用适配器 trait
|
||||
///
|
||||
/// 定义了将 Template 组件安装到应用配置目录的统一接口。
|
||||
/// 每个应用实现自己的适配器来处理特定的配置格式和目录结构。
|
||||
#[allow(dead_code)]
|
||||
pub trait AppAdapter: Send + Sync {
|
||||
/// 安装 Agent 到应用配置目录
|
||||
///
|
||||
/// # 参数
|
||||
/// - `content`: Agent 内容(Markdown 格式)
|
||||
/// - `name`: Agent 名称(用作文件名)
|
||||
///
|
||||
/// # 返回
|
||||
/// 安装后的文件路径
|
||||
fn install_agent(&self, content: &str, name: &str) -> Result<PathBuf>;
|
||||
|
||||
/// 安装 Command 到应用配置目录
|
||||
///
|
||||
/// # 参数
|
||||
/// - `content`: Command 内容(Markdown 格式)
|
||||
/// - `name`: Command 名称(用作文件名)
|
||||
///
|
||||
/// # 返回
|
||||
/// 安装后的文件路径
|
||||
fn install_command(&self, content: &str, name: &str) -> Result<PathBuf>;
|
||||
|
||||
/// 安装 MCP 服务器配置
|
||||
///
|
||||
/// # 参数
|
||||
/// - `mcp_config`: MCP 服务器配置(JSON 对象)
|
||||
///
|
||||
/// # 说明
|
||||
/// 配置会合并到应用的 MCP 配置文件中,保留现有配置。
|
||||
fn install_mcp(&self, mcp_config: &serde_json::Value) -> Result<()>;
|
||||
|
||||
/// 安装 Setting (permissions)
|
||||
///
|
||||
/// # 参数
|
||||
/// - `setting_config`: Setting 配置(JSON 对象)
|
||||
///
|
||||
/// # 说明
|
||||
/// 仅 Claude 支持此功能,会合并到 settings.json 的 permissions 字段。
|
||||
fn install_setting(&self, setting_config: &serde_json::Value) -> Result<()>;
|
||||
|
||||
/// 安装 Hook
|
||||
///
|
||||
/// # 参数
|
||||
/// - `hook_config`: Hook 配置(JSON 对象)
|
||||
///
|
||||
/// # 说明
|
||||
/// 仅 Claude 支持此功能,会合并到 settings.json 的 hooks 字段。
|
||||
fn install_hook(&self, hook_config: &serde_json::Value) -> Result<()>;
|
||||
|
||||
/// 卸载组件
|
||||
///
|
||||
/// # 参数
|
||||
/// - `component_type`: 组件类型(agent/command/mcp/setting/hook)
|
||||
/// - `name`: 组件名称或 ID
|
||||
fn uninstall(&self, component_type: &str, name: &str) -> Result<()>;
|
||||
|
||||
/// 获取配置目录路径
|
||||
fn config_dir(&self) -> PathBuf;
|
||||
|
||||
/// 检查组件类型是否支持
|
||||
///
|
||||
/// # 参数
|
||||
/// - `component_type`: 组件类型字符串
|
||||
///
|
||||
/// # 返回
|
||||
/// 如果应用支持该组件类型返回 true,否则返回 false
|
||||
#[allow(dead_code)]
|
||||
fn supports_component_type(&self, component_type: &str) -> bool;
|
||||
}
|
||||
|
||||
/// 创建应用适配器工厂函数
|
||||
///
|
||||
/// # 参数
|
||||
/// - `app_type`: 应用类型
|
||||
///
|
||||
/// # 返回
|
||||
/// 对应应用的适配器实例
|
||||
#[allow(dead_code)]
|
||||
pub fn create_adapter(app_type: &AppType) -> Box<dyn AppAdapter> {
|
||||
match app_type {
|
||||
AppType::Claude => Box::new(ClaudeAdapter::new()),
|
||||
AppType::Codex => Box::new(CodexAdapter::new()),
|
||||
AppType::Gemini => Box::new(GeminiAdapter::new()),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,746 @@
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use rusqlite::{params, Connection};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::time::timeout;
|
||||
|
||||
use super::{ComponentMetadata, ComponentType, TemplateComponent, TemplateRepo, TemplateService};
|
||||
|
||||
impl TemplateService {
|
||||
/// 刷新所有启用仓库的组件索引
|
||||
pub async fn refresh_index(&self, conn: &Connection) -> Result<()> {
|
||||
// 获取所有启用的仓库
|
||||
let repos = self.list_enabled_repos(conn)?;
|
||||
|
||||
if repos.is_empty() {
|
||||
log::info!("没有启用的模板仓库");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
log::info!("开始刷新 {} 个模板仓库", repos.len());
|
||||
|
||||
// 并行扫描所有仓库
|
||||
let scan_tasks = repos.iter().map(|repo| self.scan_repo(repo));
|
||||
let results: Vec<Result<Vec<TemplateComponent>>> =
|
||||
futures::future::join_all(scan_tasks).await;
|
||||
|
||||
// 处理扫描结果
|
||||
let mut total_components = 0;
|
||||
for (repo, result) in repos.iter().zip(results.into_iter()) {
|
||||
match result {
|
||||
Ok(components) => {
|
||||
log::info!(
|
||||
"仓库 {}/{} 扫描到 {} 个组件",
|
||||
repo.owner,
|
||||
repo.name,
|
||||
components.len()
|
||||
);
|
||||
|
||||
// 保存到数据库
|
||||
if let Err(e) = self.save_components(conn, &components) {
|
||||
log::error!("保存组件到数据库失败: {e}");
|
||||
} else {
|
||||
total_components += components.len();
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("扫描仓库 {}/{} 失败: {}", repo.owner, repo.name, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log::info!("刷新完成,共索引 {total_components} 个组件");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 扫描单个仓库
|
||||
pub async fn scan_repo(&self, repo: &TemplateRepo) -> Result<Vec<TemplateComponent>> {
|
||||
log::info!("开始扫描仓库: {}/{}", repo.owner, repo.name);
|
||||
|
||||
// 下载仓库(增加超时控制)
|
||||
let temp_dir = timeout(
|
||||
std::time::Duration::from_secs(120),
|
||||
self.download_repo(repo),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| anyhow!("下载仓库超时: {}/{}", repo.owner, repo.name))??;
|
||||
|
||||
let mut components = Vec::new();
|
||||
|
||||
// 扫描不同类型的组件
|
||||
self.scan_agents(&temp_dir, repo, &mut components)?;
|
||||
self.scan_commands(&temp_dir, repo, &mut components)?;
|
||||
self.scan_mcps(&temp_dir, repo, &mut components)?;
|
||||
self.scan_settings(&temp_dir, repo, &mut components)?;
|
||||
self.scan_hooks(&temp_dir, repo, &mut components)?;
|
||||
self.scan_skills(&temp_dir, repo, &mut components)?;
|
||||
|
||||
// 清理临时目录
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
|
||||
log::info!(
|
||||
"仓库 {}/{} 扫描完成,找到 {} 个组件",
|
||||
repo.owner,
|
||||
repo.name,
|
||||
components.len()
|
||||
);
|
||||
|
||||
Ok(components)
|
||||
}
|
||||
|
||||
/// 下载仓库 ZIP
|
||||
async fn download_repo(&self, repo: &TemplateRepo) -> Result<PathBuf> {
|
||||
let temp_dir = tempfile::tempdir().context("创建临时目录失败")?;
|
||||
let temp_path = temp_dir.path().to_path_buf();
|
||||
let _ = temp_dir.keep();
|
||||
|
||||
// 尝试多个分支
|
||||
let branches = if repo.branch.is_empty() {
|
||||
vec!["main", "master"]
|
||||
} else {
|
||||
vec![repo.branch.as_str(), "main", "master"]
|
||||
};
|
||||
|
||||
let mut last_error = None;
|
||||
for branch in branches {
|
||||
let url = format!(
|
||||
"https://github.com/{}/{}/archive/refs/heads/{}.zip",
|
||||
repo.owner, repo.name, branch
|
||||
);
|
||||
|
||||
log::debug!("尝试下载: {url}");
|
||||
match self.download_and_extract(&url, &temp_path).await {
|
||||
Ok(_) => {
|
||||
log::info!("成功下载仓库: {}/{} ({})", repo.owner, repo.name, branch);
|
||||
return Ok(temp_path);
|
||||
}
|
||||
Err(e) => {
|
||||
log::debug!("下载分支 {branch} 失败: {e}");
|
||||
last_error = Some(e);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(last_error.unwrap_or_else(|| anyhow!("所有分支下载失败")))
|
||||
}
|
||||
|
||||
/// 下载并解压 ZIP
|
||||
async fn download_and_extract(&self, url: &str, dest: &Path) -> Result<()> {
|
||||
// 下载 ZIP
|
||||
let response = self.client().get(url).send().await?;
|
||||
if !response.status().is_success() {
|
||||
anyhow::bail!("下载失败: HTTP {}", response.status());
|
||||
}
|
||||
|
||||
let bytes = response.bytes().await?;
|
||||
|
||||
// 解压
|
||||
let cursor = std::io::Cursor::new(bytes);
|
||||
let mut archive = zip::ZipArchive::new(cursor)?;
|
||||
|
||||
// 获取根目录名称
|
||||
let root_name = if !archive.is_empty() {
|
||||
let first_file = archive.by_index(0)?;
|
||||
let name = first_file.name();
|
||||
name.split('/').next().unwrap_or("").to_string()
|
||||
} else {
|
||||
return Err(anyhow!("空的压缩包"));
|
||||
};
|
||||
|
||||
// 解压所有文件
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive.by_index(i)?;
|
||||
let file_path = file.name();
|
||||
|
||||
// 跳过根目录,直接提取内容
|
||||
let relative_path =
|
||||
if let Some(stripped) = file_path.strip_prefix(&format!("{root_name}/")) {
|
||||
stripped
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if relative_path.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let outpath = dest.join(relative_path);
|
||||
|
||||
if file.is_dir() {
|
||||
fs::create_dir_all(&outpath)?;
|
||||
} else {
|
||||
if let Some(parent) = outpath.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let mut outfile = fs::File::create(&outpath)?;
|
||||
std::io::copy(&mut file, &mut outfile)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 扫描 Agents
|
||||
fn scan_agents(
|
||||
&self,
|
||||
base_dir: &Path,
|
||||
repo: &TemplateRepo,
|
||||
components: &mut Vec<TemplateComponent>,
|
||||
) -> Result<()> {
|
||||
// 尝试多个可能的路径
|
||||
let paths = [
|
||||
base_dir.join("cli-tool").join("components").join("agents"),
|
||||
base_dir.join("src").join("agents"),
|
||||
base_dir.join("components").join("agents"),
|
||||
];
|
||||
for agents_dir in paths {
|
||||
if agents_dir.exists() {
|
||||
self.scan_markdown_components(
|
||||
&agents_dir,
|
||||
base_dir,
|
||||
ComponentType::Agent,
|
||||
repo,
|
||||
components,
|
||||
)?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 扫描 Commands
|
||||
fn scan_commands(
|
||||
&self,
|
||||
base_dir: &Path,
|
||||
repo: &TemplateRepo,
|
||||
components: &mut Vec<TemplateComponent>,
|
||||
) -> Result<()> {
|
||||
let paths = [
|
||||
base_dir
|
||||
.join("cli-tool")
|
||||
.join("components")
|
||||
.join("commands"),
|
||||
base_dir.join("src").join("commands"),
|
||||
base_dir.join("components").join("commands"),
|
||||
];
|
||||
for commands_dir in paths {
|
||||
if commands_dir.exists() {
|
||||
self.scan_markdown_components(
|
||||
&commands_dir,
|
||||
base_dir,
|
||||
ComponentType::Command,
|
||||
repo,
|
||||
components,
|
||||
)?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 扫描 MCPs
|
||||
fn scan_mcps(
|
||||
&self,
|
||||
base_dir: &Path,
|
||||
repo: &TemplateRepo,
|
||||
components: &mut Vec<TemplateComponent>,
|
||||
) -> Result<()> {
|
||||
let paths = [
|
||||
base_dir.join("cli-tool").join("components").join("mcps"),
|
||||
base_dir.join("src").join("mcp"),
|
||||
base_dir.join("components").join("mcps"),
|
||||
];
|
||||
for mcps_dir in paths {
|
||||
if mcps_dir.exists() {
|
||||
self.scan_json_components(
|
||||
&mcps_dir,
|
||||
base_dir,
|
||||
ComponentType::Mcp,
|
||||
repo,
|
||||
components,
|
||||
)?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 扫描 Settings
|
||||
fn scan_settings(
|
||||
&self,
|
||||
base_dir: &Path,
|
||||
repo: &TemplateRepo,
|
||||
components: &mut Vec<TemplateComponent>,
|
||||
) -> Result<()> {
|
||||
let paths = [
|
||||
base_dir
|
||||
.join("cli-tool")
|
||||
.join("components")
|
||||
.join("settings"),
|
||||
base_dir.join("src").join("settings"),
|
||||
base_dir.join("components").join("settings"),
|
||||
];
|
||||
for settings_dir in paths {
|
||||
if settings_dir.exists() {
|
||||
self.scan_json_components(
|
||||
&settings_dir,
|
||||
base_dir,
|
||||
ComponentType::Setting,
|
||||
repo,
|
||||
components,
|
||||
)?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 扫描 Hooks
|
||||
fn scan_hooks(
|
||||
&self,
|
||||
base_dir: &Path,
|
||||
repo: &TemplateRepo,
|
||||
components: &mut Vec<TemplateComponent>,
|
||||
) -> Result<()> {
|
||||
let paths = [
|
||||
base_dir.join("cli-tool").join("components").join("hooks"),
|
||||
base_dir.join("src").join("hooks"),
|
||||
base_dir.join("components").join("hooks"),
|
||||
];
|
||||
for hooks_dir in paths {
|
||||
if hooks_dir.exists() {
|
||||
self.scan_json_components(
|
||||
&hooks_dir,
|
||||
base_dir,
|
||||
ComponentType::Hook,
|
||||
repo,
|
||||
components,
|
||||
)?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 扫描 Skills
|
||||
fn scan_skills(
|
||||
&self,
|
||||
base_dir: &Path,
|
||||
repo: &TemplateRepo,
|
||||
components: &mut Vec<TemplateComponent>,
|
||||
) -> Result<()> {
|
||||
let paths = [
|
||||
base_dir.join("cli-tool").join("components").join("skills"),
|
||||
base_dir.join("src").join("skills"),
|
||||
base_dir.join("components").join("skills"),
|
||||
];
|
||||
for skills_dir in paths {
|
||||
if skills_dir.exists() {
|
||||
self.scan_skills_recursive(&skills_dir, base_dir, repo, components)?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 扫描 Markdown 组件(Agent/Command)
|
||||
fn scan_markdown_components(
|
||||
&self,
|
||||
dir: &Path,
|
||||
base_dir: &Path,
|
||||
component_type: ComponentType,
|
||||
repo: &TemplateRepo,
|
||||
components: &mut Vec<TemplateComponent>,
|
||||
) -> Result<()> {
|
||||
for entry in fs::read_dir(dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
|
||||
if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("md") {
|
||||
if let Ok(component) =
|
||||
self.parse_markdown_component(&path, base_dir, component_type.clone(), repo)
|
||||
{
|
||||
components.push(component);
|
||||
}
|
||||
} else if path.is_dir() {
|
||||
// 递归扫描子目录(用于分类)
|
||||
self.scan_markdown_components(
|
||||
&path,
|
||||
base_dir,
|
||||
component_type.clone(),
|
||||
repo,
|
||||
components,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 扫描 JSON 组件(MCP/Setting/Hook)
|
||||
fn scan_json_components(
|
||||
&self,
|
||||
dir: &Path,
|
||||
base_dir: &Path,
|
||||
component_type: ComponentType,
|
||||
repo: &TemplateRepo,
|
||||
components: &mut Vec<TemplateComponent>,
|
||||
) -> Result<()> {
|
||||
for entry in fs::read_dir(dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
|
||||
if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("json") {
|
||||
if let Ok(component) =
|
||||
self.parse_json_component(&path, base_dir, component_type.clone(), repo)
|
||||
{
|
||||
components.push(component);
|
||||
}
|
||||
} else if path.is_dir() {
|
||||
// 递归扫描子目录(用于分类)
|
||||
self.scan_json_components(
|
||||
&path,
|
||||
base_dir,
|
||||
component_type.clone(),
|
||||
repo,
|
||||
components,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 递归扫描技能目录
|
||||
fn scan_skills_recursive(
|
||||
&self,
|
||||
current_dir: &Path,
|
||||
base_dir: &Path,
|
||||
repo: &TemplateRepo,
|
||||
components: &mut Vec<TemplateComponent>,
|
||||
) -> Result<()> {
|
||||
let skill_md = current_dir.join("SKILL.md");
|
||||
|
||||
if skill_md.exists() {
|
||||
// 发现技能
|
||||
if let Ok(component) = self.parse_skill_component(&skill_md, base_dir, repo) {
|
||||
components.push(component);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 继续递归扫描子目录
|
||||
for entry in fs::read_dir(current_dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
self.scan_skills_recursive(&path, base_dir, repo, components)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 解析 Markdown 组件元数据
|
||||
fn parse_markdown_component(
|
||||
&self,
|
||||
path: &Path,
|
||||
base_dir: &Path,
|
||||
component_type: ComponentType,
|
||||
repo: &TemplateRepo,
|
||||
) -> Result<TemplateComponent> {
|
||||
let content = fs::read_to_string(path)?;
|
||||
let meta = self.parse_component_metadata(&content)?;
|
||||
|
||||
let file_name = path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("unknown");
|
||||
|
||||
// 提取分类(从目录结构)
|
||||
let category = self.extract_category(path, &format!("src/{}", component_type.as_str()));
|
||||
|
||||
// 计算相对于仓库根目录的路径
|
||||
let relative_path = path
|
||||
.strip_prefix(base_dir)
|
||||
.unwrap_or(path)
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
Ok(TemplateComponent {
|
||||
id: None,
|
||||
repo_id: repo.id.unwrap_or(0),
|
||||
component_type,
|
||||
category,
|
||||
name: meta.name.unwrap_or_else(|| file_name.to_string()),
|
||||
path: relative_path,
|
||||
description: meta.description,
|
||||
content_hash: Some(Self::calculate_hash(&content)),
|
||||
installed: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// 解析 JSON 组件元数据
|
||||
fn parse_json_component(
|
||||
&self,
|
||||
path: &Path,
|
||||
base_dir: &Path,
|
||||
component_type: ComponentType,
|
||||
repo: &TemplateRepo,
|
||||
) -> Result<TemplateComponent> {
|
||||
let content = fs::read_to_string(path)?;
|
||||
let json: serde_json::Value = serde_json::from_str(&content)?;
|
||||
|
||||
let file_name = path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("unknown");
|
||||
|
||||
let name = json
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(file_name)
|
||||
.to_string();
|
||||
|
||||
let description = json
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
|
||||
let category = self.extract_category(path, &format!("src/{}", component_type.as_str()));
|
||||
|
||||
// 计算相对于仓库根目录的路径
|
||||
let relative_path = path
|
||||
.strip_prefix(base_dir)
|
||||
.unwrap_or(path)
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
Ok(TemplateComponent {
|
||||
id: None,
|
||||
repo_id: repo.id.unwrap_or(0),
|
||||
component_type,
|
||||
category,
|
||||
name,
|
||||
path: relative_path,
|
||||
description,
|
||||
content_hash: Some(Self::calculate_hash(&content)),
|
||||
installed: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// 解析技能组件
|
||||
fn parse_skill_component(
|
||||
&self,
|
||||
skill_md: &Path,
|
||||
base_dir: &Path,
|
||||
repo: &TemplateRepo,
|
||||
) -> Result<TemplateComponent> {
|
||||
let content = fs::read_to_string(skill_md)?;
|
||||
let meta = self.parse_component_metadata(&content)?;
|
||||
|
||||
let skill_dir = skill_md.parent().unwrap();
|
||||
let directory = skill_dir
|
||||
.strip_prefix(base_dir)
|
||||
.unwrap_or(skill_dir)
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
Ok(TemplateComponent {
|
||||
id: None,
|
||||
repo_id: repo.id.unwrap_or(0),
|
||||
component_type: ComponentType::Skill,
|
||||
category: None,
|
||||
name: meta.name.unwrap_or_else(|| directory.clone()),
|
||||
path: directory,
|
||||
description: meta.description,
|
||||
content_hash: Some(Self::calculate_hash(&content)),
|
||||
installed: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// 解析组件元数据(从 front matter)
|
||||
pub fn parse_component_metadata(&self, content: &str) -> Result<ComponentMetadata> {
|
||||
// 移除 BOM
|
||||
let content = content.trim_start_matches('\u{feff}');
|
||||
|
||||
// 提取 YAML front matter
|
||||
let parts: Vec<&str> = content.splitn(3, "---").collect();
|
||||
if parts.len() < 3 {
|
||||
return Ok(ComponentMetadata {
|
||||
name: None,
|
||||
description: None,
|
||||
tools: None,
|
||||
model: None,
|
||||
});
|
||||
}
|
||||
|
||||
let front_matter = parts[1].trim();
|
||||
let meta: ComponentMetadata =
|
||||
serde_yaml::from_str(front_matter).unwrap_or(ComponentMetadata {
|
||||
name: None,
|
||||
description: None,
|
||||
tools: None,
|
||||
model: None,
|
||||
});
|
||||
|
||||
Ok(meta)
|
||||
}
|
||||
|
||||
/// 提取分类(从路径)
|
||||
fn extract_category(&self, path: &Path, base: &str) -> Option<String> {
|
||||
let path_str = path.to_string_lossy();
|
||||
if let Some(pos) = path_str.find(base) {
|
||||
let after_base = &path_str[pos + base.len()..];
|
||||
let parts: Vec<&str> = after_base.split('/').filter(|s| !s.is_empty()).collect();
|
||||
if parts.len() > 1 {
|
||||
return Some(parts[0].to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 计算内容哈希
|
||||
fn calculate_hash(content: &str) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(content.as_bytes());
|
||||
format!("{:x}", hasher.finalize())
|
||||
}
|
||||
|
||||
/// 保存组件到数据库
|
||||
fn save_components(&self, conn: &Connection, components: &[TemplateComponent]) -> Result<()> {
|
||||
for component in components {
|
||||
// 检查是否已存在(通过 repo_id + component_type + path)
|
||||
let existing: Option<i64> = conn
|
||||
.query_row(
|
||||
"SELECT id FROM template_components
|
||||
WHERE repo_id = ?1 AND component_type = ?2 AND path = ?3",
|
||||
params![
|
||||
component.repo_id,
|
||||
component.component_type.as_str(),
|
||||
&component.path
|
||||
],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.ok();
|
||||
|
||||
if let Some(id) = existing {
|
||||
// 更新现有组件
|
||||
conn.execute(
|
||||
"UPDATE template_components
|
||||
SET category = ?1, name = ?2, description = ?3, content_hash = ?4, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?5",
|
||||
params![
|
||||
&component.category,
|
||||
&component.name,
|
||||
&component.description,
|
||||
&component.content_hash,
|
||||
id
|
||||
],
|
||||
)?;
|
||||
} else {
|
||||
// 插入新组件
|
||||
conn.execute(
|
||||
"INSERT INTO template_components (repo_id, component_type, category, name, path, description, content_hash)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
params![
|
||||
component.repo_id,
|
||||
component.component_type.as_str(),
|
||||
&component.category,
|
||||
&component.name,
|
||||
&component.path,
|
||||
&component.description,
|
||||
&component.content_hash
|
||||
],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 列出组件(支持过滤和分页)
|
||||
#[allow(dead_code)]
|
||||
pub fn list_components(
|
||||
&self,
|
||||
conn: &Connection,
|
||||
component_type: Option<ComponentType>,
|
||||
category: Option<String>,
|
||||
search: Option<String>,
|
||||
page: u32,
|
||||
page_size: u32,
|
||||
) -> Result<super::PaginatedResult<TemplateComponent>> {
|
||||
let mut where_clauses = Vec::new();
|
||||
let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
||||
|
||||
if let Some(ct) = &component_type {
|
||||
where_clauses.push("component_type = ?");
|
||||
params.push(Box::new(ct.as_str().to_string()));
|
||||
}
|
||||
|
||||
if let Some(cat) = &category {
|
||||
where_clauses.push("category = ?");
|
||||
params.push(Box::new(cat.clone()));
|
||||
}
|
||||
|
||||
if let Some(s) = &search {
|
||||
where_clauses.push("(name LIKE ? OR description LIKE ?)");
|
||||
let search_pattern = format!("%{s}%");
|
||||
params.push(Box::new(search_pattern.clone()));
|
||||
params.push(Box::new(search_pattern));
|
||||
}
|
||||
|
||||
let where_sql = if where_clauses.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("WHERE {}", where_clauses.join(" AND "))
|
||||
};
|
||||
|
||||
// 获取总数
|
||||
let count_sql = format!("SELECT COUNT(*) FROM template_components {where_sql}");
|
||||
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
||||
let total: i64 = conn.query_row(&count_sql, param_refs.as_slice(), |row| row.get(0))?;
|
||||
|
||||
// 获取分页数据
|
||||
let offset = (page - 1) * page_size;
|
||||
let query_sql = format!(
|
||||
"SELECT id, repo_id, component_type, category, name, path, description, content_hash
|
||||
FROM template_components
|
||||
{where_sql}
|
||||
ORDER BY name
|
||||
LIMIT ? OFFSET ?"
|
||||
);
|
||||
|
||||
params.push(Box::new(page_size as i64));
|
||||
params.push(Box::new(offset as i64));
|
||||
|
||||
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
||||
|
||||
let mut stmt = conn.prepare(&query_sql)?;
|
||||
let components = stmt
|
||||
.query_map(param_refs.as_slice(), |row| {
|
||||
Ok(TemplateComponent {
|
||||
id: Some(row.get(0)?),
|
||||
repo_id: row.get(1)?,
|
||||
component_type: ComponentType::from_str(&row.get::<_, String>(2)?)
|
||||
.unwrap_or(ComponentType::Agent),
|
||||
category: row.get(3)?,
|
||||
name: row.get(4)?,
|
||||
path: row.get(5)?,
|
||||
description: row.get(6)?,
|
||||
content_hash: row.get(7)?,
|
||||
installed: false,
|
||||
})
|
||||
})?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(super::PaginatedResult {
|
||||
items: components,
|
||||
total,
|
||||
page,
|
||||
page_size,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
use anyhow::Result;
|
||||
use rusqlite::{params, Connection};
|
||||
use std::fs;
|
||||
|
||||
use super::{
|
||||
BatchInstallResult, ComponentDetail, ComponentType, InstalledComponent, TemplateComponent,
|
||||
TemplateService,
|
||||
};
|
||||
|
||||
impl TemplateService {
|
||||
/// 获取组件详情(含完整内容)
|
||||
pub async fn get_component(&self, conn: &Connection, id: i64) -> Result<ComponentDetail> {
|
||||
// 查询组件基本信息
|
||||
let component: TemplateComponent = conn.query_row(
|
||||
"SELECT id, repo_id, component_type, category, name, path, description, content_hash
|
||||
FROM template_components
|
||||
WHERE id = ?1",
|
||||
params![id],
|
||||
|row| {
|
||||
Ok(TemplateComponent {
|
||||
id: Some(row.get(0)?),
|
||||
repo_id: row.get(1)?,
|
||||
component_type: ComponentType::from_str(&row.get::<_, String>(2)?)
|
||||
.unwrap_or(ComponentType::Agent),
|
||||
category: row.get(3)?,
|
||||
name: row.get(4)?,
|
||||
path: row.get(5)?,
|
||||
description: row.get(6)?,
|
||||
content_hash: row.get(7)?,
|
||||
installed: false,
|
||||
})
|
||||
},
|
||||
)?;
|
||||
|
||||
// 查询仓库信息
|
||||
let (repo_owner, repo_name, branch): (String, String, String) = conn.query_row(
|
||||
"SELECT owner, name, branch FROM template_repos WHERE id = ?1",
|
||||
params![component.repo_id],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
||||
)?;
|
||||
|
||||
// 构建 README URL
|
||||
let readme_url = format!(
|
||||
"https://github.com/{}/{}/tree/{}/{}",
|
||||
repo_owner, repo_name, branch, component.path
|
||||
);
|
||||
|
||||
// 下载并读取组件内容
|
||||
let content = self
|
||||
.download_component_content(&repo_owner, &repo_name, &branch, &component.path)
|
||||
.await?;
|
||||
|
||||
Ok(ComponentDetail {
|
||||
component,
|
||||
content,
|
||||
repo_owner,
|
||||
repo_name,
|
||||
repo_branch: branch,
|
||||
readme_url,
|
||||
})
|
||||
}
|
||||
|
||||
/// 下载组件内容
|
||||
async fn download_component_content(
|
||||
&self,
|
||||
owner: &str,
|
||||
name: &str,
|
||||
branch: &str,
|
||||
path: &str,
|
||||
) -> Result<String> {
|
||||
let url = format!("https://raw.githubusercontent.com/{owner}/{name}/{branch}/{path}");
|
||||
|
||||
let response = self.client().get(&url).send().await?;
|
||||
if !response.status().is_success() {
|
||||
anyhow::bail!("下载组件内容失败: HTTP {}", response.status());
|
||||
}
|
||||
|
||||
let content = response.text().await?;
|
||||
Ok(content)
|
||||
}
|
||||
|
||||
/// 安装组件到指定应用
|
||||
pub async fn install_component(
|
||||
&self,
|
||||
conn: &Connection,
|
||||
id: i64,
|
||||
app_type: &str,
|
||||
) -> Result<()> {
|
||||
// 获取组件详情
|
||||
let detail = self.get_component(conn, id).await?;
|
||||
|
||||
// 根据组件类型执行不同的安装逻辑
|
||||
match detail.component.component_type {
|
||||
ComponentType::Agent => {
|
||||
self.install_agent(&detail, app_type).await?;
|
||||
}
|
||||
ComponentType::Command => {
|
||||
self.install_command(&detail, app_type).await?;
|
||||
}
|
||||
ComponentType::Mcp => {
|
||||
self.install_mcp(&detail, app_type).await?;
|
||||
}
|
||||
ComponentType::Setting => {
|
||||
self.install_setting(&detail, app_type).await?;
|
||||
}
|
||||
ComponentType::Hook => {
|
||||
self.install_hook(&detail, app_type).await?;
|
||||
}
|
||||
ComponentType::Skill => {
|
||||
self.install_skill(&detail, app_type).await?;
|
||||
}
|
||||
}
|
||||
|
||||
// 记录安装状态
|
||||
self.record_installation(conn, &detail.component, app_type)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 安装 Agent
|
||||
async fn install_agent(&self, detail: &ComponentDetail, app_type: &str) -> Result<()> {
|
||||
let config_dir = Self::get_app_config_dir(app_type)?;
|
||||
let agents_dir = config_dir.join("agents");
|
||||
fs::create_dir_all(&agents_dir)?;
|
||||
|
||||
let file_name = format!("{}.md", detail.component.name);
|
||||
let dest_path = agents_dir.join(&file_name);
|
||||
|
||||
fs::write(&dest_path, &detail.content)?;
|
||||
log::info!("Agent 已安装: {}", dest_path.display());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 安装 Command
|
||||
async fn install_command(&self, detail: &ComponentDetail, app_type: &str) -> Result<()> {
|
||||
let config_dir = Self::get_app_config_dir(app_type)?;
|
||||
let commands_dir = config_dir.join("commands");
|
||||
fs::create_dir_all(&commands_dir)?;
|
||||
|
||||
let file_name = format!("{}.md", detail.component.name);
|
||||
let dest_path = commands_dir.join(&file_name);
|
||||
|
||||
fs::write(&dest_path, &detail.content)?;
|
||||
log::info!("Command 已安装: {}", dest_path.display());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 安装 MCP 服务器
|
||||
/// MCP 配置保存为独立 JSON 文件到 mcps/ 目录,不会修改原有 .mcp.json
|
||||
async fn install_mcp(&self, detail: &ComponentDetail, app_type: &str) -> Result<()> {
|
||||
let config_dir = Self::get_app_config_dir(app_type)?;
|
||||
let mcps_dir = config_dir.join("mcps");
|
||||
fs::create_dir_all(&mcps_dir)?;
|
||||
|
||||
// 保存为独立的 JSON 文件(保留原始格式,包含 mcpServers 结构)
|
||||
let file_name = format!("{}.json", detail.component.name);
|
||||
let dest_path = mcps_dir.join(&file_name);
|
||||
|
||||
fs::write(&dest_path, &detail.content)?;
|
||||
log::info!(
|
||||
"MCP 配置已保存: {} (可手动合并到 .mcp.json)",
|
||||
dest_path.display()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 安装 Setting
|
||||
/// Setting 配置保存为独立 JSON 文件到 settings/ 目录,不会修改原有 settings.json
|
||||
/// 原始格式包含 permissions 等配置,可手动合并
|
||||
async fn install_setting(&self, detail: &ComponentDetail, app_type: &str) -> Result<()> {
|
||||
let config_dir = Self::get_app_config_dir(app_type)?;
|
||||
let settings_dir = config_dir.join("settings");
|
||||
fs::create_dir_all(&settings_dir)?;
|
||||
|
||||
// 保存为独立的 JSON 文件(保留原始格式,包含 permissions 等结构)
|
||||
let file_name = format!("{}.json", detail.component.name);
|
||||
let dest_path = settings_dir.join(&file_name);
|
||||
|
||||
fs::write(&dest_path, &detail.content)?;
|
||||
log::info!(
|
||||
"Setting 配置已保存: {} (可手动合并到 settings.json)",
|
||||
dest_path.display()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 安装 Hook
|
||||
/// Hook 配置保存为独立 JSON 文件到 hooks/ 目录,不会修改原有 settings.json
|
||||
/// 原始格式包含 hooks 对象(如 PostToolUse 等),可手动合并
|
||||
async fn install_hook(&self, detail: &ComponentDetail, app_type: &str) -> Result<()> {
|
||||
let config_dir = Self::get_app_config_dir(app_type)?;
|
||||
let hooks_dir = config_dir.join("hooks");
|
||||
fs::create_dir_all(&hooks_dir)?;
|
||||
|
||||
// 保存为独立的 JSON 文件(保留原始格式,包含 hooks 结构)
|
||||
let file_name = format!("{}.json", detail.component.name);
|
||||
let dest_path = hooks_dir.join(&file_name);
|
||||
|
||||
fs::write(&dest_path, &detail.content)?;
|
||||
log::info!(
|
||||
"Hook 配置已保存: {} (可手动合并到 settings.json 的 hooks 字段)",
|
||||
dest_path.display()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 安装 Skill
|
||||
/// Skill 是一个目录结构,包含 SKILL.md 和可能的子目录(如 reference/, scripts/)
|
||||
/// 使用 GitHub API 递归下载整个目录
|
||||
async fn install_skill(&self, detail: &ComponentDetail, app_type: &str) -> Result<()> {
|
||||
let config_dir = Self::get_app_config_dir(app_type)?;
|
||||
let skills_dir = config_dir.join("skills");
|
||||
fs::create_dir_all(&skills_dir)?;
|
||||
|
||||
let skill_dir = skills_dir.join(&detail.component.name);
|
||||
fs::create_dir_all(&skill_dir)?;
|
||||
|
||||
// 首先保存 SKILL.md(已下载的内容)
|
||||
let skill_md = skill_dir.join("SKILL.md");
|
||||
fs::write(&skill_md, &detail.content)?;
|
||||
|
||||
// 尝试下载整个 skill 目录的其他文件
|
||||
// 构建 GitHub API URL 来获取目录内容
|
||||
let api_url = format!(
|
||||
"https://api.github.com/repos/{}/{}/contents/{}",
|
||||
detail.repo_owner,
|
||||
detail.repo_name,
|
||||
detail.component.path.trim_end_matches("/SKILL.md")
|
||||
);
|
||||
|
||||
// 递归下载目录内容
|
||||
if let Err(e) = self
|
||||
.download_skill_directory(&api_url, &skill_dir, &detail.repo_branch)
|
||||
.await
|
||||
{
|
||||
log::warn!("下载 Skill 附加文件失败: {e},仅安装 SKILL.md");
|
||||
}
|
||||
|
||||
log::info!("Skill 已安装: {}", skill_dir.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 递归下载 Skill 目录内容
|
||||
async fn download_skill_directory(
|
||||
&self,
|
||||
api_url: &str,
|
||||
target_dir: &std::path::Path,
|
||||
branch: &str,
|
||||
) -> Result<()> {
|
||||
let response = self
|
||||
.client()
|
||||
.get(api_url)
|
||||
.header("Accept", "application/vnd.github.v3+json")
|
||||
.header("User-Agent", "cc-switch")
|
||||
.query(&[("ref", branch)])
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
anyhow::bail!("GitHub API 请求失败: {}", response.status());
|
||||
}
|
||||
|
||||
let contents: Vec<serde_json::Value> = response.json().await?;
|
||||
|
||||
for item in contents {
|
||||
let item_type = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let item_name = item.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
|
||||
// 跳过 SKILL.md(已经下载)
|
||||
if item_name == "SKILL.md" {
|
||||
continue;
|
||||
}
|
||||
|
||||
if item_type == "file" {
|
||||
// 下载文件
|
||||
if let Some(download_url) = item.get("download_url").and_then(|v| v.as_str()) {
|
||||
let file_response = self.client().get(download_url).send().await?;
|
||||
if file_response.status().is_success() {
|
||||
let content = file_response.text().await?;
|
||||
let file_path = target_dir.join(item_name);
|
||||
fs::write(&file_path, &content)?;
|
||||
log::debug!("下载文件: {}", file_path.display());
|
||||
}
|
||||
}
|
||||
} else if item_type == "dir" {
|
||||
// 递归下载子目录
|
||||
if let Some(sub_url) = item.get("url").and_then(|v| v.as_str()) {
|
||||
let sub_dir = target_dir.join(item_name);
|
||||
fs::create_dir_all(&sub_dir)?;
|
||||
// 递归调用,使用 Box::pin 处理异步递归
|
||||
Box::pin(self.download_skill_directory(sub_url, &sub_dir, branch)).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 记录安装状态
|
||||
fn record_installation(
|
||||
&self,
|
||||
conn: &Connection,
|
||||
component: &TemplateComponent,
|
||||
app_type: &str,
|
||||
) -> Result<()> {
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO installed_components (component_id, component_type, name, path, app_type)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![
|
||||
component.id,
|
||||
component.component_type.as_str(),
|
||||
&component.name,
|
||||
&component.path,
|
||||
app_type
|
||||
],
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 卸载组件
|
||||
pub fn uninstall_component(&self, conn: &Connection, id: i64, app_type: &str) -> Result<()> {
|
||||
// 查询组件信息
|
||||
let component: TemplateComponent = conn.query_row(
|
||||
"SELECT id, repo_id, component_type, category, name, path, description, content_hash
|
||||
FROM template_components
|
||||
WHERE id = ?1",
|
||||
params![id],
|
||||
|row| {
|
||||
Ok(TemplateComponent {
|
||||
id: Some(row.get(0)?),
|
||||
repo_id: row.get(1)?,
|
||||
component_type: ComponentType::from_str(&row.get::<_, String>(2)?)
|
||||
.unwrap_or(ComponentType::Agent),
|
||||
category: row.get(3)?,
|
||||
name: row.get(4)?,
|
||||
path: row.get(5)?,
|
||||
description: row.get(6)?,
|
||||
content_hash: row.get(7)?,
|
||||
installed: false,
|
||||
})
|
||||
},
|
||||
)?;
|
||||
|
||||
// 删除文件
|
||||
match component.component_type {
|
||||
ComponentType::Agent => {
|
||||
let config_dir = Self::get_app_config_dir(app_type)?;
|
||||
let file_path = config_dir
|
||||
.join("agents")
|
||||
.join(format!("{}.md", component.name));
|
||||
if file_path.exists() {
|
||||
fs::remove_file(&file_path)?;
|
||||
}
|
||||
}
|
||||
ComponentType::Command => {
|
||||
let config_dir = Self::get_app_config_dir(app_type)?;
|
||||
let file_path = config_dir
|
||||
.join("commands")
|
||||
.join(format!("{}.md", component.name));
|
||||
if file_path.exists() {
|
||||
fs::remove_file(&file_path)?;
|
||||
}
|
||||
}
|
||||
ComponentType::Skill => {
|
||||
let config_dir = Self::get_app_config_dir(app_type)?;
|
||||
let skill_dir = config_dir.join("skills").join(&component.name);
|
||||
if skill_dir.exists() {
|
||||
fs::remove_dir_all(&skill_dir)?;
|
||||
}
|
||||
}
|
||||
ComponentType::Mcp => {
|
||||
let config_dir = Self::get_app_config_dir(app_type)?;
|
||||
let file_path = config_dir
|
||||
.join("mcps")
|
||||
.join(format!("{}.json", component.name));
|
||||
if file_path.exists() {
|
||||
fs::remove_file(&file_path)?;
|
||||
}
|
||||
}
|
||||
ComponentType::Setting => {
|
||||
let config_dir = Self::get_app_config_dir(app_type)?;
|
||||
let file_path = config_dir
|
||||
.join("settings")
|
||||
.join(format!("{}.json", component.name));
|
||||
if file_path.exists() {
|
||||
fs::remove_file(&file_path)?;
|
||||
}
|
||||
}
|
||||
ComponentType::Hook => {
|
||||
let config_dir = Self::get_app_config_dir(app_type)?;
|
||||
let file_path = config_dir
|
||||
.join("hooks")
|
||||
.join(format!("{}.json", component.name));
|
||||
if file_path.exists() {
|
||||
fs::remove_file(&file_path)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 删除安装记录
|
||||
conn.execute(
|
||||
"DELETE FROM installed_components
|
||||
WHERE component_id = ?1 AND app_type = ?2",
|
||||
params![id, app_type],
|
||||
)?;
|
||||
|
||||
log::info!("组件已卸载: {}", component.name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 批量安装组件
|
||||
pub async fn batch_install(
|
||||
&self,
|
||||
conn: &Connection,
|
||||
ids: Vec<i64>,
|
||||
app_type: &str,
|
||||
) -> Result<BatchInstallResult> {
|
||||
let mut success = Vec::new();
|
||||
let mut failed = Vec::new();
|
||||
|
||||
for id in ids {
|
||||
match self.install_component(conn, id, app_type).await {
|
||||
Ok(_) => success.push(id),
|
||||
Err(e) => failed.push((id, e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(BatchInstallResult { success, failed })
|
||||
}
|
||||
|
||||
/// 列出已安装的组件
|
||||
#[allow(dead_code)]
|
||||
pub fn list_installed(
|
||||
&self,
|
||||
conn: &Connection,
|
||||
app_type: Option<&str>,
|
||||
) -> Result<Vec<InstalledComponent>> {
|
||||
let (sql, params): (String, Vec<Box<dyn rusqlite::ToSql>>) = if let Some(at) = app_type {
|
||||
(
|
||||
"SELECT id, component_id, component_type, name, path, app_type, installed_at
|
||||
FROM installed_components
|
||||
WHERE app_type = ?
|
||||
ORDER BY installed_at DESC"
|
||||
.to_string(),
|
||||
vec![Box::new(at.to_string())],
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"SELECT id, component_id, component_type, name, path, app_type, installed_at
|
||||
FROM installed_components
|
||||
ORDER BY installed_at DESC"
|
||||
.to_string(),
|
||||
vec![],
|
||||
)
|
||||
};
|
||||
|
||||
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
||||
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let components = stmt
|
||||
.query_map(param_refs.as_slice(), |row| {
|
||||
Ok(InstalledComponent {
|
||||
id: Some(row.get(0)?),
|
||||
component_id: row.get(1)?,
|
||||
component_type: ComponentType::from_str(&row.get::<_, String>(2)?)
|
||||
.unwrap_or(ComponentType::Agent),
|
||||
name: row.get(3)?,
|
||||
path: row.get(4)?,
|
||||
app_type: row.get(5)?,
|
||||
installed_at: row
|
||||
.get::<_, String>(6)?
|
||||
.parse()
|
||||
.unwrap_or_else(|_| chrono::Utc::now()),
|
||||
})
|
||||
})?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(components)
|
||||
}
|
||||
|
||||
/// 预览组件内容(仅获取内容,不进行安装)
|
||||
pub async fn preview_content(&self, conn: &Connection, id: i64) -> Result<String> {
|
||||
// 查询组件基本信息
|
||||
let component: TemplateComponent = conn.query_row(
|
||||
"SELECT id, repo_id, component_type, category, name, path, description, content_hash
|
||||
FROM template_components
|
||||
WHERE id = ?1",
|
||||
params![id],
|
||||
|row| {
|
||||
Ok(TemplateComponent {
|
||||
id: Some(row.get(0)?),
|
||||
repo_id: row.get(1)?,
|
||||
component_type: ComponentType::from_str(&row.get::<_, String>(2)?)
|
||||
.unwrap_or(ComponentType::Agent),
|
||||
category: row.get(3)?,
|
||||
name: row.get(4)?,
|
||||
path: row.get(5)?,
|
||||
description: row.get(6)?,
|
||||
content_hash: row.get(7)?,
|
||||
installed: false,
|
||||
})
|
||||
},
|
||||
)?;
|
||||
|
||||
// 查询仓库信息
|
||||
let (repo_owner, repo_name, branch): (String, String, String) = conn.query_row(
|
||||
"SELECT owner, name, branch FROM template_repos WHERE id = ?1",
|
||||
params![component.repo_id],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
||||
)?;
|
||||
|
||||
// 下载并读取组件内容
|
||||
let content = self
|
||||
.download_component_content(&repo_owner, &repo_name, &branch, &component.path)
|
||||
.await?;
|
||||
|
||||
Ok(content)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::{DateTime, Utc};
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub mod adapters;
|
||||
pub mod index;
|
||||
pub mod installer;
|
||||
pub mod repo;
|
||||
|
||||
#[allow(unused_imports)]
|
||||
pub use adapters::{create_adapter, AppAdapter};
|
||||
|
||||
/// 组件类型枚举
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ComponentType {
|
||||
Agent,
|
||||
Command,
|
||||
Mcp,
|
||||
Setting,
|
||||
Hook,
|
||||
Skill,
|
||||
}
|
||||
|
||||
impl ComponentType {
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
ComponentType::Agent => "agent",
|
||||
ComponentType::Command => "command",
|
||||
ComponentType::Mcp => "mcp",
|
||||
ComponentType::Setting => "setting",
|
||||
ComponentType::Hook => "hook",
|
||||
ComponentType::Skill => "skill",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(s: &str) -> Option<Self> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"agent" => Some(ComponentType::Agent),
|
||||
"command" => Some(ComponentType::Command),
|
||||
"mcp" => Some(ComponentType::Mcp),
|
||||
"setting" => Some(ComponentType::Setting),
|
||||
"hook" => Some(ComponentType::Hook),
|
||||
"skill" => Some(ComponentType::Skill),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 模板仓库
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TemplateRepo {
|
||||
pub id: Option<i64>,
|
||||
pub owner: String,
|
||||
pub name: String,
|
||||
pub branch: String,
|
||||
pub enabled: bool,
|
||||
#[serde(rename = "createdAt")]
|
||||
pub created_at: Option<DateTime<Utc>>,
|
||||
#[serde(rename = "updatedAt")]
|
||||
pub updated_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl TemplateRepo {
|
||||
pub fn new(owner: String, name: String, branch: String) -> Self {
|
||||
Self {
|
||||
id: None,
|
||||
owner,
|
||||
name,
|
||||
branch,
|
||||
enabled: true,
|
||||
created_at: None,
|
||||
updated_at: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 模板组件
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TemplateComponent {
|
||||
pub id: Option<i64>,
|
||||
#[serde(rename = "repoId")]
|
||||
pub repo_id: i64,
|
||||
#[serde(rename = "componentType")]
|
||||
pub component_type: ComponentType,
|
||||
pub category: Option<String>,
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub description: Option<String>,
|
||||
#[serde(rename = "contentHash")]
|
||||
pub content_hash: Option<String>,
|
||||
/// 是否已安装(前端展示用,需要在查询时填充)
|
||||
pub installed: bool,
|
||||
}
|
||||
|
||||
/// 组件详情(含完整内容)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ComponentDetail {
|
||||
#[serde(flatten)]
|
||||
pub component: TemplateComponent,
|
||||
/// 完整文件内容
|
||||
pub content: String,
|
||||
/// 仓库所有者
|
||||
#[serde(rename = "repoOwner")]
|
||||
pub repo_owner: String,
|
||||
/// 仓库名称
|
||||
#[serde(rename = "repoName")]
|
||||
pub repo_name: String,
|
||||
/// 仓库分支
|
||||
#[serde(rename = "repoBranch")]
|
||||
pub repo_branch: String,
|
||||
/// GitHub README URL
|
||||
#[serde(rename = "readmeUrl")]
|
||||
pub readme_url: String,
|
||||
}
|
||||
|
||||
/// 组件元数据(从文件 front matter 解析)
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct ComponentMetadata {
|
||||
pub name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
/// Agent 专用 - 工具列表
|
||||
pub tools: Option<String>,
|
||||
/// Agent 专用 - 模型名称
|
||||
pub model: Option<String>,
|
||||
}
|
||||
|
||||
/// 分页结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PaginatedResult<T> {
|
||||
pub items: Vec<T>,
|
||||
pub total: i64,
|
||||
pub page: u32,
|
||||
#[serde(rename = "pageSize")]
|
||||
pub page_size: u32,
|
||||
}
|
||||
|
||||
/// 批量安装结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BatchInstallResult {
|
||||
pub success: Vec<i64>,
|
||||
pub failed: Vec<(i64, String)>,
|
||||
}
|
||||
|
||||
/// 已安装组件
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct InstalledComponent {
|
||||
pub id: Option<i64>,
|
||||
#[serde(rename = "componentId")]
|
||||
pub component_id: Option<i64>,
|
||||
#[serde(rename = "componentType")]
|
||||
pub component_type: ComponentType,
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
#[serde(rename = "appType")]
|
||||
pub app_type: String,
|
||||
#[serde(rename = "installedAt")]
|
||||
pub installed_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 市场组合项(plugin 中的单个组件)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MarketplaceBundleItem {
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
#[serde(rename = "componentType")]
|
||||
pub component_type: String,
|
||||
}
|
||||
|
||||
/// 市场组合
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MarketplaceBundle {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub category: String,
|
||||
pub components: Vec<MarketplaceBundleItem>,
|
||||
}
|
||||
|
||||
/// Template 服务
|
||||
pub struct TemplateService {
|
||||
http_client: Client,
|
||||
}
|
||||
|
||||
impl TemplateService {
|
||||
pub fn new() -> Result<Self> {
|
||||
Ok(Self {
|
||||
http_client: Client::builder()
|
||||
.user_agent("cc-switch")
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.context("创建 HTTP 客户端失败")?,
|
||||
})
|
||||
}
|
||||
|
||||
/// 获取 HTTP 客户端
|
||||
pub fn client(&self) -> &Client {
|
||||
&self.http_client
|
||||
}
|
||||
|
||||
/// 获取应用配置目录
|
||||
pub fn get_app_config_dir(app_type: &str) -> Result<PathBuf> {
|
||||
let home = dirs::home_dir().context("无法获取用户主目录")?;
|
||||
|
||||
let dir = match app_type.to_lowercase().as_str() {
|
||||
"claude" => {
|
||||
// 检查是否有自定义 Claude 配置目录
|
||||
if let Some(custom) = crate::settings::get_claude_override_dir() {
|
||||
custom
|
||||
} else {
|
||||
home.join(".claude")
|
||||
}
|
||||
}
|
||||
"codex" => {
|
||||
// 检查是否有自定义 Codex 配置目录
|
||||
if let Some(custom) = crate::settings::get_codex_override_dir() {
|
||||
custom
|
||||
} else {
|
||||
home.join(".codex")
|
||||
}
|
||||
}
|
||||
"gemini" => {
|
||||
// 检查是否有自定义 Gemini 配置目录
|
||||
if let Some(custom) = crate::settings::get_gemini_override_dir() {
|
||||
custom
|
||||
} else {
|
||||
home.join(".gemini")
|
||||
}
|
||||
}
|
||||
_ => anyhow::bail!("不支持的应用类型: {app_type}"),
|
||||
};
|
||||
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// 从 components.json 获取市场组合
|
||||
pub async fn fetch_marketplace_bundles(
|
||||
&self,
|
||||
conn: &rusqlite::Connection,
|
||||
) -> Result<Vec<MarketplaceBundle>> {
|
||||
// 获取启用的仓库
|
||||
let repos = self.list_enabled_repos(conn)?;
|
||||
if repos.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let mut bundles = Vec::new();
|
||||
|
||||
for repo in repos {
|
||||
// 尝试多个可能的路径
|
||||
let urls = [
|
||||
format!(
|
||||
"https://raw.githubusercontent.com/{}/{}/{}/components.json",
|
||||
repo.owner, repo.name, repo.branch
|
||||
),
|
||||
format!(
|
||||
"https://raw.githubusercontent.com/{}/{}/{}/docs/components.json",
|
||||
repo.owner, repo.name, repo.branch
|
||||
),
|
||||
];
|
||||
|
||||
for url in urls {
|
||||
match self.http_client.get(&url).send().await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
if let Ok(json) = resp.json::<serde_json::Value>().await {
|
||||
// 解析 marketplace.plugins(完整插件包)
|
||||
if let Some(marketplace) = json.get("marketplace") {
|
||||
if let Some(plugins) = marketplace.get("plugins") {
|
||||
if let Some(arr) = plugins.as_array() {
|
||||
for plugin in arr {
|
||||
let name = plugin
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown");
|
||||
let description = plugin
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
// 提取各类型组件路径
|
||||
let mut components = Vec::new();
|
||||
let component_types = [
|
||||
"agents", "commands", "mcps", "settings", "hooks",
|
||||
"skills",
|
||||
];
|
||||
|
||||
for comp_type in component_types {
|
||||
if let Some(paths) =
|
||||
plugin.get(comp_type).and_then(|v| v.as_array())
|
||||
{
|
||||
// 单数形式的类型名
|
||||
let singular_type = match comp_type {
|
||||
"agents" => "agent",
|
||||
"commands" => "command",
|
||||
"mcps" => "mcp",
|
||||
"settings" => "setting",
|
||||
"hooks" => "hook",
|
||||
"skills" => "skill",
|
||||
_ => comp_type,
|
||||
};
|
||||
|
||||
for path_val in paths {
|
||||
if let Some(path) = path_val.as_str() {
|
||||
// 从路径提取组件名(文件名不含扩展名)
|
||||
let comp_name =
|
||||
std::path::Path::new(path)
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
components.push(
|
||||
MarketplaceBundleItem {
|
||||
name: comp_name,
|
||||
path: path.to_string(),
|
||||
component_type: singular_type
|
||||
.to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !components.is_empty() {
|
||||
bundles.push(MarketplaceBundle {
|
||||
id: format!("{}-plugin-{}", repo.name, name),
|
||||
name: name.to_string(),
|
||||
description: description.to_string(),
|
||||
category: "plugin".to_string(),
|
||||
components,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
break; // 成功获取后跳出 URL 循环
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(bundles)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TemplateService {
|
||||
fn default() -> Self {
|
||||
Self::new().expect("创建 TemplateService 失败")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
use anyhow::{Context, Result};
|
||||
use rusqlite::{params, Connection};
|
||||
|
||||
use super::{TemplateRepo, TemplateService};
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl TemplateService {
|
||||
/// 列出所有模板仓库
|
||||
pub fn list_repos(&self, conn: &Connection) -> Result<Vec<TemplateRepo>> {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, owner, name, branch, enabled, created_at, updated_at
|
||||
FROM template_repos
|
||||
ORDER BY created_at DESC",
|
||||
)
|
||||
.context("准备查询模板仓库语句失败")?;
|
||||
|
||||
let repos = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(TemplateRepo {
|
||||
id: Some(row.get(0)?),
|
||||
owner: row.get(1)?,
|
||||
name: row.get(2)?,
|
||||
branch: row.get(3)?,
|
||||
enabled: row.get::<_, i64>(4)? != 0,
|
||||
created_at: row.get::<_, String>(5).ok().and_then(|s| s.parse().ok()),
|
||||
updated_at: row.get::<_, String>(6).ok().and_then(|s| s.parse().ok()),
|
||||
})
|
||||
})
|
||||
.context("查询模板仓库失败")?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.context("收集模板仓库结果失败")?;
|
||||
|
||||
Ok(repos)
|
||||
}
|
||||
|
||||
/// 添加模板仓库
|
||||
pub fn add_repo(&self, conn: &Connection, repo: TemplateRepo) -> Result<i64> {
|
||||
// 检查是否已存在
|
||||
let existing: Option<i64> = conn
|
||||
.query_row(
|
||||
"SELECT id FROM template_repos WHERE owner = ?1 AND name = ?2",
|
||||
params![&repo.owner, &repo.name],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.ok();
|
||||
|
||||
if let Some(id) = existing {
|
||||
// 更新已存在的仓库
|
||||
conn.execute(
|
||||
"UPDATE template_repos
|
||||
SET branch = ?1, enabled = ?2, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?3",
|
||||
params![&repo.branch, repo.enabled as i64, id],
|
||||
)
|
||||
.context("更新模板仓库失败")?;
|
||||
Ok(id)
|
||||
} else {
|
||||
// 插入新仓库
|
||||
conn.execute(
|
||||
"INSERT INTO template_repos (owner, name, branch, enabled)
|
||||
VALUES (?1, ?2, ?3, ?4)",
|
||||
params![&repo.owner, &repo.name, &repo.branch, repo.enabled as i64],
|
||||
)
|
||||
.context("插入模板仓库失败")?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除模板仓库
|
||||
pub fn remove_repo(&self, conn: &Connection, id: i64) -> Result<()> {
|
||||
let rows = conn
|
||||
.execute("DELETE FROM template_repos WHERE id = ?1", params![id])
|
||||
.context("删除模板仓库失败")?;
|
||||
|
||||
if rows == 0 {
|
||||
anyhow::bail!("模板仓库不存在: id={id}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 切换仓库启用状态
|
||||
pub fn toggle_repo_enabled(&self, conn: &Connection, id: i64) -> Result<bool> {
|
||||
// 获取当前状态
|
||||
let enabled: i64 = conn
|
||||
.query_row(
|
||||
"SELECT enabled FROM template_repos WHERE id = ?1",
|
||||
params![id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.context("查询仓库状态失败")?;
|
||||
|
||||
let new_enabled = enabled == 0;
|
||||
|
||||
// 更新状态
|
||||
conn.execute(
|
||||
"UPDATE template_repos
|
||||
SET enabled = ?1, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?2",
|
||||
params![new_enabled as i64, id],
|
||||
)
|
||||
.context("更新仓库状态失败")?;
|
||||
|
||||
Ok(new_enabled)
|
||||
}
|
||||
|
||||
/// 获取单个仓库
|
||||
pub fn get_repo(&self, conn: &Connection, id: i64) -> Result<TemplateRepo> {
|
||||
conn.query_row(
|
||||
"SELECT id, owner, name, branch, enabled, created_at, updated_at
|
||||
FROM template_repos
|
||||
WHERE id = ?1",
|
||||
params![id],
|
||||
|row| {
|
||||
Ok(TemplateRepo {
|
||||
id: Some(row.get(0)?),
|
||||
owner: row.get(1)?,
|
||||
name: row.get(2)?,
|
||||
branch: row.get(3)?,
|
||||
enabled: row.get::<_, i64>(4)? != 0,
|
||||
created_at: row.get::<_, String>(5).ok().and_then(|s| s.parse().ok()),
|
||||
updated_at: row.get::<_, String>(6).ok().and_then(|s| s.parse().ok()),
|
||||
})
|
||||
},
|
||||
)
|
||||
.context(format!("查询模板仓库失败: id={id}"))
|
||||
}
|
||||
|
||||
/// 获取启用的仓库列表
|
||||
pub fn list_enabled_repos(&self, conn: &Connection) -> Result<Vec<TemplateRepo>> {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, owner, name, branch, enabled, created_at, updated_at
|
||||
FROM template_repos
|
||||
WHERE enabled = 1
|
||||
ORDER BY created_at DESC",
|
||||
)
|
||||
.context("准备查询启用仓库语句失败")?;
|
||||
|
||||
let repos = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(TemplateRepo {
|
||||
id: Some(row.get(0)?),
|
||||
owner: row.get(1)?,
|
||||
name: row.get(2)?,
|
||||
branch: row.get(3)?,
|
||||
enabled: row.get::<_, i64>(4)? != 0,
|
||||
created_at: row.get::<_, String>(5).ok().and_then(|s| s.parse().ok()),
|
||||
updated_at: row.get::<_, String>(6).ok().and_then(|s| s.parse().ok()),
|
||||
})
|
||||
})
|
||||
.context("查询启用仓库失败")?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.context("收集启用仓库结果失败")?;
|
||||
|
||||
Ok(repos)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rusqlite::Connection;
|
||||
|
||||
fn setup_db() -> Connection {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS template_repos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
owner TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
branch TEXT NOT NULL DEFAULT 'main',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(owner, name)
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_and_list_repos() {
|
||||
let conn = setup_db();
|
||||
let service = TemplateService::new().unwrap();
|
||||
|
||||
// 添加仓库
|
||||
let repo = TemplateRepo::new(
|
||||
"yovinchen".to_string(),
|
||||
"claude-code-templates".to_string(),
|
||||
"main".to_string(),
|
||||
);
|
||||
let id = service.add_repo(&conn, repo).unwrap();
|
||||
assert!(id > 0);
|
||||
|
||||
// 列出仓库
|
||||
let repos = service.list_repos(&conn).unwrap();
|
||||
assert_eq!(repos.len(), 1);
|
||||
assert_eq!(repos[0].owner, "yovinchen");
|
||||
assert_eq!(repos[0].name, "claude-code-templates");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_toggle_repo_enabled() {
|
||||
let conn = setup_db();
|
||||
let service = TemplateService::new().unwrap();
|
||||
|
||||
// 添加仓库
|
||||
let repo = TemplateRepo::new("test".to_string(), "repo".to_string(), "main".to_string());
|
||||
let id = service.add_repo(&conn, repo).unwrap();
|
||||
|
||||
// 切换状态
|
||||
let enabled = service.toggle_repo_enabled(&conn, id).unwrap();
|
||||
assert!(!enabled);
|
||||
|
||||
let enabled = service.toggle_repo_enabled(&conn, id).unwrap();
|
||||
assert!(enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_repo() {
|
||||
let conn = setup_db();
|
||||
let service = TemplateService::new().unwrap();
|
||||
|
||||
// 添加仓库
|
||||
let repo = TemplateRepo::new("test".to_string(), "repo".to_string(), "main".to_string());
|
||||
let id = service.add_repo(&conn, repo).unwrap();
|
||||
|
||||
// 删除仓库
|
||||
service.remove_repo(&conn, id).unwrap();
|
||||
|
||||
// 验证已删除
|
||||
let repos = service.list_repos(&conn).unwrap();
|
||||
assert_eq!(repos.len(), 0);
|
||||
}
|
||||
}
|
||||
@@ -860,9 +860,7 @@ mod tests {
|
||||
} else {
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"应该不匹配的URL被允许: base_url={}, request_url={}",
|
||||
base_url,
|
||||
request_url
|
||||
"应该不匹配的URL被允许: base_url={base_url}, request_url={request_url}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user