mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-03 19:12:04 +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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user