Feat/claude skills management (#237)
* feat(skills): add Claude Skills management feature Implement complete Skills management system with repository discovery, installation, and lifecycle management capabilities. Backend: - Add SkillService with GitHub integration and installation logic - Implement skill commands (list, install, uninstall, check updates) - Support multiple skill repositories with caching Frontend: - Add Skills management page with repository browser - Create SkillCard and RepoManager components - Add badge, card, table UI components - Integrate Skills API with Tauri commands Files: 10 files changed, 1488 insertions(+) * feat(skills): integrate Skills feature into application Integrate Skills management feature with complete dependency updates, configuration structure extensions, and internationalization support. Dependencies: - Add @radix-ui/react-visually-hidden for accessibility - Add anyhow, zip, serde_yaml, tempfile for Skills backend - Enable chrono serde feature for timestamp serialization Backend Integration: - Extend MultiAppConfig with SkillStore field - Implement skills.json migration from legacy location - Register SkillService and skill commands in main app - Export skill module in commands and services Frontend Integration: - Add Skills page route and dialog in App - Integrate Skills UI with main navigation Internationalization: - Add complete Chinese translations for Skills UI - Add complete English translations for Skills UI Code Quality: - Remove redundant blank lines in gemini_mcp.rs - Format log statements in mcp.rs Tests: - Update import_export_sync tests for SkillStore - Update mcp_commands tests for new structure Files: 16 files changed, 540 insertions(+), 39 deletions(-) * style(skills): improve SkillsPage typography and spacing Optimize visual hierarchy and readability of Skills page: - Reduce title size from 2xl to lg with tighter tracking - Improve description spacing and color contrast - Enhance empty state with better text hierarchy - Use explicit gray colors for better dark mode support * feat(skills): support custom subdirectory path for skill scanning Add optional skillsPath field to SkillRepo to enable scanning skills from subdirectories (e.g., "skills/") instead of repository root. Changes: - Backend: Add skillsPath field with subdirectory scanning logic - Frontend: Add skillsPath input field and display in repo list - Presets: Add cexll/myclaude repo with skills/ subdirectory - Code quality: Fix clippy warnings (dedup logic, string formatting) Backward compatible: skillsPath is optional, defaults to root scanning. * refactor(skills): improve repo manager dialog layout Optimize dialog structure with fixed header and scrollable content: - Add flexbox layout with fixed header and scrollable body - Remove outer border wrapper for cleaner appearance - Match SkillsPage design pattern for consistency - Improve UX with better content hierarchy
This commit is contained in:
257
src-tauri/Cargo.lock
generated
257
src-tauri/Cargo.lock
generated
@@ -17,6 +17,17 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.7.8"
|
||||
@@ -484,6 +495,25 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bzip2"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47"
|
||||
dependencies = [
|
||||
"bzip2-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bzip2-sys"
|
||||
version = "0.1.13+1.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cairo-rs"
|
||||
version = "0.18.5"
|
||||
@@ -558,6 +588,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1d05d92f4b1fd76aad469d46cdd858ca761576082cd37df81416691e50199fb"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
"libc",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
@@ -565,6 +597,7 @@ dependencies = [
|
||||
name = "cc-switch"
|
||||
version = "3.6.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"dirs 5.0.1",
|
||||
"futures",
|
||||
@@ -576,6 +609,7 @@ dependencies = [
|
||||
"rquickjs",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"serial_test",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
@@ -591,6 +625,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"toml 0.8.2",
|
||||
"toml_edit 0.22.27",
|
||||
"zip 2.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -646,6 +681,16 @@ dependencies = [
|
||||
"windows-link 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.7"
|
||||
@@ -665,6 +710,12 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "constant_time_eq"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.4.0"
|
||||
@@ -730,6 +781,21 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc"
|
||||
version = "3.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675"
|
||||
dependencies = [
|
||||
"crc-catalog",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc-catalog"
|
||||
version = "2.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
@@ -836,6 +902,12 @@ dependencies = [
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deflate64"
|
||||
version = "0.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26bf8fc351c5ed29b5c2f0cbbac1b209b74f60ecd62e675a998df72c49af5204"
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.4"
|
||||
@@ -878,6 +950,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1701,6 +1774,15 @@ version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "html5ever"
|
||||
version = "0.29.1"
|
||||
@@ -1994,6 +2076,15 @@ dependencies = [
|
||||
"cfb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "io-uring"
|
||||
version = "0.7.10"
|
||||
@@ -2091,6 +2182,16 @@ version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
|
||||
|
||||
[[package]]
|
||||
name = "jobserver"
|
||||
version = "0.1.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
|
||||
dependencies = [
|
||||
"getrandom 0.3.3",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.81"
|
||||
@@ -2249,6 +2350,27 @@ version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "lzma-rs"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"crc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lzma-sys"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mac"
|
||||
version = "0.1.1"
|
||||
@@ -2862,6 +2984,16 @@ version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
|
||||
|
||||
[[package]]
|
||||
name = "pbkdf2"
|
||||
version = "0.12.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
|
||||
dependencies = [
|
||||
"digest",
|
||||
"hmac",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
@@ -3979,6 +4111,19 @@ dependencies = [
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_yaml"
|
||||
version = "0.9.34+deprecated"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
|
||||
dependencies = [
|
||||
"indexmap 2.11.4",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
"unsafe-libyaml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serial_test"
|
||||
version = "3.2.0"
|
||||
@@ -4036,6 +4181,17 @@ dependencies = [
|
||||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
@@ -4631,7 +4787,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"url",
|
||||
"windows-sys 0.60.2",
|
||||
"zip",
|
||||
"zip 4.6.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5204,6 +5360,12 @@ version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
|
||||
|
||||
[[package]]
|
||||
name = "unsafe-libyaml"
|
||||
version = "0.2.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
@@ -6230,6 +6392,15 @@ dependencies = [
|
||||
"rustix",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xz2"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2"
|
||||
dependencies = [
|
||||
"lzma-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.0"
|
||||
@@ -6361,6 +6532,20 @@ name = "zeroize"
|
||||
version = "1.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||
dependencies = [
|
||||
"zeroize_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize_derive"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
@@ -6395,6 +6580,36 @@ dependencies = [
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "2.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"arbitrary",
|
||||
"bzip2",
|
||||
"constant_time_eq",
|
||||
"crc32fast",
|
||||
"crossbeam-utils",
|
||||
"deflate64",
|
||||
"displaydoc",
|
||||
"flate2",
|
||||
"getrandom 0.3.3",
|
||||
"hmac",
|
||||
"indexmap 2.11.4",
|
||||
"lzma-rs",
|
||||
"memchr",
|
||||
"pbkdf2",
|
||||
"sha1",
|
||||
"thiserror 2.0.17",
|
||||
"time",
|
||||
"xz2",
|
||||
"zeroize",
|
||||
"zopfli",
|
||||
"zstd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "4.6.1"
|
||||
@@ -6407,6 +6622,46 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zopfli"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"crc32fast",
|
||||
"log",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd"
|
||||
version = "0.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
|
||||
dependencies = [
|
||||
"zstd-safe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd-safe"
|
||||
version = "7.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
|
||||
dependencies = [
|
||||
"zstd-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd-sys"
|
||||
version = "2.0.16+zstd.1.5.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant"
|
||||
version = "5.7.0"
|
||||
|
||||
@@ -25,7 +25,7 @@ tauri-build = { version = "2.4.0", features = [] }
|
||||
serde_json = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
log = "0.4"
|
||||
chrono = "0.4"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
tauri = { version = "2.8.2", features = ["tray-icon"] }
|
||||
tauri-plugin-log = "2"
|
||||
tauri-plugin-opener = "2"
|
||||
@@ -42,6 +42,10 @@ futures = "0.3"
|
||||
regex = "1.10"
|
||||
rquickjs = { version = "0.8", features = ["array-buffer", "classes"] }
|
||||
thiserror = "1.0"
|
||||
anyhow = "1.0"
|
||||
zip = "2.2"
|
||||
serde_yaml = "0.9"
|
||||
tempfile = "3"
|
||||
|
||||
[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies]
|
||||
tauri-plugin-single-instance = "2"
|
||||
|
||||
@@ -2,6 +2,8 @@ use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::str::FromStr;
|
||||
|
||||
use crate::services::skill::SkillStore;
|
||||
|
||||
/// MCP 服务器应用状态(标记应用到哪些客户端)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||
pub struct McpApps {
|
||||
@@ -221,6 +223,9 @@ pub struct MultiAppConfig {
|
||||
/// Prompt 配置(按客户端分治)
|
||||
#[serde(default)]
|
||||
pub prompts: PromptRoot,
|
||||
/// Claude Skills 配置
|
||||
#[serde(default)]
|
||||
pub skills: SkillStore,
|
||||
/// 通用配置片段(按应用分治)
|
||||
#[serde(default)]
|
||||
pub common_config_snippets: CommonConfigSnippets,
|
||||
@@ -245,6 +250,7 @@ impl Default for MultiAppConfig {
|
||||
apps,
|
||||
mcp: McpRoot::default(),
|
||||
prompts: PromptRoot::default(),
|
||||
skills: SkillStore::default(),
|
||||
common_config_snippets: CommonConfigSnippets::default(),
|
||||
claude_common_config_snippet: None,
|
||||
}
|
||||
@@ -288,11 +294,36 @@ impl MultiAppConfig {
|
||||
));
|
||||
}
|
||||
|
||||
let has_skills_in_config = value
|
||||
.as_object()
|
||||
.is_some_and(|map| map.contains_key("skills"));
|
||||
|
||||
// 解析 v2 结构
|
||||
let mut config: Self =
|
||||
serde_json::from_value(value).map_err(|e| AppError::json(&config_path, e))?;
|
||||
let mut updated = false;
|
||||
|
||||
if !has_skills_in_config {
|
||||
let skills_path = get_app_config_dir().join("skills.json");
|
||||
if skills_path.exists() {
|
||||
match std::fs::read_to_string(&skills_path) {
|
||||
Ok(content) => match serde_json::from_str::<SkillStore>(&content) {
|
||||
Ok(store) => {
|
||||
config.skills = store;
|
||||
updated = true;
|
||||
log::info!("已从旧版 skills.json 导入 Claude Skills 配置");
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("解析旧版 skills.json 失败: {e}");
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
log::warn!("读取旧版 skills.json 失败: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 确保 gemini 应用存在(兼容旧配置文件)
|
||||
if !config.apps.contains_key("gemini") {
|
||||
config
|
||||
|
||||
@@ -8,6 +8,7 @@ mod plugin;
|
||||
mod prompt;
|
||||
mod provider;
|
||||
mod settings;
|
||||
pub mod skill;
|
||||
|
||||
pub use config::*;
|
||||
pub use import_export::*;
|
||||
@@ -17,3 +18,4 @@ pub use plugin::*;
|
||||
pub use prompt::*;
|
||||
pub use provider::*;
|
||||
pub use settings::*;
|
||||
pub use skill::*;
|
||||
|
||||
163
src-tauri/src/commands/skill.rs
Normal file
163
src-tauri/src/commands/skill.rs
Normal file
@@ -0,0 +1,163 @@
|
||||
use crate::services::skill::SkillState;
|
||||
use crate::services::{Skill, SkillRepo, SkillService};
|
||||
use crate::store::AppState;
|
||||
use chrono::Utc;
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
|
||||
pub struct SkillServiceState(pub Arc<SkillService>);
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_skills(
|
||||
service: State<'_, SkillServiceState>,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<Vec<Skill>, String> {
|
||||
let repos = {
|
||||
let config = app_state.config.read().map_err(|e| e.to_string())?;
|
||||
config.skills.repos.clone()
|
||||
};
|
||||
|
||||
service
|
||||
.0
|
||||
.list_skills(repos)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn install_skill(
|
||||
directory: String,
|
||||
service: State<'_, SkillServiceState>,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<bool, String> {
|
||||
// 先在不持有写锁的情况下收集仓库与技能信息
|
||||
let repos = {
|
||||
let config = app_state.config.read().map_err(|e| e.to_string())?;
|
||||
config.skills.repos.clone()
|
||||
};
|
||||
|
||||
let skills = service
|
||||
.0
|
||||
.list_skills(repos)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let skill = skills
|
||||
.iter()
|
||||
.find(|s| s.directory.eq_ignore_ascii_case(&directory))
|
||||
.ok_or_else(|| "技能不存在".to_string())?;
|
||||
|
||||
if !skill.installed {
|
||||
let repo = SkillRepo {
|
||||
owner: skill
|
||||
.repo_owner
|
||||
.clone()
|
||||
.ok_or_else(|| "缺少仓库信息".to_string())?,
|
||||
name: skill
|
||||
.repo_name
|
||||
.clone()
|
||||
.ok_or_else(|| "缺少仓库信息".to_string())?,
|
||||
branch: skill
|
||||
.repo_branch
|
||||
.clone()
|
||||
.unwrap_or_else(|| "main".to_string()),
|
||||
enabled: true,
|
||||
skills_path: None, // 安装时使用默认路径
|
||||
};
|
||||
|
||||
service
|
||||
.0
|
||||
.install_skill(directory.clone(), repo)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
{
|
||||
let mut config = app_state.config.write().map_err(|e| e.to_string())?;
|
||||
|
||||
config.skills.skills.insert(
|
||||
directory.clone(),
|
||||
SkillState {
|
||||
installed: true,
|
||||
installed_at: Utc::now(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
app_state.save().map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn uninstall_skill(
|
||||
directory: String,
|
||||
service: State<'_, SkillServiceState>,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<bool, String> {
|
||||
service
|
||||
.0
|
||||
.uninstall_skill(directory.clone())
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
{
|
||||
let mut config = app_state.config.write().map_err(|e| e.to_string())?;
|
||||
|
||||
config.skills.skills.remove(&directory);
|
||||
}
|
||||
|
||||
app_state.save().map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_skill_repos(
|
||||
_service: State<'_, SkillServiceState>,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<Vec<SkillRepo>, String> {
|
||||
let config = app_state.config.read().map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(config.skills.repos.clone())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn add_skill_repo(
|
||||
repo: SkillRepo,
|
||||
service: State<'_, SkillServiceState>,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<bool, String> {
|
||||
{
|
||||
let mut config = app_state.config.write().map_err(|e| e.to_string())?;
|
||||
|
||||
service
|
||||
.0
|
||||
.add_repo(&mut config.skills, repo)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
app_state.save().map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn remove_skill_repo(
|
||||
owner: String,
|
||||
name: String,
|
||||
service: State<'_, SkillServiceState>,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<bool, String> {
|
||||
{
|
||||
let mut config = app_state.config.write().map_err(|e| e.to_string())?;
|
||||
|
||||
service
|
||||
.0
|
||||
.remove_repo(&mut config.skills, owner, name)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
app_state.save().map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
@@ -31,11 +31,13 @@ pub use mcp::{
|
||||
};
|
||||
pub use provider::{Provider, ProviderMeta};
|
||||
pub use services::{
|
||||
ConfigService, EndpointLatency, McpService, PromptService, ProviderService, SpeedtestService,
|
||||
ConfigService, EndpointLatency, McpService, PromptService, ProviderService, SkillService,
|
||||
SpeedtestService,
|
||||
};
|
||||
pub use settings::{update_settings, AppSettings};
|
||||
pub use store::AppState;
|
||||
|
||||
use std::sync::Arc;
|
||||
use tauri::{
|
||||
menu::{CheckMenuItem, Menu, MenuBuilder, MenuItem},
|
||||
tray::{TrayIconBuilder, TrayIconEvent},
|
||||
@@ -495,6 +497,17 @@ pub fn run() {
|
||||
let _tray = tray_builder.build(app)?;
|
||||
// 将同一个实例注入到全局状态,避免重复创建导致的不一致
|
||||
app.manage(app_state);
|
||||
|
||||
// 初始化 SkillService
|
||||
match SkillService::new() {
|
||||
Ok(skill_service) => {
|
||||
app.manage(commands::skill::SkillServiceState(Arc::new(skill_service)));
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("初始化 SkillService 失败: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
@@ -573,6 +586,13 @@ pub fn run() {
|
||||
commands::open_file_dialog,
|
||||
commands::sync_current_providers_live,
|
||||
update_tray_menu,
|
||||
// Skill management
|
||||
commands::get_skills,
|
||||
commands::install_skill,
|
||||
commands::uninstall_skill,
|
||||
commands::get_skill_repos,
|
||||
commands::add_skill_repo,
|
||||
commands::remove_skill_repo,
|
||||
]);
|
||||
|
||||
let app = builder
|
||||
|
||||
@@ -2,10 +2,12 @@ pub mod config;
|
||||
pub mod mcp;
|
||||
pub mod prompt;
|
||||
pub mod provider;
|
||||
pub mod skill;
|
||||
pub mod speedtest;
|
||||
|
||||
pub use config::ConfigService;
|
||||
pub use mcp::McpService;
|
||||
pub use prompt::PromptService;
|
||||
pub use provider::{ProviderService, ProviderSortUpdate};
|
||||
pub use skill::{Skill, SkillRepo, SkillService};
|
||||
pub use speedtest::{EndpointLatency, SpeedtestService};
|
||||
|
||||
526
src-tauri/src/services/skill.rs
Normal file
526
src-tauri/src/services/skill.rs
Normal file
@@ -0,0 +1,526 @@
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use chrono::{DateTime, Utc};
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::time::timeout;
|
||||
|
||||
/// 技能对象
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Skill {
|
||||
/// 唯一标识: "owner/name:directory" 或 "local:directory"
|
||||
pub key: String,
|
||||
/// 显示名称 (从 SKILL.md 解析)
|
||||
pub name: String,
|
||||
/// 技能描述
|
||||
pub description: String,
|
||||
/// 目录名称 (安装路径的最后一段)
|
||||
pub directory: String,
|
||||
/// GitHub README URL
|
||||
#[serde(rename = "readmeUrl")]
|
||||
pub readme_url: Option<String>,
|
||||
/// 是否已安装
|
||||
pub installed: bool,
|
||||
/// 仓库所有者
|
||||
#[serde(rename = "repoOwner")]
|
||||
pub repo_owner: Option<String>,
|
||||
/// 仓库名称
|
||||
#[serde(rename = "repoName")]
|
||||
pub repo_name: Option<String>,
|
||||
/// 分支名称
|
||||
#[serde(rename = "repoBranch")]
|
||||
pub repo_branch: Option<String>,
|
||||
}
|
||||
|
||||
/// 仓库配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SkillRepo {
|
||||
/// GitHub 用户/组织名
|
||||
pub owner: String,
|
||||
/// 仓库名称
|
||||
pub name: String,
|
||||
/// 分支 (默认 "main")
|
||||
pub branch: String,
|
||||
/// 是否启用
|
||||
pub enabled: bool,
|
||||
/// 技能所在的子目录路径 (可选, 如 "skills", "my-skills/subdir")
|
||||
#[serde(rename = "skillsPath")]
|
||||
pub skills_path: Option<String>,
|
||||
}
|
||||
|
||||
/// 技能安装状态
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SkillState {
|
||||
/// 是否已安装
|
||||
pub installed: bool,
|
||||
/// 安装时间
|
||||
#[serde(rename = "installedAt")]
|
||||
pub installed_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 持久化存储结构
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SkillStore {
|
||||
/// directory -> 安装状态
|
||||
pub skills: HashMap<String, SkillState>,
|
||||
/// 仓库列表
|
||||
pub repos: Vec<SkillRepo>,
|
||||
}
|
||||
|
||||
impl Default for SkillStore {
|
||||
fn default() -> Self {
|
||||
SkillStore {
|
||||
skills: HashMap::new(),
|
||||
repos: vec![
|
||||
SkillRepo {
|
||||
owner: "ComposioHQ".to_string(),
|
||||
name: "awesome-claude-skills".to_string(),
|
||||
branch: "main".to_string(),
|
||||
enabled: true,
|
||||
skills_path: None, // 扫描根目录
|
||||
},
|
||||
SkillRepo {
|
||||
owner: "anthropics".to_string(),
|
||||
name: "skills".to_string(),
|
||||
branch: "main".to_string(),
|
||||
enabled: true,
|
||||
skills_path: None, // 扫描根目录
|
||||
},
|
||||
SkillRepo {
|
||||
owner: "cexll".to_string(),
|
||||
name: "myclaude".to_string(),
|
||||
branch: "master".to_string(),
|
||||
enabled: true,
|
||||
skills_path: Some("skills".to_string()), // 扫描 skills 子目录
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 技能元数据 (从 SKILL.md 解析)
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct SkillMetadata {
|
||||
pub name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
pub struct SkillService {
|
||||
http_client: Client,
|
||||
install_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl SkillService {
|
||||
pub fn new() -> Result<Self> {
|
||||
let install_dir = Self::get_install_dir()?;
|
||||
|
||||
// 确保目录存在
|
||||
fs::create_dir_all(&install_dir)?;
|
||||
|
||||
Ok(Self {
|
||||
http_client: Client::builder()
|
||||
.user_agent("cc-switch")
|
||||
// 将单次请求超时时间控制在 10 秒以内,避免无效链接导致长时间卡住
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()?,
|
||||
install_dir,
|
||||
})
|
||||
}
|
||||
|
||||
fn get_install_dir() -> Result<PathBuf> {
|
||||
let home = dirs::home_dir().context("无法获取用户主目录")?;
|
||||
Ok(home.join(".claude").join("skills"))
|
||||
}
|
||||
}
|
||||
|
||||
// 核心方法实现
|
||||
impl SkillService {
|
||||
/// 列出所有技能
|
||||
pub async fn list_skills(&self, repos: Vec<SkillRepo>) -> Result<Vec<Skill>> {
|
||||
let mut skills = Vec::new();
|
||||
|
||||
// 仅使用启用的仓库,并行获取技能列表,避免单个无效仓库拖慢整体刷新
|
||||
let enabled_repos: Vec<SkillRepo> = repos.into_iter().filter(|repo| repo.enabled).collect();
|
||||
|
||||
let fetch_tasks = enabled_repos
|
||||
.iter()
|
||||
.map(|repo| self.fetch_repo_skills(repo));
|
||||
|
||||
let results: Vec<Result<Vec<Skill>>> = futures::future::join_all(fetch_tasks).await;
|
||||
|
||||
for (repo, result) in enabled_repos.into_iter().zip(results.into_iter()) {
|
||||
match result {
|
||||
Ok(repo_skills) => skills.extend(repo_skills),
|
||||
Err(e) => log::warn!("获取仓库 {}/{} 技能失败: {}", repo.owner, repo.name, e),
|
||||
}
|
||||
}
|
||||
|
||||
// 合并本地技能
|
||||
self.merge_local_skills(&mut skills)?;
|
||||
|
||||
// 去重并排序
|
||||
Self::deduplicate_skills(&mut skills);
|
||||
skills.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||
|
||||
Ok(skills)
|
||||
}
|
||||
|
||||
/// 从仓库获取技能列表
|
||||
async fn fetch_repo_skills(&self, repo: &SkillRepo) -> Result<Vec<Skill>> {
|
||||
// 为单个仓库加载增加整体超时,避免无效链接长时间阻塞
|
||||
let temp_dir = timeout(std::time::Duration::from_secs(15), self.download_repo(repo))
|
||||
.await
|
||||
.map_err(|_| anyhow!("下载仓库 {}/{} 超时", repo.owner, repo.name))??;
|
||||
let mut skills = Vec::new();
|
||||
|
||||
// 确定要扫描的目录路径
|
||||
let scan_dir = if let Some(ref skills_path) = repo.skills_path {
|
||||
// 如果指定了 skillsPath,则扫描该子目录
|
||||
let subdir = temp_dir.join(skills_path.trim_matches('/'));
|
||||
if !subdir.exists() {
|
||||
log::warn!(
|
||||
"仓库 {}/{} 中指定的技能路径 '{}' 不存在",
|
||||
repo.owner,
|
||||
repo.name,
|
||||
skills_path
|
||||
);
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
return Ok(skills);
|
||||
}
|
||||
subdir
|
||||
} else {
|
||||
// 否则扫描仓库根目录
|
||||
temp_dir.clone()
|
||||
};
|
||||
|
||||
// 遍历目标目录
|
||||
for entry in fs::read_dir(&scan_dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let skill_md = path.join("SKILL.md");
|
||||
if !skill_md.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 解析技能元数据
|
||||
match self.parse_skill_metadata(&skill_md) {
|
||||
Ok(meta) => {
|
||||
let directory = path.file_name().unwrap().to_string_lossy().to_string();
|
||||
|
||||
// 构建 README URL(考虑 skillsPath)
|
||||
let readme_path = if let Some(ref skills_path) = repo.skills_path {
|
||||
format!("{}/{}", skills_path.trim_matches('/'), directory)
|
||||
} else {
|
||||
directory.clone()
|
||||
};
|
||||
|
||||
skills.push(Skill {
|
||||
key: format!("{}/{}:{}", repo.owner, repo.name, directory),
|
||||
name: meta.name.unwrap_or_else(|| directory.clone()),
|
||||
description: meta.description.unwrap_or_default(),
|
||||
directory,
|
||||
readme_url: Some(format!(
|
||||
"https://github.com/{}/{}/tree/{}/{}",
|
||||
repo.owner, repo.name, repo.branch, readme_path
|
||||
)),
|
||||
installed: false,
|
||||
repo_owner: Some(repo.owner.clone()),
|
||||
repo_name: Some(repo.name.clone()),
|
||||
repo_branch: Some(repo.branch.clone()),
|
||||
});
|
||||
}
|
||||
Err(e) => log::warn!("解析 {} 元数据失败: {}", skill_md.display(), e),
|
||||
}
|
||||
}
|
||||
|
||||
// 清理临时目录
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
|
||||
Ok(skills)
|
||||
}
|
||||
|
||||
/// 解析技能元数据
|
||||
fn parse_skill_metadata(&self, path: &Path) -> Result<SkillMetadata> {
|
||||
let content = fs::read_to_string(path)?;
|
||||
|
||||
// 移除 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(SkillMetadata {
|
||||
name: None,
|
||||
description: None,
|
||||
});
|
||||
}
|
||||
|
||||
let front_matter = parts[1].trim();
|
||||
let meta: SkillMetadata = serde_yaml::from_str(front_matter).unwrap_or(SkillMetadata {
|
||||
name: None,
|
||||
description: None,
|
||||
});
|
||||
|
||||
Ok(meta)
|
||||
}
|
||||
|
||||
/// 合并本地技能
|
||||
fn merge_local_skills(&self, skills: &mut Vec<Skill>) -> Result<()> {
|
||||
if !self.install_dir.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for entry in fs::read_dir(&self.install_dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let directory = path.file_name().unwrap().to_string_lossy().to_string();
|
||||
|
||||
// 更新已安装状态
|
||||
let mut found = false;
|
||||
for skill in skills.iter_mut() {
|
||||
if skill.directory.eq_ignore_ascii_case(&directory) {
|
||||
skill.installed = true;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 添加本地独有的技能(仅当在仓库中未找到时)
|
||||
if !found {
|
||||
let skill_md = path.join("SKILL.md");
|
||||
if skill_md.exists() {
|
||||
if let Ok(meta) = self.parse_skill_metadata(&skill_md) {
|
||||
skills.push(Skill {
|
||||
key: format!("local:{directory}"),
|
||||
name: meta.name.unwrap_or_else(|| directory.clone()),
|
||||
description: meta.description.unwrap_or_default(),
|
||||
directory: directory.clone(),
|
||||
readme_url: None,
|
||||
installed: true,
|
||||
repo_owner: None,
|
||||
repo_name: None,
|
||||
repo_branch: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 去重技能列表
|
||||
fn deduplicate_skills(skills: &mut Vec<Skill>) {
|
||||
let mut seen = HashMap::new();
|
||||
skills.retain(|skill| {
|
||||
let key = skill.directory.to_lowercase();
|
||||
if let std::collections::hash_map::Entry::Vacant(e) = seen.entry(key) {
|
||||
e.insert(true);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 下载仓库
|
||||
async fn download_repo(&self, repo: &SkillRepo) -> Result<PathBuf> {
|
||||
let temp_dir = tempfile::tempdir()?;
|
||||
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
|
||||
);
|
||||
|
||||
match self.download_and_extract(&url, &temp_path).await {
|
||||
Ok(_) => {
|
||||
return Ok(temp_path);
|
||||
}
|
||||
Err(e) => {
|
||||
last_error = Some(e);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(last_error.unwrap_or_else(|| anyhow::anyhow!("所有分支下载失败")))
|
||||
}
|
||||
|
||||
/// 下载并解压 ZIP
|
||||
async fn download_and_extract(&self, url: &str, dest: &Path) -> Result<()> {
|
||||
// 下载 ZIP
|
||||
let response = self.http_client.get(url).send().await?;
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow::anyhow!("下载失败: {}", response.status()));
|
||||
}
|
||||
|
||||
let bytes = response.bytes().await?;
|
||||
|
||||
// 解压
|
||||
let cursor = std::io::Cursor::new(bytes);
|
||||
let mut archive = zip::ZipArchive::new(cursor)?;
|
||||
|
||||
// 获取根目录名称 (GitHub 的 zip 会有一个根目录)
|
||||
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::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(())
|
||||
}
|
||||
|
||||
/// 安装技能(仅负责下载和文件操作,状态更新由上层负责)
|
||||
pub async fn install_skill(&self, directory: String, repo: SkillRepo) -> Result<()> {
|
||||
let dest = self.install_dir.join(&directory);
|
||||
|
||||
// 若目标目录已存在,则视为已安装,避免重复下载
|
||||
if dest.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 下载仓库时增加总超时,防止无效链接导致长时间卡住安装过程
|
||||
let temp_dir = timeout(
|
||||
std::time::Duration::from_secs(15),
|
||||
self.download_repo(&repo),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| anyhow!("下载仓库 {}/{} 超时", repo.owner, repo.name))??;
|
||||
|
||||
// 复制到安装目录
|
||||
let source = temp_dir.join(&directory);
|
||||
|
||||
if !source.exists() {
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
return Err(anyhow::anyhow!("技能目录不存在"));
|
||||
}
|
||||
|
||||
// 删除旧版本
|
||||
if dest.exists() {
|
||||
fs::remove_dir_all(&dest)?;
|
||||
}
|
||||
|
||||
// 递归复制
|
||||
Self::copy_dir_recursive(&source, &dest)?;
|
||||
|
||||
// 清理临时目录
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 递归复制目录
|
||||
fn copy_dir_recursive(src: &Path, dest: &Path) -> Result<()> {
|
||||
fs::create_dir_all(dest)?;
|
||||
|
||||
for entry in fs::read_dir(src)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
let dest_path = dest.join(entry.file_name());
|
||||
|
||||
if path.is_dir() {
|
||||
Self::copy_dir_recursive(&path, &dest_path)?;
|
||||
} else {
|
||||
fs::copy(&path, &dest_path)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 卸载技能(仅负责文件操作,状态更新由上层负责)
|
||||
pub fn uninstall_skill(&self, directory: String) -> Result<()> {
|
||||
let dest = self.install_dir.join(&directory);
|
||||
|
||||
if dest.exists() {
|
||||
fs::remove_dir_all(&dest)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 列出仓库
|
||||
pub fn list_repos(&self, store: &SkillStore) -> Vec<SkillRepo> {
|
||||
store.repos.clone()
|
||||
}
|
||||
|
||||
/// 添加仓库
|
||||
pub fn add_repo(&self, store: &mut SkillStore, repo: SkillRepo) -> Result<()> {
|
||||
// 检查重复
|
||||
if let Some(pos) = store
|
||||
.repos
|
||||
.iter()
|
||||
.position(|r| r.owner == repo.owner && r.name == repo.name)
|
||||
{
|
||||
store.repos[pos] = repo;
|
||||
} else {
|
||||
store.repos.push(repo);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 删除仓库
|
||||
pub fn remove_repo(&self, store: &mut SkillStore, owner: String, name: String) -> Result<()> {
|
||||
store
|
||||
.repos
|
||||
.retain(|r| !(r.owner == owner && r.name == name));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -14,9 +14,9 @@
|
||||
{
|
||||
"label": "main",
|
||||
"title": "",
|
||||
"width": 900,
|
||||
"width": 1000,
|
||||
"height": 650,
|
||||
"minWidth": 800,
|
||||
"minWidth": 900,
|
||||
"minHeight": 600,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
|
||||
Reference in New Issue
Block a user