mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-25 13:45:03 +08:00
4bbd63bf2b
- 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
526 lines
19 KiB
Rust
526 lines
19 KiB
Rust
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)
|
||
}
|
||
}
|