Skip to main content

imp_core/tools/
extend.rs

1use std::path::Path;
2#[cfg(test)]
3use std::path::PathBuf;
4
5use async_trait::async_trait;
6use serde_json::json;
7
8use super::{Tool, ToolContext, ToolOutput};
9use crate::error::Result;
10use crate::storage;
11
12const LUA_REFERENCE: &str = include_str!("../../skills/lua-tools/SKILL.md");
13const SKILL_REFERENCE: &str = include_str!("../../skills/writing-skills/REFERENCE.md");
14
15pub struct ExtendTool;
16
17#[async_trait]
18impl Tool for ExtendTool {
19    fn name(&self) -> &str {
20        "extend"
21    }
22
23    fn label(&self) -> &str {
24        "Extend Imp"
25    }
26
27    fn description(&self) -> &str {
28        "Create skills and Lua tools to extend imp. Actions: \
29         'lua_reference' returns the Lua tool API, \
30         'skill_reference' returns the skill authoring guide, \
31         'create'/'patch'/'delete' manage skill files."
32    }
33
34    fn parameters(&self) -> serde_json::Value {
35        json!({
36            "type": "object",
37            "required": ["action"],
38            "properties": {
39                "action": {
40                    "type": "string",
41                    "enum": ["lua_reference", "skill_reference", "create", "patch", "delete"],
42                    "description": "lua_reference: Lua tool API guide. skill_reference: skill authoring guide. create/patch/delete: manage skill files."
43                },
44                "name": {
45                    "type": "string",
46                    "description": "Skill name for create/patch/delete (lowercase, hyphens, e.g. 'deploy-k8s')"
47                },
48                "content": {
49                    "type": "string",
50                    "description": "Full SKILL.md content including frontmatter (for 'create')"
51                },
52                "old_text": {
53                    "type": "string",
54                    "description": "Text to find in the skill (for 'patch')"
55                },
56                "new_text": {
57                    "type": "string",
58                    "description": "Replacement text (for 'patch')"
59                }
60            }
61        })
62    }
63
64    fn is_readonly(&self) -> bool {
65        false
66    }
67
68    async fn execute(
69        &self,
70        _call_id: &str,
71        params: serde_json::Value,
72        _ctx: ToolContext,
73    ) -> Result<ToolOutput> {
74        let action = params["action"].as_str().unwrap_or("");
75
76        match action {
77            "lua_reference" => Ok(ToolOutput::text(LUA_REFERENCE)),
78            "skill_reference" => Ok(ToolOutput::text(SKILL_REFERENCE)),
79            "create" | "patch" | "delete" => {
80                let name = params["name"].as_str().unwrap_or("");
81                if name.is_empty() {
82                    return Ok(ToolOutput::error(
83                        "Missing required parameter: name (for create/patch/delete)",
84                    ));
85                }
86                if let Some(reason) = validate_skill_name(name) {
87                    return Ok(ToolOutput::error(reason));
88                }
89
90                let agent_skills_dir = storage::global_skills_dir().join("agent");
91
92                match action {
93                    "create" => {
94                        let content = params["content"].as_str().unwrap_or("");
95                        if content.is_empty() {
96                            return Ok(ToolOutput::error(
97                                "Missing required parameter: content (for 'create')",
98                            ));
99                        }
100                        create_skill(&agent_skills_dir, name, content)
101                    }
102                    "patch" => {
103                        let old_text = params["old_text"].as_str().unwrap_or("");
104                        let new_text = params["new_text"].as_str().unwrap_or("");
105                        if old_text.is_empty() {
106                            return Ok(ToolOutput::error(
107                                "Missing required parameter: old_text (for 'patch')",
108                            ));
109                        }
110                        patch_skill(&agent_skills_dir, name, old_text, new_text)
111                    }
112                    "delete" => delete_skill(&agent_skills_dir, name),
113                    other => Ok(ToolOutput::error(format!(
114                        "Unknown skill action \"{other}\". Use: create, patch, delete"
115                    ))),
116                }
117            }
118            "" => Ok(ToolOutput::error("Missing required parameter: action")),
119            other => Ok(ToolOutput::error(format!(
120                "Unknown action \"{other}\". Use: lua_reference, skill_reference, create, patch, delete"
121            ))),
122        }
123    }
124}
125
126fn validate_skill_name(name: &str) -> Option<String> {
127    if name.len() > 64 {
128        return Some(format!(
129            "Skill name too long ({} chars, max 64)",
130            name.len()
131        ));
132    }
133    if name.starts_with('-') || name.ends_with('-') {
134        return Some("Skill name cannot start or end with a hyphen".to_string());
135    }
136    if name.contains("--") {
137        return Some("Skill name cannot contain consecutive hyphens".to_string());
138    }
139    if !name
140        .chars()
141        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
142    {
143        return Some(
144            "Skill name must contain only lowercase letters, numbers, and hyphens".to_string(),
145        );
146    }
147    None
148}
149
150fn validate_frontmatter(content: &str, expected_name: &str) -> Option<String> {
151    let trimmed = content.trim();
152    if !trimmed.starts_with("---") {
153        return Some("SKILL.md must start with YAML frontmatter (---)".to_string());
154    }
155
156    let after_first = &trimmed[3..];
157    let end = after_first.find("\n---");
158    let Some(end) = end else {
159        return Some("SKILL.md frontmatter not closed (missing ---)".to_string());
160    };
161
162    let yaml_block = &after_first[..end];
163
164    let has_name = yaml_block
165        .lines()
166        .any(|l| l.trim_start().starts_with("name:"));
167    let has_desc = yaml_block
168        .lines()
169        .any(|l| l.trim_start().starts_with("description:"));
170
171    if !has_name {
172        return Some("Frontmatter missing required field: name".to_string());
173    }
174    if !has_desc {
175        return Some("Frontmatter missing required field: description".to_string());
176    }
177
178    for line in yaml_block.lines() {
179        let line = line.trim();
180        if let Some(val) = line.strip_prefix("name:") {
181            let val = val.trim().trim_matches('"').trim_matches('\'');
182            if val != expected_name {
183                return Some(format!(
184                    "Frontmatter name \"{val}\" doesn't match skill name \"{expected_name}\""
185                ));
186            }
187        }
188    }
189
190    None
191}
192
193fn create_skill(agent_dir: &Path, name: &str, content: &str) -> Result<ToolOutput> {
194    let skill_dir = agent_dir.join(name);
195    let skill_file = skill_dir.join("SKILL.md");
196
197    if skill_file.exists() {
198        return Ok(ToolOutput::error(format!(
199            "Skill \"{name}\" already exists. Use 'patch' to update it."
200        )));
201    }
202
203    if let Some(reason) = validate_frontmatter(content, name) {
204        return Ok(ToolOutput::error(reason));
205    }
206
207    std::fs::create_dir_all(&skill_dir)?;
208    std::fs::write(&skill_file, content)?;
209
210    Ok(ToolOutput::text(format!(
211        "Created skill \"{name}\" at {}",
212        skill_file.display()
213    )))
214}
215
216fn patch_skill(agent_dir: &Path, name: &str, old_text: &str, new_text: &str) -> Result<ToolOutput> {
217    let agent_path = agent_dir.join(name).join("SKILL.md");
218    let skill_path = if agent_path.exists() {
219        agent_path
220    } else {
221        let parent_dir = agent_dir.parent().unwrap_or(agent_dir);
222        let alt_path = parent_dir.join(name).join("SKILL.md");
223        if alt_path.exists() {
224            alt_path
225        } else {
226            return Ok(ToolOutput::error(format!(
227                "Skill \"{name}\" not found. Use 'create' first."
228            )));
229        }
230    };
231
232    let content = std::fs::read_to_string(&skill_path)?;
233    let count = content.matches(old_text).count();
234
235    match count {
236        0 => Ok(ToolOutput::error(format!(
237            "Text not found in skill \"{name}\""
238        ))),
239        1 => {
240            let updated = content.replacen(old_text, new_text, 1);
241            std::fs::write(&skill_path, &updated)?;
242            Ok(ToolOutput::text(format!("Patched skill \"{name}\"")))
243        }
244        n => Ok(ToolOutput::error(format!(
245            "Text matches {n} times in skill \"{name}\". Provide a more specific old_text."
246        ))),
247    }
248}
249
250fn delete_skill(agent_dir: &Path, name: &str) -> Result<ToolOutput> {
251    let skill_dir = agent_dir.join(name);
252
253    if !skill_dir.exists() {
254        let parent = agent_dir.parent().unwrap_or(agent_dir);
255        if parent.join(name).exists() {
256            return Ok(ToolOutput::error(format!(
257                "Skill \"{name}\" exists but is not agent-created. \
258                 Only agent-created skills (in agent/ directory) can be deleted."
259            )));
260        }
261        return Ok(ToolOutput::error(format!("Skill \"{name}\" not found")));
262    }
263
264    std::fs::remove_dir_all(&skill_dir)?;
265    Ok(ToolOutput::text(format!("Deleted skill \"{name}\"")))
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271    use tempfile::TempDir;
272
273    fn setup() -> (TempDir, PathBuf) {
274        let dir = TempDir::new().unwrap();
275        let agent_dir = dir.path().join("skills").join("agent");
276        std::fs::create_dir_all(&agent_dir).unwrap();
277        (dir, agent_dir)
278    }
279
280    fn valid_skill_content(name: &str) -> String {
281        format!("---\nname: {name}\ndescription: A test skill\n---\n\n# Test Skill\n\nDo things.\n")
282    }
283
284    fn test_ctx() -> ToolContext {
285        let (tx, _rx) = tokio::sync::mpsc::channel(16);
286        let (cmd_tx, _cmd_rx) = tokio::sync::mpsc::channel(16);
287        ToolContext {
288            cwd: PathBuf::from("/tmp"),
289            cancelled: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
290            update_tx: tx,
291            command_tx: cmd_tx,
292            ui: std::sync::Arc::new(crate::ui::NullInterface),
293            file_cache: std::sync::Arc::new(crate::tools::FileCache::new()),
294            checkpoint_state: std::sync::Arc::new(crate::tools::CheckpointState::new()),
295            file_tracker: std::sync::Arc::new(std::sync::Mutex::new(
296                crate::tools::FileTracker::new(),
297            )),
298            anchor_store: std::sync::Arc::new(crate::tools::AnchorStore::new()),
299            lua_tool_loader: None,
300            mode: crate::config::AgentMode::Full,
301            read_max_lines: 500,
302            turn_mana_review: std::sync::Arc::new(std::sync::Mutex::new(
303                crate::mana_review::TurnManaReviewAccumulator::default(),
304            )),
305            config: std::sync::Arc::new(crate::config::Config::default()),
306        }
307    }
308
309    // --- References ---
310
311    #[tokio::test]
312    async fn extend_lua_reference() {
313        let tool = ExtendTool;
314        let result = tool
315            .execute("c1", json!({"action": "lua_reference"}), test_ctx())
316            .await
317            .unwrap();
318        assert!(!result.is_error);
319        let text = result.text_content().unwrap();
320        assert!(text.contains("imp.register_tool"));
321        assert!(text.contains("imp.exec"));
322    }
323
324    #[tokio::test]
325    async fn extend_skill_reference() {
326        let tool = ExtendTool;
327        let result = tool
328            .execute("c2", json!({"action": "skill_reference"}), test_ctx())
329            .await
330            .unwrap();
331        assert!(!result.is_error);
332        let text = result.text_content().unwrap();
333        assert!(text.contains("SKILL.md"));
334        assert!(text.contains("frontmatter"));
335    }
336
337    // --- Name validation ---
338
339    #[test]
340    fn extend_valid_names() {
341        assert!(validate_skill_name("deploy-k8s").is_none());
342        assert!(validate_skill_name("a").is_none());
343        assert!(validate_skill_name("my-skill-123").is_none());
344    }
345
346    #[test]
347    fn extend_rejects_bad_names() {
348        assert!(validate_skill_name("Deploy").is_some());
349        assert!(validate_skill_name("-bad").is_some());
350        assert!(validate_skill_name("bad-").is_some());
351        assert!(validate_skill_name("bad--name").is_some());
352        assert!(validate_skill_name("bad name").is_some());
353        assert!(validate_skill_name(&"a".repeat(65)).is_some());
354    }
355
356    // --- Frontmatter ---
357
358    #[test]
359    fn extend_valid_frontmatter() {
360        let content = "---\nname: test\ndescription: A test\n---\n\n# Body\n";
361        assert!(validate_frontmatter(content, "test").is_none());
362    }
363
364    #[test]
365    fn extend_frontmatter_missing_fields() {
366        assert!(validate_frontmatter("---\ndescription: A test\n---\n", "test").is_some());
367        assert!(validate_frontmatter("---\nname: test\n---\n", "test").is_some());
368    }
369
370    #[test]
371    fn extend_frontmatter_name_mismatch() {
372        let r = validate_frontmatter("---\nname: wrong\ndescription: A test\n---\n", "test");
373        assert!(r.is_some());
374        assert!(r.unwrap().contains("doesn't match"));
375    }
376
377    #[test]
378    fn extend_no_frontmatter() {
379        assert!(validate_frontmatter("# Just a heading", "test").is_some());
380    }
381
382    // --- Create ---
383
384    #[test]
385    fn extend_create_skill() {
386        let (_dir, agent_dir) = setup();
387        let content = valid_skill_content("my-skill");
388        let r = create_skill(&agent_dir, "my-skill", &content).unwrap();
389        assert!(!r.is_error);
390        assert!(agent_dir.join("my-skill").join("SKILL.md").exists());
391    }
392
393    #[test]
394    fn extend_create_duplicate() {
395        let (_dir, agent_dir) = setup();
396        let content = valid_skill_content("dup");
397        create_skill(&agent_dir, "dup", &content).unwrap();
398        let r = create_skill(&agent_dir, "dup", &content).unwrap();
399        assert!(r.is_error);
400    }
401
402    // --- Patch ---
403
404    #[test]
405    fn extend_patch_skill() {
406        let (_dir, agent_dir) = setup();
407        create_skill(&agent_dir, "patchme", &valid_skill_content("patchme")).unwrap();
408        let r = patch_skill(&agent_dir, "patchme", "Do things.", "Do better things.").unwrap();
409        assert!(!r.is_error);
410        let updated = std::fs::read_to_string(agent_dir.join("patchme").join("SKILL.md")).unwrap();
411        assert!(updated.contains("Do better things."));
412    }
413
414    #[test]
415    fn extend_patch_not_found() {
416        let (_dir, agent_dir) = setup();
417        create_skill(&agent_dir, "patchme", &valid_skill_content("patchme")).unwrap();
418        let r = patch_skill(&agent_dir, "patchme", "NONEXISTENT", "new").unwrap();
419        assert!(r.is_error);
420    }
421
422    // --- Delete ---
423
424    #[test]
425    fn extend_delete_skill() {
426        let (_dir, agent_dir) = setup();
427        create_skill(&agent_dir, "deleteme", &valid_skill_content("deleteme")).unwrap();
428        let r = delete_skill(&agent_dir, "deleteme").unwrap();
429        assert!(!r.is_error);
430        assert!(!agent_dir.join("deleteme").exists());
431    }
432
433    #[test]
434    fn extend_delete_nonexistent() {
435        let (_dir, agent_dir) = setup();
436        let r = delete_skill(&agent_dir, "nope").unwrap();
437        assert!(r.is_error);
438    }
439
440    #[test]
441    fn extend_delete_non_agent_refused() {
442        let (_dir, agent_dir) = setup();
443        let parent = agent_dir.parent().unwrap();
444        let user_skill = parent.join("user-skill");
445        std::fs::create_dir_all(&user_skill).unwrap();
446        std::fs::write(user_skill.join("SKILL.md"), "content").unwrap();
447        let r = delete_skill(&agent_dir, "user-skill").unwrap();
448        assert!(r.is_error);
449        assert!(r.text_content().unwrap().contains("not agent-created"));
450    }
451}