diff --git a/.gitignore b/.gitignore index 2a0038a..3e41ded 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target -.idea \ No newline at end of file +.idea +.cache \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 1842f18..d105414 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -181,6 +181,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "futures" version = "0.3.32" @@ -265,12 +271,28 @@ dependencies = [ "wasi", ] +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "indicator" version = "0.4.4" @@ -474,11 +496,34 @@ dependencies = [ "getrandom", ] +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + [[package]] name = "regex-automata" version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "ruff_python_ast" @@ -549,6 +594,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -601,6 +652,19 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "siphasher" version = "1.0.2" @@ -764,6 +828,12 @@ dependencies = [ "rand", ] +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "utf8parse" version = "0.2.2" @@ -779,10 +849,12 @@ dependencies = [ "console", "indicator", "path_abs", + "regex", "ruff_python_ast", "ruff_python_parser", "serde", "serde_json", + "serde_yaml", "walkdir", ] diff --git a/Cargo.toml b/Cargo.toml index 3a47d42..a79bc95 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,4 +13,6 @@ path_abs = "0.5" console = "0.16.3" indicator = "0.4.4" ruff_python_parser = { git = "https://github.com/astral-sh/ruff", rev = "v0.4.0" } -ruff_python_ast = { git = "https://github.com/astral-sh/ruff", rev = "v0.4.0" } \ No newline at end of file +ruff_python_ast = { git = "https://github.com/astral-sh/ruff", rev = "v0.4.0" } +regex = "1.12.3" +serde_yaml = "0.9.34" \ No newline at end of file diff --git a/README.md b/README.md index 0e96794..88315fd 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,25 @@ -
# Viceroy (总督) Pretor的插件管理工具 - -
--- -*"你们搞大模型的就是码奸,你们已经害死前端兄弟了,还要害死后端兄弟,测试兄弟,运维兄弟,害死网安兄弟,害死ic兄弟,最后害死自己害死全人类"* +>*"你们搞大模型的就是码奸,你们已经害死前端兄弟了,还要害死后端兄弟,测试兄弟,运维兄弟,害死网安兄弟,害死ic兄弟,最后害死自己害死全人类"* + +viceroy 是一个由rust编写的安装工具,用于pretor的插件管理 + +--- +## 目前支持对象 +- skill: 安装skill并进行简单的解析到目标文件夹下 + +--- +## 使用方法 +#### Skill +**Skill** 是一个由指令、脚本和资源组成的集合,Agent通过动态加载这些内容,以在特定任务上提升表现。**Skill** 教会 **Agent** 如何以可重复的方式完成特定任务,例如按照公司品牌指南创建文档、使用组织特定的工作流程分析数据,或自动化个人任务。 +目标仓库:https://github.com/anthropics/skills +```Bash +./viceroy install (github仓库名) [-p (仓库内SKILL.md所在目录的相对路径)] -o (输出路径) +``` +**viceroy**将在skill根目录下产生 **skill.json** 和 **metadata.json**两个文件。 +**skill.json**包括SKILL.md的**name**,**description**,**instructions**。 +**metadata**包含整个skill的文件树和架构和python脚本工具的函数信息。 \ No newline at end of file diff --git a/src/installer/git.rs b/src/installer/git.rs index 51a35d3..f324173 100644 --- a/src/installer/git.rs +++ b/src/installer/git.rs @@ -11,9 +11,7 @@ pub struct GitInstaller{ } impl GitInstaller { - // 1. 增加一个关联函数 new,负责“一键初始化” pub fn new(git_repo_url: &str, root_cache_path: &str) -> Self { - // 逻辑:从 URL 提取仓库名(总督的直觉) let repo_name = git_repo_url .split('/') .last() @@ -21,8 +19,6 @@ impl GitInstaller { .trim_end_matches(".git"); let mut path_buf = PathBuf::from(root_cache_path); path_buf.push(repo_name); - - // 返回实例 Self { git_repo_url: git_repo_url.to_owned(), cache_path: Some(path_buf.display().to_string()), diff --git a/src/installer/install.rs b/src/installer/install.rs index f6b6612..10b8206 100644 --- a/src/installer/install.rs +++ b/src/installer/install.rs @@ -1,5 +1,30 @@ use anyhow::Result; +use std::path::Path; +use std::fs; pub trait Installer { fn download(&self) -> Result<()>; +} + +pub fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> { + if !dst.exists() { + fs::create_dir_all(dst)?; + } + for entry in fs::read_dir(src)? { + let entry = entry?; + let ty = entry.file_type()?; + let target = dst.join(entry.file_name()); + + // Skip .git directory to avoid unnecessary weight + if entry.file_name() == ".git" { + continue; + } + + if ty.is_dir() { + copy_dir_recursive(&entry.path(), &target)?; + } else { + fs::copy(&entry.path(), &target)?; + } + } + Ok(()) } \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 4b02c1b..2850f21 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,69 @@ +use clap::{Parser, Subcommand}; +use std::path::PathBuf; +use viceroy::manifest::skill::analysis::process_and_save_skill; +use viceroy::manifest::skill::model::SkillModel; +use anyhow::Result; - -fn main() { - - +#[derive(Parser)] +#[command(name = "viceroy")] +#[command(about = "Pretor's plugin management tool", long_about = None)] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Parse a skill directory, extracting SKILL.md and analyzing python files. + Parse { + /// The path to the skill directory + #[arg(short, long, value_name = "DIR")] + path: PathBuf, + }, + /// Install a skill from a Git repository and parse it + Install { + /// The Git repository URL + url: String, + + /// Subdirectory path inside the repo (default is root) + #[arg(short = 'p', long, default_value = "")] + path: String, + + /// Root cache directory to clone into + #[arg(short = 'c', long, default_value = ".cache")] + cache_dir: String, + + /// Output directory to move the final skill into + #[arg(short = 'o', long)] + output: Option, + }, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + + match &cli.command { + Commands::Parse { path } => { + if !path.exists() || !path.is_dir() { + anyhow::bail!("Error: path {:?} does not exist or is not a directory", path); + } + println!("Parsing skill directory: {:?}", path); + process_and_save_skill(path)?; + println!("Done."); + } + Commands::Install { url, path, cache_dir, output } => { + let mut final_url = url.clone(); + if !final_url.starts_with("http://") && !final_url.starts_with("https://") && !final_url.starts_with("git@") { + final_url = format!("https://github.com/{}", final_url); + } + + println!("Installing skill from {} into {}", final_url, cache_dir); + let skill = SkillModel::install(final_url, cache_dir.clone(), path.clone(), output.clone()); + println!("Analyzing installed skill at {}", skill.skill_path); + skill.analysis()?; + println!("Done."); + } + } + + Ok(()) } diff --git a/src/manifest.rs b/src/manifest.rs index 5d6c58e..4784a6b 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -1 +1 @@ -mod skill; +pub mod skill; diff --git a/src/manifest/skill/analysis.rs b/src/manifest/skill/analysis.rs index c51b6bb..512063b 100644 --- a/src/manifest/skill/analysis.rs +++ b/src/manifest/skill/analysis.rs @@ -1,9 +1,39 @@ -use crate::manifest::skill::skill_structure_tree::{SkillNode,PythonFuncNode,PythonFileNode}; +use crate::manifest::skill::skill_structure_tree::{SkillNode, SkillJson, PythonFuncNode, PythonFileNode}; use walkdir::WalkDir; use std::path::Path; use std::collections::HashMap; use ruff_python_parser::{parse, Mode}; use ruff_python_ast::{Mod, Stmt}; +use regex::Regex; + +pub fn parse_skill_md(content: &str) -> SkillJson { + let mut metadata = SkillJson::default(); + + // Pattern to match YAML frontmatter between `---` and `---` + let re_frontmatter = Regex::new(r"(?s)^---\s*(.*?)\s*---").unwrap(); + if let Some(caps) = re_frontmatter.captures(content) { + let frontmatter = caps.get(1).map_or("", |m| m.as_str()); + + // Parse frontmatter properly using serde_yaml to support multiline values like `|` + if let Ok(yaml_data) = serde_yaml::from_str::(frontmatter) { + if let Some(name) = yaml_data.get("name").and_then(|v| v.as_str()) { + metadata.name = name.to_string(); + } + if let Some(desc) = yaml_data.get("description").and_then(|v| v.as_str()) { + metadata.description = desc.to_string(); + } + } + + // Extract instructions (everything after the frontmatter) + let body = re_frontmatter.replace(content, "").trim().to_string(); + metadata.instructions = body; + } else { + // No frontmatter found, whole file is instructions + metadata.instructions = content.trim().to_string(); + } + + metadata +} fn analyze_python_file(code: &str) -> PythonFileNode { let mut func_dict = HashMap::new(); @@ -29,25 +59,62 @@ fn analyze_python_file(code: &str) -> PythonFileNode { } } -fn main_scan_logic(root_path: &Path) { +pub fn analyze_skill_directory(root_path: &Path) -> (Option, SkillNode) { let mut root_node = SkillNode::new_folder("root"); + let mut skill_metadata = None; + for entry in WalkDir::new(root_path).into_iter().filter_map(|e| e.ok()) { let path = entry.path(); if path.is_file() { + let file_name_str = path.file_name().unwrap().to_string_lossy().to_string(); + + // Skip useless files + let lower_name = file_name_str.to_lowercase(); + if lower_name.starts_with("license") || file_name_str.starts_with('.') { + continue; + } + let rel_path = path.strip_prefix(root_path).unwrap(); let segments: Vec<&str> = rel_path.iter().map(|s| s.to_str().unwrap()).collect(); - let node = if path.extension().and_then(|s| s.to_str()) == Some("py") { + + if file_name_str.to_lowercase() == "skill.md" && segments.len() == 1 { let code = std::fs::read_to_string(path).expect("读取文件失败"); + skill_metadata = Some(parse_skill_md(&code)); + // We don't add SKILL.md to the tree since it will be in skill.json + continue; + } + + let node = if path.extension().and_then(|s| s.to_str()) == Some("py") { + let code = std::fs::read_to_string(path).unwrap_or_default(); let mut py_node = analyze_python_file(&code); - py_node.file_name = path.file_stem().unwrap().to_string_lossy().into(); + py_node.file_name = file_name_str.clone(); SkillNode::Python(py_node) } else { - let file_name = path.file_name().unwrap().to_string_lossy().into(); - SkillNode::File(file_name) + SkillNode::File(file_name_str) }; root_node.insert_recursive(&segments, node); } } - let json = serde_json::to_string_pretty(&root_node).unwrap(); - println!("{}", json); + + (skill_metadata, root_node) +} + +pub fn process_and_save_skill(root_path: &Path) -> anyhow::Result<()> { + let (metadata_opt, tree) = analyze_skill_directory(root_path); + + // Save skill.json + if let Some(metadata) = metadata_opt { + let skill_json_path = root_path.join("skill.json"); + let skill_json_content = serde_json::to_string_pretty(&metadata)?; + std::fs::write(&skill_json_path, skill_json_content)?; + println!("Saved {:?}", skill_json_path); + } + + // Save metadata.json + let metadata_json_path = root_path.join("metadata.json"); + let tree_json_content = serde_json::to_string_pretty(&tree)?; + std::fs::write(&metadata_json_path, tree_json_content)?; + println!("Saved {:?}", metadata_json_path); + + Ok(()) } diff --git a/src/manifest/skill/skill.rs b/src/manifest/skill/skill.rs index de706b1..d2eec3b 100644 --- a/src/manifest/skill/skill.rs +++ b/src/manifest/skill/skill.rs @@ -4,7 +4,7 @@ use crate::manifest::skill::model::SkillModel; use std::path::PathBuf; impl SkillModel{ - fn install(git_repo_url: String, root_cache_path: String, relative_path: String) -> Self{ + pub fn install(git_repo_url: String, root_cache_path: String, relative_path: String, output_dir: Option) -> Self{ let git_installer = git::GitInstaller::new(&git_repo_url, &root_cache_path); if let Err(e) = git_installer.download() { eprintln!("安装失败: {}", e); @@ -17,12 +17,36 @@ impl SkillModel{ let mut path_builder = PathBuf::from(&root_cache_path); path_builder.push(git_repo_name); path_builder.push(&relative_path); - let skill_path = path_builder.to_string_lossy().to_string(); + + let mut final_path = path_builder.to_string_lossy().to_string(); + + if let Some(out_dir) = output_dir { + // Determine the name of the skill directory to create inside the output directory. + // e.g. if relative_path is "skills/skill-creator", skill_dir_name is "skill-creator". + // If relative_path is empty, use the repo name. + let skill_dir_name = if relative_path.is_empty() { + git_repo_name + } else { + relative_path.split('/').last().unwrap_or(git_repo_name) + }; + + let target_dst = std::path::Path::new(&out_dir).join(skill_dir_name); + + // Copy the contents to the new target directory + if let Err(e) = crate::installer::install::copy_dir_recursive(&path_builder, &target_dst) { + eprintln!("复制到目标文件夹失败: {}", e); + } else { + final_path = target_dst.to_string_lossy().to_string(); + } + } + Self{ - skill_path, + skill_path: final_path, } } - fn analysis(&self){ + pub fn analysis(&self) -> anyhow::Result<()> { + use std::path::Path; + use crate::manifest::skill::analysis::process_and_save_skill; + process_and_save_skill(Path::new(&self.skill_path)) } -} - +} \ No newline at end of file diff --git a/src/manifest/skill/skill_structure_tree.rs b/src/manifest/skill/skill_structure_tree.rs index 3c3f070..cd37538 100644 --- a/src/manifest/skill/skill_structure_tree.rs +++ b/src/manifest/skill/skill_structure_tree.rs @@ -1,5 +1,5 @@ use std::collections::HashMap; -use serde::Serialize; +use serde::{Serialize, Deserialize}; #[derive(Serialize)] pub struct PythonFuncNode{ @@ -44,4 +44,11 @@ impl SkillNode { panic!("试图在已存在文件{}内保存文件", segments[0]); } } +} + +#[derive(Serialize, Deserialize, Default)] +pub struct SkillJson { + pub name: String, + pub description: String, + pub instructions: String, } \ No newline at end of file