feat: skills import — 从其他工具复制 Skills 的交互式 TUI
新增: - skills_importer.rs: 扫描已知路径 (Claude Code, Agent Skills, OpenCode, pi) 发现可导入的 Skill - ias skills list: 列出可导入的 Skill - ias skills import: 交互式多选 TUI (dialoguer MultiSelect) 复制选中的 Skill 到 skills/ 目录 依赖: dialoguer + console + dirs
This commit is contained in:
Generated
+84
-1
@@ -402,6 +402,18 @@ dependencies = [
|
|||||||
"crossbeam-utils",
|
"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]]
|
[[package]]
|
||||||
name = "const-oid"
|
name = "const-oid"
|
||||||
version = "0.9.6"
|
version = "0.9.6"
|
||||||
@@ -546,6 +558,18 @@ dependencies = [
|
|||||||
"powerfmt",
|
"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]]
|
[[package]]
|
||||||
name = "digest"
|
name = "digest"
|
||||||
version = "0.10.7"
|
version = "0.10.7"
|
||||||
@@ -558,6 +582,27 @@ dependencies = [
|
|||||||
"subtle",
|
"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]]
|
[[package]]
|
||||||
name = "displaydoc"
|
name = "displaydoc"
|
||||||
version = "0.2.6"
|
version = "0.2.6"
|
||||||
@@ -584,6 +629,12 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "encode_unicode"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "encoding_rs"
|
name = "encoding_rs"
|
||||||
version = "0.8.35"
|
version = "0.8.35"
|
||||||
@@ -1131,6 +1182,9 @@ dependencies = [
|
|||||||
"base64",
|
"base64",
|
||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
|
"console",
|
||||||
|
"dialoguer",
|
||||||
|
"dirs",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"hex",
|
"hex",
|
||||||
@@ -1784,6 +1838,12 @@ dependencies = [
|
|||||||
"vcpkg",
|
"vcpkg",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "option-ext"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "parking"
|
name = "parking"
|
||||||
version = "2.2.1"
|
version = "2.2.1"
|
||||||
@@ -2162,6 +2222,17 @@ dependencies = [
|
|||||||
"bitflags",
|
"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]]
|
[[package]]
|
||||||
name = "regex-automata"
|
name = "regex-automata"
|
||||||
version = "0.4.14"
|
version = "0.4.14"
|
||||||
@@ -2331,7 +2402,7 @@ dependencies = [
|
|||||||
"nix",
|
"nix",
|
||||||
"radix_trie",
|
"radix_trie",
|
||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
"unicode-width",
|
"unicode-width 0.1.14",
|
||||||
"utf8parse",
|
"utf8parse",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
@@ -2472,6 +2543,12 @@ dependencies = [
|
|||||||
"lazy_static",
|
"lazy_static",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "shell-words"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "shlex"
|
name = "shlex"
|
||||||
version = "1.3.0"
|
version = "1.3.0"
|
||||||
@@ -3196,6 +3273,12 @@ version = "0.1.14"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
|
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-width"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-xid"
|
name = "unicode-xid"
|
||||||
version = "0.2.6"
|
version = "0.2.6"
|
||||||
|
|||||||
@@ -38,3 +38,6 @@ async-trait = "0.1"
|
|||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
sqlx = { version = "0.8.6", features = ["runtime-tokio-native-tls", "postgres", "uuid", "chrono", "derive", "migrate"] }
|
sqlx = { version = "0.8.6", features = ["runtime-tokio-native-tls", "postgres", "uuid", "chrono", "derive", "migrate"] }
|
||||||
tracing-appender = "0.2.5"
|
tracing-appender = "0.2.5"
|
||||||
|
dialoguer = "0.12.0"
|
||||||
|
console = "0.16.3"
|
||||||
|
dirs = "6.0.0"
|
||||||
|
|||||||
+14
@@ -63,4 +63,18 @@ pub enum Commands {
|
|||||||
|
|
||||||
/// 后台服务模式(等同 listen --llm,日志写入文件)
|
/// 后台服务模式(等同 listen --llm,日志写入文件)
|
||||||
Service,
|
Service,
|
||||||
|
|
||||||
|
/// 从其他工具导入 Skills
|
||||||
|
Skills {
|
||||||
|
#[command(subcommand)]
|
||||||
|
action: SkillsAction,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand, Debug)]
|
||||||
|
pub enum SkillsAction {
|
||||||
|
/// 扫描并交互式导入
|
||||||
|
Import,
|
||||||
|
/// 列出可导入的 Skill
|
||||||
|
List,
|
||||||
}
|
}
|
||||||
|
|||||||
+68
-1
@@ -5,12 +5,13 @@ mod db;
|
|||||||
mod llm;
|
mod llm;
|
||||||
mod logger;
|
mod logger;
|
||||||
mod scheduler;
|
mod scheduler;
|
||||||
|
mod skills_importer;
|
||||||
mod state;
|
mod state;
|
||||||
mod tools;
|
mod tools;
|
||||||
mod wechat;
|
mod wechat;
|
||||||
|
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use cli::{Cli, Commands};
|
use cli::{Cli, Commands, SkillsAction};
|
||||||
use config::AppConfig;
|
use config::AppConfig;
|
||||||
use context::MemoryStore;
|
use context::MemoryStore;
|
||||||
use db::Database;
|
use db::Database;
|
||||||
@@ -90,6 +91,9 @@ async fn main() {
|
|||||||
Commands::Service => {
|
Commands::Service => {
|
||||||
cmd_listen(true, false, &database, &file_state, &config, &memory_store).await
|
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),
|
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<String> = 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 后生效");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<PathBuf, String> {
|
||||||
|
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(())
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user