From ec303544caeb14a84fbe9f8b8c44362a4b8d513e Mon Sep 17 00:00:00 2001 From: YoVinchen Date: Tue, 18 Nov 2025 22:05:54 +0800 Subject: [PATCH] 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 --- package.json | 1 + pnpm-lock.yaml | 63 +++ src-tauri/Cargo.lock | 257 ++++++++++++- src-tauri/Cargo.toml | 6 +- src-tauri/src/app_config.rs | 31 ++ src-tauri/src/commands/mod.rs | 2 + src-tauri/src/commands/skill.rs | 163 ++++++++ src-tauri/src/lib.rs | 22 +- src-tauri/src/services/mod.rs | 2 + src-tauri/src/services/skill.rs | 526 ++++++++++++++++++++++++++ src-tauri/tauri.conf.json | 4 +- src/App.tsx | 27 ++ src/components/skills/RepoManager.tsx | 218 +++++++++++ src/components/skills/SkillCard.tsx | 145 +++++++ src/components/skills/SkillsPage.tsx | 190 ++++++++++ src/components/ui/badge.tsx | 36 ++ src/components/ui/card.tsx | 86 +++++ src/components/ui/table.tsx | 121 ++++++ src/i18n/locales/en.json | 47 ++- src/i18n/locales/zh.json | 47 ++- src/lib/api/skills.ts | 47 +++ 21 files changed, 2034 insertions(+), 7 deletions(-) create mode 100644 src-tauri/src/commands/skill.rs create mode 100644 src-tauri/src/services/skill.rs create mode 100644 src/components/skills/RepoManager.tsx create mode 100644 src/components/skills/SkillCard.tsx create mode 100644 src/components/skills/SkillsPage.tsx create mode 100644 src/components/ui/badge.tsx create mode 100644 src/components/ui/card.tsx create mode 100644 src/components/ui/table.tsx create mode 100644 src/lib/api/skills.ts diff --git a/package.json b/package.json index d5d5d03..145cf56 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-visually-hidden": "^1.2.4", "@tailwindcss/vite": "^4.1.13", "@tanstack/react-query": "^5.90.3", "@tauri-apps/api": "^2.8.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 14f6983..9aebb80 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,6 +65,9 @@ importers: '@radix-ui/react-tabs': specifier: ^1.1.13 version: 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-visually-hidden': + specifier: ^1.2.4 + version: 1.2.4(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@tailwindcss/vite': specifier: ^4.1.13 version: 4.1.13(vite@5.4.19(@types/node@20.19.9)(lightningcss@1.30.1)) @@ -842,6 +845,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-primitive@2.1.4': + resolution: {integrity: sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-roving-focus@1.1.11': resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} peerDependencies: @@ -877,6 +893,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-slot@1.2.4': + resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-switch@1.2.6': resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==} peerDependencies: @@ -988,6 +1013,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-visually-hidden@1.2.4': + resolution: {integrity: sha512-kaeiyGCe844dkb9AVF+rb4yTyb1LiLN/e3es3nLiRyN4dC8AduBYPMnnNlDjX2VDOcvDEiPnRNMJeWCfsX0txg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} @@ -3036,6 +3074,15 @@ snapshots: '@types/react': 18.3.23 '@types/react-dom': 18.3.7(@types/react@18.3.23) + '@radix-ui/react-primitive@2.1.4(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-slot': 1.2.4(@types/react@18.3.23)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.23 + '@types/react-dom': 18.3.7(@types/react@18.3.23) + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -3089,6 +3136,13 @@ snapshots: optionalDependencies: '@types/react': 18.3.23 + '@radix-ui/react-slot@1.2.4(@types/react@18.3.23)(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.23 + '@radix-ui/react-switch@1.2.6(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -3183,6 +3237,15 @@ snapshots: '@types/react': 18.3.23 '@types/react-dom': 18.3.7(@types/react@18.3.23) + '@radix-ui/react-visually-hidden@1.2.4(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.23 + '@types/react-dom': 18.3.7(@types/react@18.3.23) + '@radix-ui/rect@1.1.1': {} '@rolldown/pluginutils@1.0.0-beta.27': {} diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index d966f0a..d5fb415 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -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" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 066182b..61e8174 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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" diff --git a/src-tauri/src/app_config.rs b/src-tauri/src/app_config.rs index dd748ea..6343596 100644 --- a/src-tauri/src/app_config.rs +++ b/src-tauri/src/app_config.rs @@ -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::(&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 diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 90d7516..17b671e 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -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::*; diff --git a/src-tauri/src/commands/skill.rs b/src-tauri/src/commands/skill.rs new file mode 100644 index 0000000..64a5c18 --- /dev/null +++ b/src-tauri/src/commands/skill.rs @@ -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); + +#[tauri::command] +pub async fn get_skills( + service: State<'_, SkillServiceState>, + app_state: State<'_, AppState>, +) -> Result, 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 { + // 先在不持有写锁的情况下收集仓库与技能信息 + 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 { + 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, 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 { + { + 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 { + { + 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) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9e8852e..de53b1a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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 diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index a715aea..c1908a0 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -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}; diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs new file mode 100644 index 0000000..2af22b3 --- /dev/null +++ b/src-tauri/src/services/skill.rs @@ -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, + /// 是否已安装 + pub installed: bool, + /// 仓库所有者 + #[serde(rename = "repoOwner")] + pub repo_owner: Option, + /// 仓库名称 + #[serde(rename = "repoName")] + pub repo_name: Option, + /// 分支名称 + #[serde(rename = "repoBranch")] + pub repo_branch: Option, +} + +/// 仓库配置 +#[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, +} + +/// 技能安装状态 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SkillState { + /// 是否已安装 + pub installed: bool, + /// 安装时间 + #[serde(rename = "installedAt")] + pub installed_at: DateTime, +} + +/// 持久化存储结构 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SkillStore { + /// directory -> 安装状态 + pub skills: HashMap, + /// 仓库列表 + pub repos: Vec, +} + +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, + pub description: Option, +} + +pub struct SkillService { + http_client: Client, + install_dir: PathBuf, +} + +impl SkillService { + pub fn new() -> Result { + 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 { + let home = dirs::home_dir().context("无法获取用户主目录")?; + Ok(home.join(".claude").join("skills")) + } +} + +// 核心方法实现 +impl SkillService { + /// 列出所有技能 + pub async fn list_skills(&self, repos: Vec) -> Result> { + let mut skills = Vec::new(); + + // 仅使用启用的仓库,并行获取技能列表,避免单个无效仓库拖慢整体刷新 + let enabled_repos: Vec = repos.into_iter().filter(|repo| repo.enabled).collect(); + + let fetch_tasks = enabled_repos + .iter() + .map(|repo| self.fetch_repo_skills(repo)); + + let results: Vec>> = 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> { + // 为单个仓库加载增加整体超时,避免无效链接长时间阻塞 + 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 { + 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) -> 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) { + 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 { + 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 { + 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(()) + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 9cdf3d3..4ae2c6b 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -14,9 +14,9 @@ { "label": "main", "title": "", - "width": 900, + "width": 1000, "height": 650, - "minWidth": 800, + "minWidth": 900, "minHeight": 600, "resizable": true, "fullscreen": false, diff --git a/src/App.tsx b/src/App.tsx index d15b0de..ee9f908 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -22,7 +22,15 @@ import { UpdateBadge } from "@/components/UpdateBadge"; import UsageScriptModal from "@/components/UsageScriptModal"; import UnifiedMcpPanel from "@/components/mcp/UnifiedMcpPanel"; import PromptPanel from "@/components/prompts/PromptPanel"; +import { SkillsPage } from "@/components/skills/SkillsPage"; import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { VisuallyHidden } from "@radix-ui/react-visually-hidden"; function App() { const { t } = useTranslation(); @@ -33,6 +41,7 @@ function App() { const [isAddOpen, setIsAddOpen] = useState(false); const [isMcpOpen, setIsMcpOpen] = useState(false); const [isPromptOpen, setIsPromptOpen] = useState(false); + const [isSkillsOpen, setIsSkillsOpen] = useState(false); const [editingProvider, setEditingProvider] = useState(null); const [usageProvider, setUsageProvider] = useState(null); const [confirmDelete, setConfirmDelete] = useState(null); @@ -218,6 +227,13 @@ function App() { > MCP + + + + {error &&

{error}

} + + + {/* 仓库列表 */} +
+

{t("skills.repo.list")}

+ {repos.length === 0 ? ( +

+ {t("skills.repo.empty")} +

+ ) : ( +
+ {repos.map((repo) => ( +
+
+
+ {repo.owner}/{repo.name} +
+
+ {t("skills.repo.branch")}: {repo.branch || "main"} + {repo.skillsPath && ( + <> + + {t("skills.repo.path")}: {repo.skillsPath} + + )} + + {t("skills.repo.skillCount", { + count: getSkillCount(repo), + })} + +
+
+
+ + +
+
+ ))} +
+ )} +
+ + + + + ); +} diff --git a/src/components/skills/SkillCard.tsx b/src/components/skills/SkillCard.tsx new file mode 100644 index 0000000..74dffbd --- /dev/null +++ b/src/components/skills/SkillCard.tsx @@ -0,0 +1,145 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { ExternalLink, Download, Trash2, Loader2 } from "lucide-react"; +import { settingsApi } from "@/lib/api"; +import type { Skill } from "@/lib/api/skills"; + +interface SkillCardProps { + skill: Skill; + onInstall: (directory: string) => Promise; + onUninstall: (directory: string) => Promise; +} + +export function SkillCard({ skill, onInstall, onUninstall }: SkillCardProps) { + const { t } = useTranslation(); + const [loading, setLoading] = useState(false); + + const handleInstall = async () => { + setLoading(true); + try { + await onInstall(skill.directory); + } finally { + setLoading(false); + } + }; + + const handleUninstall = async () => { + setLoading(true); + try { + await onUninstall(skill.directory); + } finally { + setLoading(false); + } + }; + + const handleOpenGithub = async () => { + if (skill.readmeUrl) { + try { + await settingsApi.openExternal(skill.readmeUrl); + } catch (error) { + console.error("Failed to open URL:", error); + } + } + }; + + const showDirectory = + Boolean(skill.directory) && + skill.directory.trim().toLowerCase() !== skill.name.trim().toLowerCase(); + + return ( + + +
+
+ + {skill.name} + +
+ {showDirectory && ( + + {skill.directory} + + )} + {skill.repoOwner && skill.repoName && ( + + {skill.repoOwner}/{skill.repoName} + + )} +
+
+ {skill.installed && ( + + {t("skills.installed")} + + )} +
+
+ +

+ {skill.description || t("skills.noDescription")} +

+
+ + {skill.readmeUrl && ( + + )} + {skill.installed ? ( + + ) : ( + + )} + +
+ ); +} diff --git a/src/components/skills/SkillsPage.tsx b/src/components/skills/SkillsPage.tsx new file mode 100644 index 0000000..eed361e --- /dev/null +++ b/src/components/skills/SkillsPage.tsx @@ -0,0 +1,190 @@ +import { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { RefreshCw, Settings } from "lucide-react"; +import { toast } from "sonner"; +import { SkillCard } from "./SkillCard"; +import { RepoManager } from "./RepoManager"; +import { skillsApi, type Skill, type SkillRepo } from "@/lib/api/skills"; + +interface SkillsPageProps { + onClose?: () => void; +} + +export function SkillsPage({ onClose: _onClose }: SkillsPageProps = {}) { + const { t } = useTranslation(); + const [skills, setSkills] = useState([]); + const [repos, setRepos] = useState([]); + const [loading, setLoading] = useState(true); + const [repoManagerOpen, setRepoManagerOpen] = useState(false); + + const loadSkills = async (afterLoad?: (data: Skill[]) => void) => { + try { + setLoading(true); + const data = await skillsApi.getAll(); + setSkills(data); + if (afterLoad) { + afterLoad(data); + } + } catch (error) { + toast.error(t("skills.loadFailed"), { + description: error instanceof Error ? error.message : t("common.error"), + }); + } finally { + setLoading(false); + } + }; + + const loadRepos = async () => { + try { + const data = await skillsApi.getRepos(); + setRepos(data); + } catch (error) { + console.error("Failed to load repos:", error); + } + }; + + useEffect(() => { + Promise.all([loadSkills(), loadRepos()]); + }, []); + + const handleInstall = async (directory: string) => { + try { + await skillsApi.install(directory); + toast.success(t("skills.installSuccess", { name: directory })); + await loadSkills(); + } catch (error) { + toast.error(t("skills.installFailed"), { + description: error instanceof Error ? error.message : t("common.error"), + }); + } + }; + + const handleUninstall = async (directory: string) => { + try { + await skillsApi.uninstall(directory); + toast.success(t("skills.uninstallSuccess", { name: directory })); + await loadSkills(); + } catch (error) { + toast.error(t("skills.uninstallFailed"), { + description: error instanceof Error ? error.message : t("common.error"), + }); + } + }; + + const handleAddRepo = async (repo: SkillRepo) => { + await skillsApi.addRepo(repo); + + let repoSkillCount = 0; + await Promise.all([ + loadRepos(), + loadSkills((data) => { + repoSkillCount = data.filter( + (skill) => + skill.repoOwner === repo.owner && + skill.repoName === repo.name && + (skill.repoBranch || "main") === (repo.branch || "main"), + ).length; + }), + ]); + + toast.success( + t("skills.repo.addSuccess", { + owner: repo.owner, + name: repo.name, + count: repoSkillCount, + }), + ); + }; + + const handleRemoveRepo = async (owner: string, name: string) => { + await skillsApi.removeRepo(owner, name); + toast.success(t("skills.repo.removeSuccess", { owner, name })); + await Promise.all([loadRepos(), loadSkills()]); + }; + + return ( +
+ {/* 顶部操作栏(固定区域) */} +
+
+

+ {t("skills.title")} +

+
+ + +
+
+ + {/* 描述 */} +

+ {t("skills.description")} +

+
+ + {/* 技能网格(可滚动详情区域) */} +
+ {loading ? ( +
+ +
+ ) : skills.length === 0 ? ( +
+

+ {t("skills.empty")} +

+

+ {t("skills.emptyDescription")} +

+ +
+ ) : ( +
+ {skills.map((skill) => ( + + ))} +
+ )} +
+ + {/* 仓库管理对话框 */} + +
+ ); +} diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000..d3d5d60 --- /dev/null +++ b/src/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const badgeVariants = cva( + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ); +} + +export { Badge, badgeVariants }; diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx new file mode 100644 index 0000000..dc3b01d --- /dev/null +++ b/src/components/ui/card.tsx @@ -0,0 +1,86 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +Card.displayName = "Card"; + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardHeader.displayName = "CardHeader"; + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +CardTitle.displayName = "CardTitle"; + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +CardDescription.displayName = "CardDescription"; + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +CardContent.displayName = "CardContent"; + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardFooter.displayName = "CardFooter"; + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardDescription, + CardContent, +}; diff --git a/src/components/ui/table.tsx b/src/components/ui/table.tsx new file mode 100644 index 0000000..ab47cae --- /dev/null +++ b/src/components/ui/table.tsx @@ -0,0 +1,121 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const Table = React.forwardRef< + HTMLTableElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+ + +)); +Table.displayName = "Table"; + +const TableHeader = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableHeader.displayName = "TableHeader"; + +const TableBody = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableBody.displayName = "TableBody"; + +const TableFooter = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + tr]:last:border-b-0", + className, + )} + {...props} + /> +)); +TableFooter.displayName = "TableFooter"; + +const TableRow = React.forwardRef< + HTMLTableRowElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableRow.displayName = "TableRow"; + +const TableHead = React.forwardRef< + HTMLTableCellElement, + React.ThHTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +TableHead.displayName = "TableHead"; + +const TableCell = React.forwardRef< + HTMLTableCellElement, + React.TdHTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableCell.displayName = "TableCell"; + +const TableCaption = React.forwardRef< + HTMLTableCaptionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +TableCaption.displayName = "TableCaption"; + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +}; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index b66f491..19abdac 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -26,7 +26,8 @@ "format": "Format", "formatSuccess": "Formatted successfully", "formatError": "Format failed: {{error}}", - "copy": "Copy" + "copy": "Copy", + "view": "View" }, "apiKeyInput": { "placeholder": "Enter API Key", @@ -606,5 +607,49 @@ "deleteTitle": "Confirm Delete", "deleteMessage": "Are you sure you want to delete prompt \"{{name}}\"?" } + }, + "skills": { + "manage": "Skills", + "title": "Claude Skills Management", + "description": "Discover and install Claude skills from popular repositories to extend Claude Code/Codex capabilities", + "refresh": "Refresh", + "refreshing": "Refreshing...", + "repoManager": "Repository Management", + "count": "{{count}} skills", + "empty": "No skills available", + "emptyDescription": "Add skill repositories to discover available skills", + "addRepo": "Add Skill Repository", + "loading": "Loading...", + "installed": "Installed", + "install": "Install", + "installing": "Installing...", + "uninstall": "Uninstall", + "uninstalling": "Uninstalling...", + "view": "View", + "noDescription": "No description", + "loadFailed": "Failed to load", + "installSuccess": "Skill {{name}} installed", + "installFailed": "Failed to install", + "uninstallSuccess": "Skill {{name}} uninstalled", + "uninstallFailed": "Failed to uninstall", + "repo": { + "title": "Manage Skill Repositories", + "description": "Add or remove GitHub skill repository sources", + "url": "Repository URL", + "urlPlaceholder": "owner/name or https://github.com/owner/name", + "branch": "Branch", + "branchPlaceholder": "main", + "path": "Skills Path", + "pathPlaceholder": "skills (optional, leave empty for root)", + "add": "Add Repository", + "list": "Added Repositories", + "empty": "No repositories", + "invalidUrl": "Invalid repository URL format", + "addSuccess": "Repository {{owner}}/{{name}} added, detected {{count}} skills", + "addFailed": "Failed to add", + "removeSuccess": "Repository {{owner}}/{{name}} removed", + "removeFailed": "Failed to remove", + "skillCount": "{{count}} skills detected" + } } } diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 6959f59..24f0cfb 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -26,7 +26,8 @@ "format": "格式化", "formatSuccess": "格式化成功", "formatError": "格式化失败:{{error}}", - "copy": "复制" + "copy": "复制", + "view": "查看" }, "apiKeyInput": { "placeholder": "请输入API Key", @@ -606,5 +607,49 @@ "deleteTitle": "确认删除", "deleteMessage": "确定要删除提示词 \"{{name}}\" 吗?" } + }, + "skills": { + "manage": "Skills", + "title": "Claude Skills 管理", + "description": "从流行的仓库发现并安装 Claude 技能,扩展 Claude Code/Codex 的能力", + "refresh": "刷新", + "refreshing": "刷新中...", + "repoManager": "仓库管理", + "count": "共 {{count}} 个技能", + "empty": "暂无可用技能", + "emptyDescription": "添加技能仓库以发现可用的技能", + "addRepo": "添加技能仓库", + "loading": "加载中...", + "installed": "已安装", + "install": "安装", + "installing": "安装中...", + "uninstall": "卸载", + "uninstalling": "卸载中...", + "view": "查看", + "noDescription": "暂无描述", + "loadFailed": "加载失败", + "installSuccess": "技能 {{name}} 已安装", + "installFailed": "安装失败", + "uninstallSuccess": "技能 {{name}} 已卸载", + "uninstallFailed": "卸载失败", + "repo": { + "title": "管理技能仓库", + "description": "添加或删除 GitHub 技能仓库源", + "url": "仓库 URL", + "urlPlaceholder": "owner/name 或 https://github.com/owner/name", + "branch": "分支", + "branchPlaceholder": "main", + "path": "技能路径", + "pathPlaceholder": "skills (可选,留空扫描根目录)", + "add": "添加仓库", + "list": "已添加的仓库", + "empty": "暂无仓库", + "invalidUrl": "无效的仓库 URL 格式", + "addSuccess": "仓库 {{owner}}/{{name}} 已添加,识别到 {{count}} 个技能", + "addFailed": "添加失败", + "removeSuccess": "仓库 {{owner}}/{{name}} 已删除", + "removeFailed": "删除失败", + "skillCount": "识别到 {{count}} 个技能" + } } } diff --git a/src/lib/api/skills.ts b/src/lib/api/skills.ts new file mode 100644 index 0000000..c0ddb87 --- /dev/null +++ b/src/lib/api/skills.ts @@ -0,0 +1,47 @@ +import { invoke } from "@tauri-apps/api/core"; + +export interface Skill { + key: string; + name: string; + description: string; + directory: string; + readmeUrl?: string; + installed: boolean; + repoOwner?: string; + repoName?: string; + repoBranch?: string; +} + +export interface SkillRepo { + owner: string; + name: string; + branch: string; + enabled: boolean; + skillsPath?: string; // 可选:技能所在的子目录路径,如 "skills" +} + +export const skillsApi = { + async getAll(): Promise { + return await invoke("get_skills"); + }, + + async install(directory: string): Promise { + return await invoke("install_skill", { directory }); + }, + + async uninstall(directory: string): Promise { + return await invoke("uninstall_skill", { directory }); + }, + + async getRepos(): Promise { + return await invoke("get_skill_repos"); + }, + + async addRepo(repo: SkillRepo): Promise { + return await invoke("add_skill_repo", { repo }); + }, + + async removeRepo(owner: string, name: string): Promise { + return await invoke("remove_skill_repo", { owner, name }); + }, +};