oxi_agent/tools/
manage_skill_tool.rs1use super::{AgentTool, AgentToolResult, ToolContext, ToolError, ToolExecutionMode, ToolTier};
6use async_trait::async_trait;
7use serde_json::{Value, json};
8use std::path::PathBuf;
9use tokio::fs;
10use tokio::sync::oneshot;
11
12fn managed_skills_dir() -> PathBuf {
14 let home = std::env::var("HOME")
15 .or_else(|_| std::env::var("USERPROFILE"))
16 .unwrap_or_else(|_| ".".to_string());
17 PathBuf::from(home).join(".omp/agent/managed-skills")
18}
19
20fn validate_skill_name(name: &str) -> Result<String, ToolError> {
22 let sanitized: String = name
23 .chars()
24 .filter(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == '-')
25 .collect();
26 if sanitized.is_empty() || sanitized != name {
27 return Err(format!(
28 "Invalid skill name '{}'. Use kebab-case (lowercase letters, digits, hyphens).",
29 name
30 ));
31 }
32 Ok(sanitized)
33}
34
35pub struct ManageSkillTool;
37
38#[async_trait]
39impl AgentTool for ManageSkillTool {
40 fn name(&self) -> &str {
41 "manage_skill"
42 }
43
44 fn label(&self) -> &str {
45 "Manage Skill"
46 }
47
48 fn description(&self) -> &str {
49 concat!(
50 "Create, update, or delete an isolated managed skill. ",
51 "Managed skills are SKILL.md files in ~/.omp/agent/managed-skills/ ",
52 "that are surfaced to future sessions like normal skills. ",
53 "Actions: create (requires name, description, body), ",
54 "update (requires name, description, body), delete (requires name)."
55 )
56 }
57
58 fn essential(&self) -> bool {
59 false
60 }
61
62 fn parameters_schema(&self) -> Value {
63 json!({
64 "type": "object",
65 "properties": {
66 "action": {
67 "type": "string",
68 "enum": ["create", "update", "delete"],
69 "description": "Action to perform on the managed skill."
70 },
71 "name": {
72 "type": "string",
73 "description": "Kebab-case skill name (lowercase letters, digits, hyphens)."
74 },
75 "description": {
76 "type": "string",
77 "description": "One-line description of when to use the skill (required for create/update)."
78 },
79 "body": {
80 "type": "string",
81 "description": "The SKILL.md body in markdown, no frontmatter (required for create/update)."
82 }
83 },
84 "required": ["action", "name"]
85 })
86 }
87
88 fn intent(&self) -> Option<&str> {
89 Some("Manage a SKILL.md file")
90 }
91
92 fn execution_mode(&self) -> ToolExecutionMode {
93 ToolExecutionMode::SequentialOnly
94 }
95
96 fn tool_tier(&self) -> ToolTier {
97 ToolTier::Write
98 }
99
100 async fn execute(
101 &self,
102 _tool_call_id: &str,
103 params: Value,
104 _signal: Option<oneshot::Receiver<()>>,
105 _ctx: &ToolContext,
106 ) -> Result<AgentToolResult, ToolError> {
107 let action = params
108 .get("action")
109 .and_then(|v| v.as_str())
110 .ok_or_else(|| "Missing required parameter: action".to_string())?;
111
112 let name = params
113 .get("name")
114 .and_then(|v| v.as_str())
115 .ok_or_else(|| "Missing required parameter: name".to_string())?;
116 let name = validate_skill_name(name)?;
117
118 match action {
119 "create" | "update" => {
120 let description = params
121 .get("description")
122 .and_then(|v| v.as_str())
123 .ok_or_else(|| "Missing 'description' for create/update".to_string())?;
124 let body = params
125 .get("body")
126 .and_then(|v| v.as_str())
127 .ok_or_else(|| "Missing 'body' for create/update".to_string())?;
128
129 let skill_dir = managed_skills_dir().join(&name);
130 fs::create_dir_all(&skill_dir)
131 .await
132 .map_err(|e| format!("Failed to create skill directory: {}", e))?;
133
134 let skill_path = skill_dir.join("SKILL.md");
135 let content = format!(
136 "---\nname: {}\ndescription: {}\n---\n\n{}",
137 name, description, body
138 );
139 fs::write(&skill_path, &content)
140 .await
141 .map_err(|e| format!("Failed to write SKILL.md: {}", e))?;
142
143 Ok(AgentToolResult::success(format!(
144 "Skill '{}' {}d.\nDescription: {}\nPath: {}",
145 name,
146 action,
147 description,
148 skill_path.display()
149 )))
150 }
151 "delete" => {
152 let skill_dir = managed_skills_dir().join(&name);
153 let skill_path = skill_dir.join("SKILL.md");
154
155 if !skill_path.exists() {
156 return Err(format!(
157 "Skill '{}' not found at {}",
158 name,
159 skill_path.display()
160 ));
161 }
162
163 fs::remove_file(&skill_path)
164 .await
165 .map_err(|e| format!("Failed to delete SKILL.md: {}", e))?;
166
167 let _ = fs::remove_dir(&skill_dir).await;
169
170 Ok(AgentToolResult::success(format!(
171 "Skill '{}' deleted.",
172 name
173 )))
174 }
175 _ => Err(format!("Unknown action: {}", action)),
176 }
177 }
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183
184 #[tokio::test]
185 async fn test_validate_skill_name() {
186 assert!(validate_skill_name("debug-rust").is_ok());
187 assert!(validate_skill_name("my-skill-123").is_ok());
188 assert!(validate_skill_name("Bad Name").is_err());
189 assert!(validate_skill_name("UPPERCASE").is_err());
190 }
191}