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