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:
2026-06-01 21:31:00 +08:00
parent f51b5c100f
commit cef8bee8ee
5 changed files with 249 additions and 2 deletions
+14
View File
@@ -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,
}
+68 -1
View File
@@ -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<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 后生效");
}
}
}
+80
View File
@@ -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(())
}