PicoBot/src/tools/skill_manage.rs

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"));
}
}