Skip to main content

oxi_agent/tools/
manage_skill_tool.rs

1/// Manage Skill tool — create, update, or delete isolated managed SKILL.md files.
2///
3/// Writes to `~/.omp/agent/managed-skills/<name>/SKILL.md`. Mirrors the omp
4/// `manage_skill` tool. Requires filesystem access to the managed skills directory.
5use 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
12/// Default managed skills directory.
13fn 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
20/// Validate a kebab-case skill name.
21fn 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
35/// ManageSkillTool — create/update/delete managed skills.
36pub 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                // Try to remove the directory (may fail if not empty)
168                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}