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 launch_cancel: None,
324 memory_backend: None,
325 watcher_exit_path: None,
326 tool_register_tx: None,
327 session_manager: None,
328 subagent_registry: None,
329 event_queue: None,
330 delegation_parent: None,
331 codex_parent_plan: None,
332 secret_prompt: None,
333 orchestration: None,
334 tool_activation: None,
335 mcp_leases: None,
336 extension_leases: None,
337 memory_context: None,
338 cwd: None,
339 env: None,
340 env_stripped: Vec::new(),
341 env_warned: Default::default(),
342 },
343 limits: crate::tools::ToolLimits {
344 max_tool_output: 30000,
345 max_tool_buffer: 256 * 1024,
346 bash_timeout: 30,
347 bash_max_timeout: 300,
348 subagent_timeout: 300,
349 },
350 }
351 }
352
353 fn mk(name: &str, plugin: Option<&str>) -> LoadedSkill {
354 LoadedSkill::new_inline(
355 name,
356 &format!("desc-{name}"),
357 &format!("body-{name}"),
358 plugin,
359 PathBuf::from("/"),
360 PathBuf::from("/SKILL.md"),
361 )
362 }
363
364 #[test]
369 fn hostile_inline_metadata_is_bounded_and_redacted() {
370 let s = LoadedSkill::new_inline(
371 &format!("evil\u{7}name\u{1b}[31m{}", "n".repeat(300)),
372 &format!("desc\r\nwith\u{0}controls{}", "d".repeat(2000)),
373 "SECRET_INLINE_BODY_4242",
374 None,
375 PathBuf::from("/secret/base/dir"),
376 PathBuf::from("/secret/source/SKILL.md"),
377 );
378 let out = LoadSkillTool::format_body(&s).unwrap();
379 let header = out.lines().next().unwrap();
380 assert!(header.starts_with("# Skill: "));
381 assert!(
382 !header.chars().any(char::is_control),
383 "header must be control-free"
384 );
385 assert!(!header.contains('\u{7}') && !header.contains('\u{1b}'));
386 assert!(
387 header.len() < 1300,
388 "header bounded: {} bytes",
389 header.len()
390 );
391 assert!(out.contains("SECRET_INLINE_BODY_4242"));
393
394 let rendered = format!("{s:?}");
396 assert!(!rendered.contains("SECRET_INLINE_BODY_4242"));
397 assert!(!rendered.contains("/secret/"));
398 assert!(!rendered.contains("bytes"), "no body-derived length");
399 assert!(!rendered.contains("23"), "no body length digits (23)");
400 }
401
402 #[test]
403 fn format_body_includes_name_and_description() {
404 let s = LoadedSkill::new_inline(
405 "x",
406 "y",
407 "z",
408 None,
409 PathBuf::from("/"),
410 PathBuf::from("/SKILL.md"),
411 );
412 let out = LoadSkillTool::format_body(&s).unwrap();
413 assert!(out.contains("x"));
414 assert!(out.contains("y"));
415 assert!(out.contains("z"));
416 assert!(out.contains("Follow these guidelines"));
417 }
418
419 #[tokio::test]
420 async fn execute_returns_skill_body_on_unique_match() {
421 use crate::Tool;
422 let reg = Arc::new(crate::skills::registry::CommandRegistry::new(
423 &[],
424 vec![mk("search", Some("p1"))],
425 ));
426 let tool = LoadSkillTool::new(reg);
427 let result = tool
428 .execute(serde_json::json!({"skill": "search"}), test_ctx())
429 .await
430 .unwrap();
431 assert!(result.contains("# Skill: search"));
432 assert!(result.contains("desc-search"));
433 assert!(result.contains("body-search"));
434 }
435
436 #[tokio::test]
437 async fn execute_errors_on_ambiguous() {
438 use crate::Tool;
439 let reg = Arc::new(crate::skills::registry::CommandRegistry::new(
440 &[],
441 vec![mk("search", Some("p1")), mk("search", Some("p2"))],
442 ));
443 let tool = LoadSkillTool::new(reg);
444 let err = tool
445 .execute(serde_json::json!({"skill": "search"}), test_ctx())
446 .await
447 .unwrap_err();
448 let msg = format!("{err}");
449 assert!(msg.contains("ambiguous"));
450 assert!(msg.contains("p1:search"));
451 assert!(msg.contains("p2:search"));
452 }
453
454 #[tokio::test]
455 async fn execute_errors_on_unknown() {
456 use crate::Tool;
457 let reg = Arc::new(crate::skills::registry::CommandRegistry::new(&[], vec![]));
458 let tool = LoadSkillTool::new(reg);
459 let err = tool
460 .execute(serde_json::json!({"skill": "nosuch"}), test_ctx())
461 .await
462 .unwrap_err();
463 assert!(format!("{err}").contains("unknown skill 'nosuch'"));
464 }
465
466 #[tokio::test]
467 async fn execute_errors_on_builtin() {
468 use crate::Tool;
469 let reg = Arc::new(crate::skills::registry::CommandRegistry::new(
471 &["clear"],
472 vec![],
473 ));
474 let tool = LoadSkillTool::new(reg);
475 let err = tool
476 .execute(serde_json::json!({"skill": "clear"}), test_ctx())
477 .await
478 .unwrap_err();
479 assert!(format!("{err}").contains("unknown skill 'clear'"));
480 }
481
482 #[tokio::test]
483 async fn execute_errors_on_missing_skill_param() {
484 use crate::Tool;
485 let reg = Arc::new(crate::skills::registry::CommandRegistry::new(&[], vec![]));
486 let tool = LoadSkillTool::new(reg);
487 let err = tool
488 .execute(serde_json::json!({}), test_ctx())
489 .await
490 .unwrap_err();
491 assert!(format!("{err}").contains("Missing 'skill' parameter"));
492 }
493
494 #[test]
495 fn parameters_schema_is_well_formed() {
496 use crate::Tool;
497 let reg = Arc::new(crate::skills::registry::CommandRegistry::new(&[], vec![]));
498 let tool = LoadSkillTool::new(reg);
499 let schema = tool.parameters();
500 assert_eq!(schema["type"], "object");
501 assert_eq!(schema["properties"]["skill"]["type"], "string");
502 assert_eq!(schema["required"], serde_json::json!(["skill"]));
503 }
504
505 #[test]
510 fn load_skill_catalogs_as_builtin_capability() {
511 use crate::tools::catalog::{CapabilitySource, ToolId, TrustProvenance};
512 let reg = Arc::new(crate::skills::registry::CommandRegistry::new(
513 &[],
514 vec![mk("search", Some("p1"))],
515 ));
516 let mut tools = crate::tools::ToolRegistry::empty();
517 tools.register(Arc::new(LoadSkillTool::new(reg)));
518
519 let record = tools
520 .catalog()
521 .get(&ToolId::builtin("load_skill"))
522 .expect("load_skill cataloged under the builtin namespace");
523 assert_eq!(record.source(), &CapabilitySource::Builtin);
524 assert_eq!(record.provenance(), &TrustProvenance::BuiltinRuntime);
525 assert!(
526 tools
527 .catalog()
528 .get(&ToolId::unclassified("load_skill"))
529 .is_none(),
530 "load_skill must not be cataloged as unknown"
531 );
532 assert!(tools.get("load_skill").is_some());
534 assert_eq!(tools.tools_schema().len(), 1);
535 }
536}