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