468 lines
17 KiB
Rust
468 lines
17 KiB
Rust
use async_trait::async_trait;
|
|
use serde_json::json;
|
|
use std::sync::Arc;
|
|
|
|
use crate::skills::{SkillRuntime, SkillScope};
|
|
use crate::tools::traits::{Tool, ToolResult};
|
|
use crate::tools::{extract_bool, extract_string_array};
|
|
|
|
pub struct SkillManageTool {
|
|
skills: Arc<SkillRuntime>,
|
|
}
|
|
|
|
impl SkillManageTool {
|
|
pub fn new(skills: Arc<SkillRuntime>) -> Self {
|
|
Self { skills }
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Tool for SkillManageTool {
|
|
fn name(&self) -> &str {
|
|
"skill_manage"
|
|
}
|
|
|
|
fn description(&self) -> &str {
|
|
"Manage PicoBot skills. Actions: list, get, create, update, delete, disable, reload.\n\n\
|
|
IMPORTANT: To create or modify skills, ALWAYS use this tool (skill_manage), NOT the write tool.\n\n\
|
|
Skill Structure:\n\
|
|
- Folder name: kebab-case (lowercase with hyphens, e.g., 'my-cool-skill')\n\
|
|
- Required: SKILL.md with YAML frontmatter + Markdown body\n\
|
|
- Optional folders: scripts/, references/, assets/\n\
|
|
- Storage paths (created automatically by this tool):\n\
|
|
- Project scope: {current-dir}/.picobot/skills/{name}/SKILL.md\n\
|
|
- User scope: ~/.picobot/skills/{name}/SKILL.md\n\n\
|
|
Installing from Zip:\n\
|
|
- Extract skill folders to .picobot/skills/ directory (NOT skills/)\n\
|
|
- If zip contains multiple skills, extract each subfolder separately\n\
|
|
- Final structure: .picobot/skills/{skill-name}/SKILL.md"
|
|
}
|
|
|
|
fn parameters_schema(&self) -> serde_json::Value {
|
|
json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"action": {
|
|
"type": "string",
|
|
"enum": ["list", "get", "create", "update", "delete", "disable", "reload"],
|
|
"description": "Management action to perform"
|
|
},
|
|
"scope": {
|
|
"type": "string",
|
|
"enum": ["project", "user"],
|
|
"description": "Writable skill scope for create/update/delete/disable. Defaults to project. .agents discovery sources are read-only here, but can still be disabled via sidecar state."
|
|
},
|
|
"name": {
|
|
"type": "string",
|
|
"description": "Skill folder name in kebab-case (e.g., 'my-cool-skill', 'code-review'). The skill_manage tool automatically creates files at .picobot/skills/{name}/SKILL.md (project scope) or ~/.picobot/skills/{name}/SKILL.md (user scope)."
|
|
},
|
|
"names": {
|
|
"type": "array",
|
|
"items": {
|
|
"type": "string"
|
|
},
|
|
"description": "Skill names for batch disable; pass a single-item array to disable one skill"
|
|
},
|
|
"description": {
|
|
"type": "string",
|
|
"description": "Skill description used for discovery"
|
|
},
|
|
"body": {
|
|
"type": "string",
|
|
"description": "Skill body instructions"
|
|
},
|
|
"reload": {
|
|
"type": "boolean",
|
|
"description": "Whether to reload the runtime catalog after mutation. Defaults to true.",
|
|
"default": true
|
|
}
|
|
},
|
|
"required": ["action"]
|
|
})
|
|
}
|
|
|
|
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
|
let action = match args.get("action").and_then(|v| v.as_str()) {
|
|
Some(value) => value,
|
|
None => {
|
|
return Ok(error_result("Missing required parameter: action"));
|
|
}
|
|
};
|
|
|
|
let reload = extract_bool(&args, "reload").unwrap_or(true);
|
|
let scope = match args.get("scope").and_then(|v| v.as_str()) {
|
|
Some(value) => match SkillScope::parse(value) {
|
|
Some(scope) => scope,
|
|
None => {
|
|
return Ok(error_result(
|
|
"scope must be 'project' or 'user'; .agents sources are discovery-only",
|
|
));
|
|
}
|
|
},
|
|
None => SkillScope::Project,
|
|
};
|
|
|
|
let name = args.get("name").and_then(|v| v.as_str());
|
|
|
|
let result = match action {
|
|
"list" => list_skills_payload(&self.skills),
|
|
"get" => {
|
|
let name = match name {
|
|
Some(name) => name,
|
|
None => return Ok(error_result("Missing required parameter: name")),
|
|
};
|
|
|
|
match self.skills.get_skill(name) {
|
|
Some(skill) => json!({
|
|
"name": skill.name,
|
|
"description": skill.description,
|
|
"body": skill.body,
|
|
"source": match skill.source {
|
|
crate::skills::SkillSource::User => "user",
|
|
crate::skills::SkillSource::UserAgent => "user_agent",
|
|
crate::skills::SkillSource::UserOpenclaw => "user_openclaw",
|
|
crate::skills::SkillSource::Project => "project",
|
|
crate::skills::SkillSource::ProjectAgent => "project_agent",
|
|
crate::skills::SkillSource::ProjectOpenclaw => "project_openclaw",
|
|
},
|
|
"path": skill.path.display().to_string(),
|
|
}),
|
|
None => return Ok(error_result(&format!("skill '{}' not found", name))),
|
|
}
|
|
}
|
|
"create" => {
|
|
let name = match name {
|
|
Some(name) => name,
|
|
None => return Ok(error_result("Missing required parameter: name")),
|
|
};
|
|
let description = match args.get("description").and_then(|v| v.as_str()) {
|
|
Some(value) => value,
|
|
None => return Ok(error_result("Missing required parameter: description")),
|
|
};
|
|
let body = args.get("body").and_then(|v| v.as_str()).unwrap_or("");
|
|
|
|
match self
|
|
.skills
|
|
.create_skill(scope, name, description, body, reload)
|
|
{
|
|
Ok(skill) => json!({
|
|
"status": "created",
|
|
"name": skill.name,
|
|
"path": skill.path.display().to_string(),
|
|
"scope": scope.as_str(),
|
|
"reloaded": reload,
|
|
}),
|
|
Err(err) => return Ok(error_result(&err)),
|
|
}
|
|
}
|
|
"update" => {
|
|
let name = match name {
|
|
Some(name) => name,
|
|
None => return Ok(error_result("Missing required parameter: name")),
|
|
};
|
|
let description = args.get("description").and_then(|v| v.as_str());
|
|
let body = args.get("body").and_then(|v| v.as_str());
|
|
if description.is_none() && body.is_none() {
|
|
return Ok(error_result("update requires description or body"));
|
|
}
|
|
|
|
match self
|
|
.skills
|
|
.update_skill(scope, name, description, body, reload)
|
|
{
|
|
Ok(skill) => json!({
|
|
"status": "updated",
|
|
"name": skill.name,
|
|
"path": skill.path.display().to_string(),
|
|
"scope": scope.as_str(),
|
|
"reloaded": reload,
|
|
}),
|
|
Err(err) => return Ok(error_result(&err)),
|
|
}
|
|
}
|
|
"delete" => {
|
|
let name = match name {
|
|
Some(name) => name,
|
|
None => return Ok(error_result("Missing required parameter: name")),
|
|
};
|
|
|
|
match self.skills.delete_skill(scope, name, reload) {
|
|
Ok(path) => json!({
|
|
"status": "deleted",
|
|
"name": name,
|
|
"path": path.display().to_string(),
|
|
"scope": scope.as_str(),
|
|
"reloaded": reload,
|
|
}),
|
|
Err(err) => return Ok(error_result(&err)),
|
|
}
|
|
}
|
|
"reload" => match self.skills.reload() {
|
|
Ok(catalog) => json!({
|
|
"status": "reloaded",
|
|
"count": catalog.len(),
|
|
}),
|
|
Err(err) => return Ok(error_result(&err)),
|
|
},
|
|
"disable" => {
|
|
let names = match parse_disable_names(&args) {
|
|
Ok(names) => names,
|
|
Err(err) => return Ok(error_result(&err)),
|
|
};
|
|
let targets = &names;
|
|
|
|
let mut changes = Vec::new();
|
|
for target in targets {
|
|
match self.skills.disable_skill(scope, target, false) {
|
|
Ok(change) => changes.push(change),
|
|
Err(err) => return Ok(error_result(&err)),
|
|
}
|
|
}
|
|
if reload {
|
|
if let Err(err) = self.skills.reload() {
|
|
return Ok(error_result(&err));
|
|
}
|
|
}
|
|
|
|
json!({
|
|
"status": "disabled",
|
|
"scope": scope.as_str(),
|
|
"count": changes.len(),
|
|
"reloaded": reload,
|
|
"changes": changes.into_iter().map(skill_change_payload).collect::<Vec<_>>(),
|
|
})
|
|
}
|
|
_ => return Ok(error_result("Unsupported action")),
|
|
};
|
|
|
|
Ok(ToolResult {
|
|
success: true,
|
|
output: serde_json::to_string_pretty(&result)?,
|
|
error: None,
|
|
})
|
|
}
|
|
}
|
|
|
|
fn error_result(message: &str) -> ToolResult {
|
|
ToolResult {
|
|
success: false,
|
|
output: String::new(),
|
|
error: Some(message.to_string()),
|
|
}
|
|
}
|
|
|
|
fn parse_disable_names(args: &serde_json::Value) -> Result<Vec<String>, String> {
|
|
// 支持两种格式:实际数组 或 字符串化的 JSON 数组
|
|
extract_string_array(args, "names")
|
|
.filter(|arr| !arr.is_empty())
|
|
.ok_or_else(|| "disable requires names (array of strings)".to_string())
|
|
}
|
|
|
|
fn skill_change_payload(change: crate::skills::SkillAvailabilityChange) -> serde_json::Value {
|
|
json!({
|
|
"name": change.name,
|
|
"scope": change.scope.as_str(),
|
|
"path": change.state_path.display().to_string(),
|
|
"changed": change.changed,
|
|
"available": change.available,
|
|
"disabled_in_scopes": change.disabled_in_scopes.into_iter().map(|scope| scope.as_str()).collect::<Vec<_>>(),
|
|
})
|
|
}
|
|
|
|
fn list_skills_payload(skills: &Arc<SkillRuntime>) -> serde_json::Value {
|
|
let skills = skills.list_skills();
|
|
json!({
|
|
"count": skills.len(),
|
|
"skills": skills.into_iter().map(|skill| json!({
|
|
"name": skill.name,
|
|
"description": skill.description,
|
|
"source": match skill.source {
|
|
crate::skills::SkillSource::User => "user",
|
|
crate::skills::SkillSource::UserAgent => "user_agent",
|
|
crate::skills::SkillSource::UserOpenclaw => "user_openclaw",
|
|
crate::skills::SkillSource::Project => "project",
|
|
crate::skills::SkillSource::ProjectAgent => "project_agent",
|
|
crate::skills::SkillSource::ProjectOpenclaw => "project_openclaw",
|
|
},
|
|
"path": skill.path.display().to_string(),
|
|
})).collect::<Vec<_>>()
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::config::SkillsConfig;
|
|
use crate::skills::acquire_skill_test_env_lock;
|
|
use std::ffi::OsString;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
struct CurrentDirGuard {
|
|
previous: PathBuf,
|
|
}
|
|
|
|
struct HomeDirGuard {
|
|
previous: Option<OsString>,
|
|
previous_userprofile: Option<OsString>,
|
|
}
|
|
|
|
impl CurrentDirGuard {
|
|
fn enter(path: &Path) -> Self {
|
|
let previous = std::env::current_dir().unwrap();
|
|
std::env::set_current_dir(path).unwrap();
|
|
Self { previous }
|
|
}
|
|
}
|
|
|
|
impl Drop for CurrentDirGuard {
|
|
fn drop(&mut self) {
|
|
let _ = std::env::set_current_dir(&self.previous);
|
|
}
|
|
}
|
|
|
|
impl HomeDirGuard {
|
|
fn enter(path: &Path) -> Self {
|
|
let home_backup = std::env::var_os("HOME");
|
|
let userprofile_backup = std::env::var_os("USERPROFILE");
|
|
|
|
unsafe {
|
|
std::env::set_var("HOME", path);
|
|
std::env::set_var("USERPROFILE", path);
|
|
}
|
|
|
|
Self {
|
|
previous: home_backup,
|
|
previous_userprofile: userprofile_backup,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Drop for HomeDirGuard {
|
|
fn drop(&mut self) {
|
|
unsafe {
|
|
match &self.previous {
|
|
Some(value) => std::env::set_var("HOME", value),
|
|
None => std::env::remove_var("HOME"),
|
|
}
|
|
match &self.previous_userprofile {
|
|
Some(value) => std::env::set_var("USERPROFILE", value),
|
|
None => std::env::remove_var("USERPROFILE"),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn acquire_test_lock() -> std::sync::MutexGuard<'static, ()> {
|
|
acquire_skill_test_env_lock()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_skill_manage_disable_updates_runtime() {
|
|
let _lock = acquire_test_lock();
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
let home_dir = temp_dir.path().join("home");
|
|
let project_dir = temp_dir.path().join("project");
|
|
std::fs::create_dir_all(&home_dir).unwrap();
|
|
std::fs::create_dir_all(&project_dir).unwrap();
|
|
let _home = HomeDirGuard::enter(&home_dir);
|
|
let _guard = CurrentDirGuard::enter(&project_dir);
|
|
|
|
let runtime = Arc::new(SkillRuntime::from_config(SkillsConfig {
|
|
enabled: true,
|
|
sources: vec!["project".to_string()],
|
|
max_index_chars: 4000,
|
|
max_listed_skills: 32,
|
|
}));
|
|
runtime
|
|
.create_skill(SkillScope::Project, "demo", "demo skill", "body", true)
|
|
.unwrap();
|
|
|
|
let tool = SkillManageTool::new(runtime.clone());
|
|
|
|
let disabled = tool
|
|
.execute(json!({
|
|
"action": "disable",
|
|
"names": ["demo"],
|
|
"scope": "project"
|
|
}))
|
|
.await
|
|
.unwrap();
|
|
assert!(disabled.success);
|
|
assert!(disabled.output.contains("disabled"));
|
|
assert!(runtime.get_skill("demo").is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_skill_manage_batch_disable_uses_names_array() {
|
|
let _lock = acquire_test_lock();
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
let home_dir = temp_dir.path().join("home");
|
|
let project_dir = temp_dir.path().join("project");
|
|
std::fs::create_dir_all(&home_dir).unwrap();
|
|
std::fs::create_dir_all(&project_dir).unwrap();
|
|
let _home = HomeDirGuard::enter(&home_dir);
|
|
let _guard = CurrentDirGuard::enter(&project_dir);
|
|
|
|
let runtime = Arc::new(SkillRuntime::from_config(SkillsConfig {
|
|
enabled: true,
|
|
sources: vec!["project".to_string()],
|
|
max_index_chars: 4000,
|
|
max_listed_skills: 32,
|
|
}));
|
|
runtime
|
|
.create_skill(SkillScope::Project, "demo-a", "demo skill a", "body", true)
|
|
.unwrap();
|
|
runtime
|
|
.create_skill(SkillScope::Project, "demo-b", "demo skill b", "body", true)
|
|
.unwrap();
|
|
|
|
let tool = SkillManageTool::new(runtime.clone());
|
|
|
|
let disabled = tool
|
|
.execute(json!({
|
|
"action": "disable",
|
|
"names": ["demo-a", "demo-b"],
|
|
"scope": "project"
|
|
}))
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(disabled.success);
|
|
assert!(disabled.output.contains("\"count\": 2"));
|
|
assert!(runtime.get_skill("demo-a").is_none());
|
|
assert!(runtime.get_skill("demo-b").is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_skill_manage_disable_requires_names_array() {
|
|
let _lock = acquire_test_lock();
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
let home_dir = temp_dir.path().join("home");
|
|
let project_dir = temp_dir.path().join("project");
|
|
std::fs::create_dir_all(&home_dir).unwrap();
|
|
std::fs::create_dir_all(&project_dir).unwrap();
|
|
let _home = HomeDirGuard::enter(&home_dir);
|
|
let _guard = CurrentDirGuard::enter(&project_dir);
|
|
|
|
let runtime = Arc::new(SkillRuntime::from_config(SkillsConfig {
|
|
enabled: true,
|
|
sources: vec!["project".to_string()],
|
|
max_index_chars: 4000,
|
|
max_listed_skills: 32,
|
|
}));
|
|
let tool = SkillManageTool::new(runtime);
|
|
|
|
let result = tool
|
|
.execute(json!({
|
|
"action": "disable",
|
|
"name": "demo",
|
|
"scope": "project"
|
|
}))
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(!result.success);
|
|
assert!(result.error.unwrap().contains("disable requires names"));
|
|
}
|
|
}
|