diff --git a/Cargo.lock b/Cargo.lock index 9ff213a..19bbb0c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -402,6 +402,18 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "console" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +dependencies = [ + "encode_unicode", + "libc", + "unicode-width 0.2.2", + "windows-sys 0.61.2", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -546,6 +558,18 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "dialoguer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96" +dependencies = [ + "console", + "shell-words", + "tempfile", + "zeroize", +] + [[package]] name = "digest" version = "0.10.7" @@ -558,6 +582,27 @@ dependencies = [ "subtle", ] +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + [[package]] name = "displaydoc" version = "0.2.6" @@ -584,6 +629,12 @@ dependencies = [ "serde", ] +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -1131,6 +1182,9 @@ dependencies = [ "base64", "chrono", "clap", + "console", + "dialoguer", + "dirs", "dotenvy", "futures-util", "hex", @@ -1784,6 +1838,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "parking" version = "2.2.1" @@ -2162,6 +2222,17 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + [[package]] name = "regex-automata" version = "0.4.14" @@ -2331,7 +2402,7 @@ dependencies = [ "nix", "radix_trie", "unicode-segmentation", - "unicode-width", + "unicode-width 0.1.14", "utf8parse", "windows-sys 0.52.0", ] @@ -2472,6 +2543,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "1.3.0" @@ -3196,6 +3273,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" diff --git a/Cargo.toml b/Cargo.toml index f976fc0..15377d0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,3 +38,6 @@ async-trait = "0.1" futures-util = "0.3" sqlx = { version = "0.8.6", features = ["runtime-tokio-native-tls", "postgres", "uuid", "chrono", "derive", "migrate"] } tracing-appender = "0.2.5" +dialoguer = "0.12.0" +console = "0.16.3" +dirs = "6.0.0" diff --git a/src/cli.rs b/src/cli.rs index f0b5d82..a6140ed 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -63,4 +63,18 @@ pub enum Commands { /// 后台服务模式(等同 listen --llm,日志写入文件) Service, + + /// 从其他工具导入 Skills + Skills { + #[command(subcommand)] + action: SkillsAction, + }, +} + +#[derive(Subcommand, Debug)] +pub enum SkillsAction { + /// 扫描并交互式导入 + Import, + /// 列出可导入的 Skill + List, } diff --git a/src/main.rs b/src/main.rs index f4d52b3..783c5c0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,12 +5,13 @@ mod db; mod llm; mod logger; mod scheduler; +mod skills_importer; mod state; mod tools; mod wechat; use clap::Parser; -use cli::{Cli, Commands}; +use cli::{Cli, Commands, SkillsAction}; use config::AppConfig; use context::MemoryStore; use db::Database; @@ -90,6 +91,9 @@ async fn main() { Commands::Service => { cmd_listen(true, false, &database, &file_state, &config, &memory_store).await } + Commands::Skills { action } => { + cmd_skills(action).await; + } } } @@ -749,3 +753,66 @@ async fn cmd_send( Err(e) => error!("发送失败: {}", e), } } + +async fn cmd_skills(action: SkillsAction) { + use dialoguer::{MultiSelect, theme::ColorfulTheme}; + use console::Term; + + match action { + SkillsAction::List => { + let discovered = skills_importer::discover_skills(); + if discovered.is_empty() { + println!("未发现可导入的 Skill"); + return; + } + println!("发现 {} 个可导入的 Skill:", discovered.len()); + for (source, name, _path) in &discovered { + println!(" [{source}] {name}"); + } + } + SkillsAction::Import => { + let discovered = skills_importer::discover_skills(); + if discovered.is_empty() { + println!("未发现可导入的 Skill"); + return; + } + + let items: Vec = discovered.iter() + .map(|(src, name, _)| format!("[{}] {}", src, name)) + .collect(); + + println!("发现 {} 个可导入的 Skill(空格选择,回车确认,q 退出):", discovered.len()); + let selection = MultiSelect::with_theme(&ColorfulTheme::default()) + .items(&items) + .interact_on(&Term::stderr()) + .unwrap_or_default(); + + if selection.is_empty() { + println!("未选择任何 Skill"); + return; + } + + let dest = std::path::PathBuf::from("skills"); + std::fs::create_dir_all(&dest).ok(); + + let mut imported = 0; + let mut skipped = 0; + for idx in selection { + let (src, name, path) = &discovered[idx]; + println!(" 导入 [{src}] {name}..."); + match skills_importer::copy_skill(path, name, &dest) { + Ok(_) => { + println!(" ✅ 已导入"); + imported += 1; + } + Err(e) => { + println!(" ⚠️ {e}"); + skipped += 1; + } + } + } + println!("\n完成:导入 {imported} 个,跳过 {skipped} 个"); + println!("重启 iAs 后生效"); + } + } +} diff --git a/src/skills_importer.rs b/src/skills_importer.rs new file mode 100644 index 0000000..d8231c2 --- /dev/null +++ b/src/skills_importer.rs @@ -0,0 +1,80 @@ +use std::path::{Path, PathBuf}; +use std::fs; + +/// 已知的 Skills 来源路径 +fn known_skill_paths() -> Vec<(String, PathBuf)> { + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); + + let mut paths = Vec::new(); + + // Claude Code + paths.push(("Claude Code".into(), home.join(".claude").join("skills"))); + // Claude (project) + paths.push(("Claude (项目)".into(), PathBuf::from(".claude/skills"))); + + // Agent Skills 标准 + paths.push(("Agent Skills (全局)".into(), home.join(".agents").join("skills"))); + paths.push(("Agent Skills (项目)".into(), PathBuf::from(".agents/skills"))); + + // opencode + paths.push(("OpenCode".into(), home.join(".opencode").join("skills"))); + + // pi coding agent + paths.push(("pi".into(), home.join(".pi").join("agent").join("skills"))); + + paths +} + +/// 扫描所有已知路径,返回 (来源, 技能名称, 技能目录) 列表 +pub fn discover_skills() -> Vec<(String, String, PathBuf)> { + let mut results = Vec::new(); + + for (source, dir) in known_skill_paths() { + if let Ok(entries) = fs::read_dir(&dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + let skill_md = path.join("SKILL.md"); + if skill_md.exists() { + let name = path.file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + results.push((source.clone(), name, path.clone())); + } + } + } + } + } + + results.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0))); // sort by name, then source + results +} + +/// 复制一个技能到目标目录 +pub fn copy_skill(src: &Path, name: &str, dest_dir: &Path) -> Result { + let target = dest_dir.join(name); + if target.exists() { + return Err(format!("技能 {} 已存在", name)); + } + + copy_dir_recursive(src, &target)?; + Ok(target) +} + +fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), String> { + fs::create_dir_all(dst).map_err(|e| format!("创建目录失败: {}", e))?; + + for entry in fs::read_dir(src).map_err(|e| format!("读取目录失败: {}", e))? { + let entry = entry.map_err(|e| format!("读取条目失败: {}", e))?; + let src_path = entry.path(); + let dst_path = dst.join(entry.file_name()); + + if src_path.is_dir() { + copy_dir_recursive(&src_path, &dst_path)?; + } else { + fs::copy(&src_path, &dst_path) + .map_err(|e| format!("复制文件失败 {}: {}", src_path.display(), e))?; + } + } + Ok(()) +}