1use crate::skills::{
6 looks_like_stable_skill_id,
7 registry::{CommandRegistry, Resolution},
8 stable_skill_id, LoadedSkill,
9};
10use serde_json::json;
11use std::sync::Arc;
12
13pub struct LoadSkillTool {
14 registry: Arc<CommandRegistry>,
15}
16
17impl LoadSkillTool {
18 pub fn new(registry: Arc<CommandRegistry>) -> Self {
19 Self { registry }
20 }
21
22 pub fn format_body(skill: &LoadedSkill) -> Result<String, String> {
28 let body = skill.load_body().map_err(|reason| {
29 format!(
30 "skill '{}' could not be loaded: {reason}",
31 crate::BoundedText::new(&skill.name, 128).text
32 )
33 })?;
34 Ok(format!(
39 "# Skill: {} — {}\n\nFollow these guidelines for the rest of this conversation.\n\n{}",
40 sanitize_header_field(&skill.name, 128),
41 sanitize_header_field(&skill.description, 1024),
42 body
43 ))
44 }
45}
46
47fn sanitize_header_field(raw: &str, max_bytes: usize) -> String {
51 let cleaned: String = raw
52 .chars()
53 .map(|c| if c.is_control() { ' ' } else { c })
54 .collect();
55 crate::BoundedText::new(&cleaned, max_bytes).text
56}
57
58#[async_trait::async_trait]
59impl crate::Tool for LoadSkillTool {
60 fn effect(&self) -> crate::tools::catalog::ToolEffect {
61 crate::tools::catalog::ToolEffect::ReadOnly
62 }
63
64 fn name(&self) -> &str {
65 "load_skill"
66 }
67
68 fn description(&self) -> &str {
69 "Load a skill to guide your behavior for the current conversation. \
70 Skills provide structured guidelines, checklists, and best practices. \
71 Call this when a task would benefit from a specific methodology."
72 }
73
74 fn origin(&self) -> crate::tools::ToolOrigin {
78 crate::tools::ToolOrigin::Builtin
79 }
80
81 fn parameters(&self) -> serde_json::Value {
86 json!({
87 "type": "object",
88 "properties": {
89 "skill": {
90 "type": "string",
91 "description": "Skill to load: a stable id from search_skills, a bare name, or plugin:skill."
92 }
93 },
94 "required": ["skill"]
95 })
96 }
97
98 async fn execute(
99 &self,
100 params: serde_json::Value,
101 _ctx: crate::ToolContext,
102 ) -> crate::Result<String> {
103 let name = params["skill"]
104 .as_str()
105 .ok_or_else(|| crate::RuntimeError::Tool("Missing 'skill' parameter".to_string()))?;
106
107 if looks_like_stable_skill_id(name) {
116 let matches: Vec<Arc<LoadedSkill>> = self
117 .registry
118 .all_skills()
119 .into_iter()
120 .filter(|s| stable_skill_id(s) == name)
121 .collect();
122 let legacy: Option<Arc<LoadedSkill>> = match self.registry.resolve(name) {
128 Resolution::Skill(s) => Some(s),
129 _ => None,
130 };
131 if let Some(legacy) = &legacy {
132 if !matches.is_empty() {
133 return Err(crate::RuntimeError::Tool(format!(
134 "ambiguous skill id '{}': it names both a stable skill id and a \
135 plugin-qualified skill; use '{}' for the plugin skill",
136 crate::BoundedText::new(name, 160).text,
137 stable_skill_id(legacy)
138 )));
139 }
140 return Self::format_body(legacy).map_err(crate::RuntimeError::Tool);
141 }
142 return match matches.as_slice() {
143 [] => Err(crate::RuntimeError::Tool(format!(
144 "unknown skill id '{}'",
145 crate::BoundedText::new(name, 160).text
146 ))),
147 [skill] => Self::format_body(skill).map_err(crate::RuntimeError::Tool),
148 _ => Err(crate::RuntimeError::Tool(format!(
149 "ambiguous skill id '{}'",
150 crate::BoundedText::new(name, 160).text
151 ))),
152 };
153 }
154
155 match self.registry.resolve(name) {
156 Resolution::Skill(s) => Self::format_body(&s).map_err(crate::RuntimeError::Tool),
157 Resolution::Ambiguous(opts) => {
158 let bounded: Vec<String> = opts
161 .iter()
162 .map(|opt| crate::BoundedText::new(opt, 128).text)
163 .collect();
164 let list = crate::BoundedText::new(&bounded.join(", "), 1024).text;
165 Err(crate::RuntimeError::Tool(format!(
166 "ambiguous skill '{}'; specify one of: {}",
167 crate::BoundedText::new(name, 128).text,
168 list
169 )))
170 }
171 Resolution::PluginCommand(_) | Resolution::Builtin | Resolution::Unknown => {
172 Err(crate::RuntimeError::Tool(format!(
173 "unknown skill '{}'",
174 crate::BoundedText::new(name, 128).text
175 )))
176 }
177 }
178 }
179}
180
181const SKILL_DESCRIPTION_MAX_BYTES: usize = 160;
185const SEARCH_SKILLS_MAX_RESULTS: usize = 16;
187const SEARCH_SKILLS_MAX_RESULT_BYTES: usize = 8 * 1024;
189
190pub struct SearchSkillsTool {
195 registry: Arc<CommandRegistry>,
196}
197
198impl SearchSkillsTool {
199 pub fn new(registry: Arc<CommandRegistry>) -> Self {
200 Self { registry }
201 }
202}
203
204#[async_trait::async_trait]
205impl crate::Tool for SearchSkillsTool {
206 fn effect(&self) -> crate::tools::catalog::ToolEffect {
207 crate::tools::catalog::ToolEffect::ReadOnly
208 }
209
210 fn name(&self) -> &str {
211 "search_skills"
212 }
213
214 fn description(&self) -> &str {
215 "Search available skills by keyword. Returns bounded stable skill \
216 ids with compact descriptions. Pass a returned id to load_skill to \
217 load exactly that skill."
218 }
219
220 fn origin(&self) -> crate::tools::ToolOrigin {
222 crate::tools::ToolOrigin::Builtin
223 }
224
225 fn parameters(&self) -> serde_json::Value {
226 json!({
227 "type": "object",
228 "properties": {
229 "query": {
230 "type": "string",
231 "description": "Case-insensitive keyword matched against skill ids, names, and descriptions."
232 }
233 },
234 "required": ["query"]
235 })
236 }
237
238 async fn execute(
239 &self,
240 params: serde_json::Value,
241 _ctx: crate::ToolContext,
242 ) -> crate::Result<String> {
243 let raw = params["query"].as_str().ok_or_else(|| {
244 crate::RuntimeError::Tool("Missing 'query' parameter (string)".to_string())
245 })?;
246 let query = crate::tools::catalog::DiscoveryQuery::parse(raw)
249 .map_err(|err| crate::RuntimeError::Tool(format!("invalid skill query: {err}")))?;
250
251 let mut candidates: Vec<(String, Arc<LoadedSkill>)> = self
254 .registry
255 .all_skills()
256 .into_iter()
257 .map(|s| (stable_skill_id(&s), s))
258 .collect();
259 candidates.sort_by(|a, b| {
260 a.0.cmp(&b.0)
261 .then_with(|| a.1.plugin.cmp(&b.1.plugin))
262 .then_with(|| a.1.name.cmp(&b.1.name))
263 });
264
265 let mut hits = Vec::new();
266 let mut used_bytes = 2usize;
269 let mut truncated = false;
270 for (id, skill) in candidates {
271 let description =
272 crate::BoundedText::new(&skill.description, SKILL_DESCRIPTION_MAX_BYTES).text;
273 let haystack = format!(
274 "{}\n{}\n{}",
275 id.to_lowercase(),
276 skill.name.to_lowercase(),
277 description.to_lowercase()
278 );
279 if !haystack.contains(query.needle()) {
280 continue;
281 }
282 let entry = json!({
283 "id": id,
284 "name": skill.name,
285 "description": description,
286 });
287 let entry_bytes = serde_json::to_vec(&entry)
288 .map_err(|err| {
289 crate::RuntimeError::Tool(format!("failed to serialize skill entry: {err}"))
290 })?
291 .len();
292 let separator = usize::from(!hits.is_empty());
293 let next_bytes = used_bytes + separator + entry_bytes;
294 if hits.len() == SEARCH_SKILLS_MAX_RESULTS
295 || next_bytes > SEARCH_SKILLS_MAX_RESULT_BYTES
296 {
297 truncated = true;
298 break;
299 }
300 used_bytes = next_bytes;
301 hits.push(entry);
302 }
303
304 let body = json!({"truncated": truncated, "skills": hits});
305 serde_json::to_string(&body).map_err(|err| {
306 crate::RuntimeError::Tool(format!("failed to serialize skill search results: {err}"))
307 })
308 }
309}
310
311#[cfg(test)]
312mod tests {
313 use super::*;
314 use std::path::PathBuf;
315
316 fn test_ctx() -> crate::ToolContext {
317 crate::ToolContext {
318 channels: crate::tools::ToolChannels {
319 tx_delta: None,
320 tx_events: None,
321 },
322 capabilities: crate::tools::ToolCapabilities {
323 watcher_exit_path: None,
324 tool_register_tx: None,
325 session_manager: None,
326 subagent_registry: None,
327 event_queue: None,
328 delegation_parent: None,
329 secret_prompt: None,
330 orchestration: None,
331 tool_activation: None,
332 mcp_leases: None,
333 extension_leases: None,
334 memory_context: None,
335 },
336 limits: crate::tools::ToolLimits {
337 max_tool_output: 30000,
338 max_tool_buffer: 256 * 1024,
339 bash_timeout: 30,
340 bash_max_timeout: 300,
341 subagent_timeout: 300,
342 },
343 }
344 }
345
346 fn mk(name: &str, plugin: Option<&str>) -> LoadedSkill {
347 LoadedSkill::new_inline(
348 name,
349 &format!("desc-{name}"),
350 &format!("body-{name}"),
351 plugin,
352 PathBuf::from("/"),
353 PathBuf::from("/SKILL.md"),
354 )
355 }
356
357 #[test]
362 fn hostile_inline_metadata_is_bounded_and_redacted() {
363 let s = LoadedSkill::new_inline(
364 &format!("evil\u{7}name\u{1b}[31m{}", "n".repeat(300)),
365 &format!("desc\r\nwith\u{0}controls{}", "d".repeat(2000)),
366 "SECRET_INLINE_BODY_4242",
367 None,
368 PathBuf::from("/secret/base/dir"),
369 PathBuf::from("/secret/source/SKILL.md"),
370 );
371 let out = LoadSkillTool::format_body(&s).unwrap();
372 let header = out.lines().next().unwrap();
373 assert!(header.starts_with("# Skill: "));
374 assert!(
375 !header.chars().any(char::is_control),
376 "header must be control-free"
377 );
378 assert!(!header.contains('\u{7}') && !header.contains('\u{1b}'));
379 assert!(
380 header.len() < 1300,
381 "header bounded: {} bytes",
382 header.len()
383 );
384 assert!(out.contains("SECRET_INLINE_BODY_4242"));
386
387 let rendered = format!("{s:?}");
389 assert!(!rendered.contains("SECRET_INLINE_BODY_4242"));
390 assert!(!rendered.contains("/secret/"));
391 assert!(!rendered.contains("bytes"), "no body-derived length");
392 assert!(!rendered.contains("23"), "no body length digits (23)");
393 }
394
395 #[test]
396 fn format_body_includes_name_and_description() {
397 let s = LoadedSkill::new_inline(
398 "x",
399 "y",
400 "z",
401 None,
402 PathBuf::from("/"),
403 PathBuf::from("/SKILL.md"),
404 );
405 let out = LoadSkillTool::format_body(&s).unwrap();
406 assert!(out.contains("x"));
407 assert!(out.contains("y"));
408 assert!(out.contains("z"));
409 assert!(out.contains("Follow these guidelines"));
410 }
411
412 #[tokio::test]
413 async fn execute_returns_skill_body_on_unique_match() {
414 use crate::Tool;
415 let reg = Arc::new(crate::skills::registry::CommandRegistry::new(
416 &[],
417 vec![mk("search", Some("p1"))],
418 ));
419 let tool = LoadSkillTool::new(reg);
420 let result = tool
421 .execute(serde_json::json!({"skill": "search"}), test_ctx())
422 .await
423 .unwrap();
424 assert!(result.contains("# Skill: search"));
425 assert!(result.contains("desc-search"));
426 assert!(result.contains("body-search"));
427 }
428
429 #[tokio::test]
430 async fn execute_errors_on_ambiguous() {
431 use crate::Tool;
432 let reg = Arc::new(crate::skills::registry::CommandRegistry::new(
433 &[],
434 vec![mk("search", Some("p1")), mk("search", Some("p2"))],
435 ));
436 let tool = LoadSkillTool::new(reg);
437 let err = tool
438 .execute(serde_json::json!({"skill": "search"}), test_ctx())
439 .await
440 .unwrap_err();
441 let msg = format!("{err}");
442 assert!(msg.contains("ambiguous"));
443 assert!(msg.contains("p1:search"));
444 assert!(msg.contains("p2:search"));
445 }
446
447 #[tokio::test]
448 async fn execute_errors_on_unknown() {
449 use crate::Tool;
450 let reg = Arc::new(crate::skills::registry::CommandRegistry::new(&[], vec![]));
451 let tool = LoadSkillTool::new(reg);
452 let err = tool
453 .execute(serde_json::json!({"skill": "nosuch"}), test_ctx())
454 .await
455 .unwrap_err();
456 assert!(format!("{err}").contains("unknown skill 'nosuch'"));
457 }
458
459 #[tokio::test]
460 async fn execute_errors_on_builtin() {
461 use crate::Tool;
462 let reg = Arc::new(crate::skills::registry::CommandRegistry::new(
464 &["clear"],
465 vec![],
466 ));
467 let tool = LoadSkillTool::new(reg);
468 let err = tool
469 .execute(serde_json::json!({"skill": "clear"}), test_ctx())
470 .await
471 .unwrap_err();
472 assert!(format!("{err}").contains("unknown skill 'clear'"));
473 }
474
475 #[tokio::test]
476 async fn execute_errors_on_missing_skill_param() {
477 use crate::Tool;
478 let reg = Arc::new(crate::skills::registry::CommandRegistry::new(&[], vec![]));
479 let tool = LoadSkillTool::new(reg);
480 let err = tool
481 .execute(serde_json::json!({}), test_ctx())
482 .await
483 .unwrap_err();
484 assert!(format!("{err}").contains("Missing 'skill' parameter"));
485 }
486
487 #[test]
488 fn parameters_schema_is_well_formed() {
489 use crate::Tool;
490 let reg = Arc::new(crate::skills::registry::CommandRegistry::new(&[], vec![]));
491 let tool = LoadSkillTool::new(reg);
492 let schema = tool.parameters();
493 assert_eq!(schema["type"], "object");
494 assert_eq!(schema["properties"]["skill"]["type"], "string");
495 assert_eq!(schema["required"], serde_json::json!(["skill"]));
496 }
497
498 #[test]
503 fn load_skill_catalogs_as_builtin_capability() {
504 use crate::tools::catalog::{CapabilitySource, ToolId, TrustProvenance};
505 let reg = Arc::new(crate::skills::registry::CommandRegistry::new(
506 &[],
507 vec![mk("search", Some("p1"))],
508 ));
509 let mut tools = crate::tools::ToolRegistry::empty();
510 tools.register(Arc::new(LoadSkillTool::new(reg)));
511
512 let record = tools
513 .catalog()
514 .get(&ToolId::builtin("load_skill"))
515 .expect("load_skill cataloged under the builtin namespace");
516 assert_eq!(record.source(), &CapabilitySource::Builtin);
517 assert_eq!(record.provenance(), &TrustProvenance::BuiltinRuntime);
518 assert!(
519 tools
520 .catalog()
521 .get(&ToolId::unclassified("load_skill"))
522 .is_none(),
523 "load_skill must not be cataloged as unknown"
524 );
525 assert!(tools.get("load_skill").is_some());
527 assert_eq!(tools.tools_schema().len(), 1);
528 }
529}