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:
YoVinchen
2025-12-19 09:42:20 +08:00
parent 6bdbb4df23
commit 4bbd63bf2b
37 changed files with 6359 additions and 21 deletions
@@ -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)
}
}