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 #[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 #[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 #[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 #[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 #[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 #[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}