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, } impl SkillManageTool { pub fn new(skills: Arc) -> 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 { 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::>(), }) } _ => 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, 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::>(), }) } fn list_skills_payload(skills: &Arc) -> 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::>() }) } #[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, previous_userprofile: Option, } 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")); } }