1use super::{Capability, CapabilityStatus, SystemPromptContext};
35use crate::skill::{
36 SkillContext, expand_skill_arguments, parse_skill_md, substitute_activation_vars,
37 validate_skill_name,
38};
39use crate::tool_types::{BuiltinTool, DeferrablePolicy, ToolDefinition, ToolHints, ToolPolicy};
40use crate::tools::{Tool, ToolExecutionResult};
41use crate::traits::{SessionFileSystem, ToolContext};
42use crate::typed_id::SessionId;
43use async_trait::async_trait;
44use serde_json::{Value, json};
45use std::path::{Component, Path, PathBuf};
46use std::sync::Arc;
47
48use super::skills::SKILLS_CAPABILITY_ID;
49
50const DEFAULT_WORKSPACE_ROOT: &str = "/.agents/skills";
52const WORKSPACE_PREFIX: &str = "/workspace";
54const MAX_SKILLS_IN_PROMPT: usize = 15;
56const MAX_SKILLS_SCAN_PER_SCOPE: usize = 64;
58const MAX_DESCRIPTION_CHARS: usize = 76;
60const MAX_EXTRA_SKILL_FILES: usize = 64;
62const MAX_SKILL_FILE_BYTES: usize = 1024 * 1024;
64const SKILL_ACTIVATION_KIND: &str = "skill_activation";
66
67fn skill_activation_resource_id(name: &str) -> String {
68 format!("{SKILL_ACTIVATION_KIND}:{name}")
69}
70
71fn truncate_description(s: &str, max_chars: usize) -> String {
72 if s.chars().count() <= max_chars {
73 return s.to_string();
74 }
75 let truncated: String = s.chars().take(max_chars.saturating_sub(1)).collect();
76 format!("{}…", truncated.trim_end())
77}
78
79#[derive(Clone, Debug)]
88pub struct SkillScope {
89 pub label: String,
91 pub vfs_root: String,
93 pub writable: bool,
95}
96
97impl SkillScope {
98 pub fn new(label: impl Into<String>, vfs_root: impl Into<String>, writable: bool) -> Self {
99 Self {
100 label: label.into(),
101 vfs_root: vfs_root.into(),
102 writable,
103 }
104 }
105
106 fn dir_vfs(&self, name: &str) -> String {
108 format!("{}/{name}", self.vfs_root.trim_end_matches('/'))
109 }
110
111 fn skill_md_vfs(&self, name: &str) -> String {
113 format!("{}/SKILL.md", self.dir_vfs(name))
114 }
115}
116
117pub trait SkillDirResolver: Send + Sync {
126 fn skill_dir(&self, scope: &SkillScope, name: &str) -> String;
128
129 fn display_dir(&self, scope: &SkillScope, name: &str) -> String {
133 let vfs = scope.dir_vfs(name);
134 if vfs.starts_with('/') {
135 format!("{WORKSPACE_PREFIX}{vfs}")
136 } else {
137 format!("{WORKSPACE_PREFIX}/{vfs}")
138 }
139 }
140}
141
142pub struct VfsSkillDirResolver;
144
145impl SkillDirResolver for VfsSkillDirResolver {
146 fn skill_dir(&self, scope: &SkillScope, name: &str) -> String {
147 scope.dir_vfs(name)
148 }
149}
150
151#[derive(Clone)]
153pub struct SkillsConfig {
154 pub scopes: Vec<SkillScope>,
157 pub resolver: Arc<dyn SkillDirResolver>,
159 pub manage_tools: bool,
162}
163
164impl Default for SkillsConfig {
165 fn default() -> Self {
166 Self {
167 scopes: vec![SkillScope::new("workspace", DEFAULT_WORKSPACE_ROOT, true)],
168 resolver: Arc::new(VfsSkillDirResolver),
169 manage_tools: false,
170 }
171 }
172}
173
174impl SkillsConfig {
175 fn writable_scope(&self, label: Option<&str>) -> Option<&SkillScope> {
178 self.scopes
179 .iter()
180 .find(|s| s.writable && label.is_none_or(|l| l == s.label))
181 }
182
183 fn scope_by_label(&self, label: &str) -> Option<&SkillScope> {
184 self.scopes.iter().find(|s| s.label == label)
185 }
186}
187
188const SCOPED_SKILLS_SYSTEM_PROMPT: &str = "Skills are reusable instruction packs \
193discovered across one or more scopes (e.g. your workspace, global config, and ones \
194bundled with the agent). Use `list_skills` to see what's available and `activate_skill` \
195(by name) to load one. Only activate skills relevant to the current task.";
196
197pub struct ScopedSkillsCapability {
201 config: Arc<SkillsConfig>,
202}
203
204impl ScopedSkillsCapability {
205 pub fn new(config: SkillsConfig) -> Self {
206 Self {
207 config: Arc::new(config),
208 }
209 }
210}
211
212#[async_trait]
213impl Capability for ScopedSkillsCapability {
214 fn id(&self) -> &str {
215 SKILLS_CAPABILITY_ID
216 }
217
218 fn name(&self) -> &str {
219 "Agent Skills"
220 }
221
222 fn description(&self) -> &str {
223 "Discover, activate, and manage skills across multiple scopes (workspace, \
224 global config, and bundled), all through the session filesystem."
225 }
226
227 fn status(&self) -> CapabilityStatus {
228 CapabilityStatus::Available
229 }
230
231 fn icon(&self) -> Option<&str> {
232 Some("wand")
233 }
234
235 fn category(&self) -> Option<&str> {
236 Some("Skills")
237 }
238
239 fn system_prompt_addition(&self) -> Option<&str> {
240 Some(SCOPED_SKILLS_SYSTEM_PROMPT)
241 }
242
243 async fn system_prompt_contribution(&self, ctx: &SystemPromptContext) -> Option<String> {
244 let mut prompt = String::from(SCOPED_SKILLS_SYSTEM_PROMPT);
245
246 if let Some(file_store) = ctx.file_store.as_ref() {
247 let discovered = discover_skills(
248 &self.config,
249 file_store.as_ref(),
250 ctx.session_id,
251 Some(MAX_SKILLS_SCAN_PER_SCOPE),
252 )
253 .await;
254 let visible: Vec<&DiscoveredSkill> = discovered
255 .iter()
256 .filter(|s| s.error.is_none() && !s.disable_model_invocation)
257 .collect();
258
259 if !visible.is_empty() {
260 prompt.push_str("\n\nAvailable skills:\n");
261 for skill in visible.iter().take(MAX_SKILLS_IN_PROMPT) {
262 let desc = truncate_description(&skill.description, MAX_DESCRIPTION_CHARS);
263 let invocable = if skill.user_invocable {
264 format!(" (/{})", skill.name)
265 } else {
266 String::new()
267 };
268 prompt.push_str(&format!(
269 "- **{}** [{}]: {}{}\n",
270 skill.name, skill.scope_label, desc, invocable
271 ));
272 }
273 if visible.len() > MAX_SKILLS_IN_PROMPT {
274 prompt.push_str(&format!(
275 "\n({} more — use `list_skills` to see all)\n",
276 visible.len() - MAX_SKILLS_IN_PROMPT
277 ));
278 }
279 }
280 }
281
282 Some(format!(
283 "<capability id=\"{}\">\n{}\n</capability>",
284 self.id(),
285 prompt
286 ))
287 }
288
289 fn tools(&self) -> Vec<Box<dyn Tool>> {
290 let mut tools: Vec<Box<dyn Tool>> = vec![
291 Box::new(ListSkillsTool {
292 config: self.config.clone(),
293 }),
294 Box::new(ActivateSkillTool {
295 config: self.config.clone(),
296 }),
297 ];
298 if self.config.manage_tools {
299 tools.push(Box::new(ReadSkillTool {
300 config: self.config.clone(),
301 }));
302 tools.push(Box::new(WriteSkillTool {
303 config: self.config.clone(),
304 }));
305 }
306 tools
307 }
308
309 fn tool_definitions(&self) -> Vec<ToolDefinition> {
310 let readonly = ToolHints::default()
311 .with_readonly(true)
312 .with_idempotent(true);
313 let mut defs = vec![
314 ToolDefinition::Builtin(BuiltinTool {
315 name: "list_skills".to_string(),
316 display_name: Some("List Skills".to_string()),
317 description: "Discover available skills across all configured scopes.".to_string(),
318 parameters: list_skills_schema(),
319 policy: ToolPolicy::Auto,
320 category: None,
321 deferrable: DeferrablePolicy::default(),
322 hints: readonly.clone(),
323 full_parameters: None,
324 }),
325 ToolDefinition::Builtin(BuiltinTool {
326 name: "activate_skill".to_string(),
327 display_name: Some("Activate Skill".to_string()),
328 description: "Activate a skill by name to load its full instructions.".to_string(),
329 parameters: activate_skill_schema(),
330 policy: ToolPolicy::Auto,
331 category: None,
332 deferrable: DeferrablePolicy::default(),
333 hints: readonly.clone(),
334 full_parameters: None,
335 }),
336 ];
337 if self.config.manage_tools {
338 defs.push(ToolDefinition::Builtin(BuiltinTool {
339 name: "read_skill".to_string(),
340 display_name: Some("Read Skill".to_string()),
341 description: "Read one installed skill's SKILL.md and file manifest.".to_string(),
342 parameters: read_skill_schema(),
343 policy: ToolPolicy::Auto,
344 category: None,
345 deferrable: DeferrablePolicy::default(),
346 hints: readonly,
347 full_parameters: None,
348 }));
349 defs.push(ToolDefinition::Builtin(BuiltinTool {
350 name: "write_skill".to_string(),
351 display_name: Some("Write Skill".to_string()),
352 description: "Install or update a skill in a writable scope.".to_string(),
353 parameters: write_skill_schema(),
354 policy: ToolPolicy::Auto,
355 category: None,
356 deferrable: DeferrablePolicy::default(),
357 hints: ToolHints::default().with_idempotent(true),
358 full_parameters: None,
359 }));
360 }
361 defs
362 }
363
364 fn dependencies(&self) -> Vec<&'static str> {
365 vec!["session_file_system"]
366 }
367}
368
369struct DiscoveredSkill {
375 scope_label: String,
376 scope_index: usize,
377 dir_name: String,
378 name: String,
379 description: String,
380 version: String,
381 user_invocable: bool,
382 disable_model_invocation: bool,
383 error: Option<String>,
384}
385
386async fn discover_skills(
393 config: &SkillsConfig,
394 fs: &dyn SessionFileSystem,
395 session_id: SessionId,
396 scan_limit: Option<usize>,
397) -> Vec<DiscoveredSkill> {
398 let mut seen = std::collections::HashSet::new();
399 let mut out = Vec::new();
400 for (scope_index, scope) in config.scopes.iter().enumerate() {
401 let entries = match fs.list_directory(session_id, &scope.vfs_root).await {
402 Ok(entries) => entries,
403 Err(_) => continue, };
405 let mut dirs: Vec<_> = entries.into_iter().filter(|e| e.is_directory).collect();
406 dirs.sort_by(|a, b| a.name.cmp(&b.name));
407 if let Some(limit) = scan_limit {
408 dirs.truncate(limit);
409 }
410 for entry in dirs {
411 if !seen.insert(entry.name.clone()) {
412 continue;
413 }
414 let md_path = scope.skill_md_vfs(&entry.name);
415 let content = match fs.read_file(session_id, &md_path).await {
416 Ok(Some(file)) => file.content.unwrap_or_default(),
417 _ => continue,
418 };
419 let discovered = match parse_skill_md(&content) {
420 Ok(parsed) => DiscoveredSkill {
421 scope_label: scope.label.clone(),
422 scope_index,
423 dir_name: entry.name.clone(),
424 name: parsed.name,
425 description: parsed.description,
426 version: parsed.version,
427 user_invocable: parsed.user_invocable,
428 disable_model_invocation: parsed.disable_model_invocation,
429 error: None,
430 },
431 Err(errors) => DiscoveredSkill {
432 scope_label: scope.label.clone(),
433 scope_index,
434 name: entry.name.clone(),
435 description: String::new(),
436 version: String::new(),
437 user_invocable: false,
438 disable_model_invocation: false,
439 error: Some(format!("Invalid SKILL.md: {}", errors.join(", "))),
440 dir_name: entry.name.clone(),
441 },
442 };
443 out.push(discovered);
444 }
445 }
446 out
447}
448
449async fn locate_skill(
452 config: &SkillsConfig,
453 fs: &dyn SessionFileSystem,
454 session_id: SessionId,
455 name: &str,
456) -> Option<(usize, String)> {
457 for (idx, scope) in config.scopes.iter().enumerate() {
458 if let Ok(Some(file)) = fs.read_file(session_id, &scope.skill_md_vfs(name)).await {
459 return Some((idx, file.content.unwrap_or_default()));
460 }
461 }
462 None
463}
464
465fn list_skills_schema() -> Value {
470 json!({ "type": "object", "properties": {}, "required": [] })
471}
472
473struct ListSkillsTool {
474 config: Arc<SkillsConfig>,
475}
476
477#[async_trait]
478impl Tool for ListSkillsTool {
479 fn narrate(
480 &self,
481 tool_call: &crate::tool_types::ToolCall,
482 phase: crate::tool_narration::ToolNarrationPhase,
483 locale: Option<&str>,
484 _ctx: crate::tool_narration::ToolNarrationContext<'_>,
485 ) -> Option<String> {
486 crate::tool_narration::narrate_skill(&tool_call.name, &tool_call.arguments, phase, locale)
487 }
488
489 fn name(&self) -> &str {
490 "list_skills"
491 }
492
493 fn display_name(&self) -> Option<&str> {
494 Some("List Skills")
495 }
496
497 fn description(&self) -> &str {
498 "Discover available skills across all configured scopes. Returns each \
499 skill's name, description, and scope."
500 }
501
502 fn parameters_schema(&self) -> Value {
503 list_skills_schema()
504 }
505
506 fn hints(&self) -> ToolHints {
507 ToolHints::default()
508 .with_readonly(true)
509 .with_idempotent(true)
510 }
511
512 fn requires_context(&self) -> bool {
513 true
514 }
515
516 async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
517 ToolExecutionResult::tool_error("list_skills requires session context.")
518 }
519
520 async fn execute_with_context(
521 &self,
522 _arguments: Value,
523 ctx: &ToolContext,
524 ) -> ToolExecutionResult {
525 let Some(fs) = &ctx.file_store else {
526 return ToolExecutionResult::tool_error(
527 "File store not available. The session_file_system capability is required.",
528 );
529 };
530 let discovered = discover_skills(&self.config, fs.as_ref(), ctx.session_id, None).await;
533 let skills: Vec<Value> = discovered
534 .into_iter()
535 .map(|s| {
536 let scope = &self.config.scopes[s.scope_index];
537 let display = self.config.resolver.display_dir(scope, &s.dir_name);
538 match s.error {
539 Some(error) => json!({
540 "name": s.dir_name,
541 "scope": s.scope_label,
542 "path": format!("{display}/SKILL.md"),
543 "error": error,
544 }),
545 None => json!({
546 "name": s.name,
547 "description": s.description,
548 "scope": s.scope_label,
549 "version": s.version,
550 "user_invocable": s.user_invocable,
551 "disable_model_invocation": s.disable_model_invocation,
552 "path": format!("{display}/SKILL.md"),
553 }),
554 }
555 })
556 .collect();
557 ToolExecutionResult::success(json!({ "count": skills.len(), "skills": skills }))
558 }
559}
560
561fn activate_skill_schema() -> Value {
566 json!({
567 "type": "object",
568 "properties": {
569 "name": { "type": "string", "description": "The skill directory name (e.g., 'pdf-processing')" },
570 "arguments": { "type": "string", "description": "Optional arguments to pass to the skill for $ARGUMENTS substitution" }
571 },
572 "required": ["name"]
573 })
574}
575
576struct ActivateSkillTool {
577 config: Arc<SkillsConfig>,
578}
579
580#[async_trait]
581impl Tool for ActivateSkillTool {
582 fn narrate(
583 &self,
584 tool_call: &crate::tool_types::ToolCall,
585 phase: crate::tool_narration::ToolNarrationPhase,
586 locale: Option<&str>,
587 _ctx: crate::tool_narration::ToolNarrationContext<'_>,
588 ) -> Option<String> {
589 crate::tool_narration::narrate_skill(&tool_call.name, &tool_call.arguments, phase, locale)
590 }
591
592 fn name(&self) -> &str {
593 "activate_skill"
594 }
595
596 fn display_name(&self) -> Option<&str> {
597 Some("Activate Skill")
598 }
599
600 fn description(&self) -> &str {
601 "Activate a skill by name to load its full instructions. Skills resolve \
602 across the configured scopes (earlier scopes win on conflict)."
603 }
604
605 fn parameters_schema(&self) -> Value {
606 activate_skill_schema()
607 }
608
609 fn hints(&self) -> ToolHints {
610 ToolHints::default()
611 .with_readonly(true)
612 .with_idempotent(true)
613 }
614
615 fn requires_context(&self) -> bool {
616 true
617 }
618
619 async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
620 ToolExecutionResult::tool_error("activate_skill requires session context.")
621 }
622
623 async fn execute_with_context(
624 &self,
625 arguments: Value,
626 ctx: &ToolContext,
627 ) -> ToolExecutionResult {
628 let Some(name) = arguments.get("name").and_then(|v| v.as_str()) else {
629 return ToolExecutionResult::tool_error(
630 "Missing required parameter: name. Call as \
631 activate_skill({\"name\":\"ship\"}). Use list_skills to see valid skill names.",
632 );
633 };
634 let skill_args = arguments
635 .get("arguments")
636 .and_then(|v| v.as_str())
637 .unwrap_or("");
638
639 if let Err(msg) = validate_requested_skill_name(name) {
640 return ToolExecutionResult::tool_error(msg);
641 }
642
643 if let Some(registry) = &ctx.session_resource_registry {
646 let resource_id = skill_activation_resource_id(name);
647 if let Ok(Some(entry)) = registry.get(ctx.session_id, &resource_id).await
648 && entry.status == crate::session_resource::SessionResourceStatus::Active
649 && entry.kind == SKILL_ACTIVATION_KIND
650 && let Value::Object(mut map) = entry.metadata
651 {
652 map.insert("already_active".to_string(), Value::Bool(true));
653 return ToolExecutionResult::success(Value::Object(map));
654 }
655 }
656
657 let Some(fs) = &ctx.file_store else {
658 return ToolExecutionResult::tool_error(
659 "File store not available. The session_file_system capability is required.",
660 );
661 };
662
663 let Some((scope_index, content)) =
664 locate_skill(&self.config, fs.as_ref(), ctx.session_id, name).await
665 else {
666 return ToolExecutionResult::tool_error(format!(
667 "Skill '{name}' not found in any scope. Use list_skills to see what's available."
668 ));
669 };
670 let scope = &self.config.scopes[scope_index];
671
672 match parse_skill_md(&content) {
673 Ok(parsed) => {
674 let expanded = expand_skill_arguments(&parsed.instructions, skill_args);
678 let skill_dir = self.config.resolver.skill_dir(scope, name);
679 let substituted =
680 substitute_activation_vars(&expanded, &ctx.session_id.to_string(), &skill_dir);
681 let instructions = format!(
682 "<skill name=\"{}\">\n{}\n</skill>",
683 parsed.name, substituted
684 );
685
686 let mut result = json!({
687 "skill": parsed.name,
688 "scope": scope.label,
689 "description": parsed.description,
690 "instructions": instructions,
691 });
692 if parsed.context == SkillContext::Fork {
693 result["context"] = json!("fork");
694 result["agent"] = json!(parsed.agent.as_deref().unwrap_or("general-purpose"));
695 if let Some(model) = &parsed.model {
696 result["model"] = json!(model);
697 }
698 }
699
700 if let Some(registry) = &ctx.session_resource_registry {
701 let entry = crate::session_resource::RegisterSessionResource {
702 session_id: ctx.session_id,
703 resource_id: skill_activation_resource_id(name),
704 kind: SKILL_ACTIVATION_KIND.to_string(),
705 display_name: format!("skill:{name}"),
706 status: crate::session_resource::SessionResourceStatus::Active,
707 metadata: result.clone(),
708 };
709 if let Err(e) = registry.register(entry).await {
710 tracing::warn!(error = %e, skill = %parsed.name, "activate_skill: failed to record activation");
711 }
712 }
713
714 ToolExecutionResult::success(result)
715 }
716 Err(errors) => ToolExecutionResult::tool_error(format!(
717 "Invalid SKILL.md for '{name}': {}",
718 errors.join(", ")
719 )),
720 }
721 }
722}
723
724fn read_skill_schema() -> Value {
729 json!({
730 "type": "object",
731 "properties": {
732 "name": { "type": "string", "description": "The skill directory name." },
733 "scope": { "type": "string", "description": "Optional scope label to read from. Omit to use normal precedence." }
734 },
735 "required": ["name"],
736 "additionalProperties": false
737 })
738}
739
740struct ReadSkillTool {
741 config: Arc<SkillsConfig>,
742}
743
744#[async_trait]
745impl Tool for ReadSkillTool {
746 fn narrate(
747 &self,
748 tool_call: &crate::tool_types::ToolCall,
749 phase: crate::tool_narration::ToolNarrationPhase,
750 locale: Option<&str>,
751 _ctx: crate::tool_narration::ToolNarrationContext<'_>,
752 ) -> Option<String> {
753 crate::tool_narration::narrate_skill(&tool_call.name, &tool_call.arguments, phase, locale)
754 }
755
756 fn name(&self) -> &str {
757 "read_skill"
758 }
759
760 fn display_name(&self) -> Option<&str> {
761 Some("Read Skill")
762 }
763
764 fn description(&self) -> &str {
765 "Read one installed skill's SKILL.md and file manifest. Use before \
766 modifying or upgrading a skill."
767 }
768
769 fn parameters_schema(&self) -> Value {
770 read_skill_schema()
771 }
772
773 fn hints(&self) -> ToolHints {
774 ToolHints::default()
775 .with_readonly(true)
776 .with_idempotent(true)
777 }
778
779 fn requires_context(&self) -> bool {
780 true
781 }
782
783 async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
784 ToolExecutionResult::tool_error("read_skill requires session context.")
785 }
786
787 async fn execute_with_context(
788 &self,
789 arguments: Value,
790 ctx: &ToolContext,
791 ) -> ToolExecutionResult {
792 let Some(name) = arguments.get("name").and_then(|v| v.as_str()) else {
793 return ToolExecutionResult::tool_error("Missing required parameter: name");
794 };
795 if let Err(msg) = validate_requested_skill_name(name) {
796 return ToolExecutionResult::tool_error(msg);
797 }
798 let Some(fs) = &ctx.file_store else {
799 return ToolExecutionResult::tool_error(
800 "File store not available. The session_file_system capability is required.",
801 );
802 };
803
804 let located = match arguments.get("scope").and_then(|v| v.as_str()) {
805 Some(label) => {
806 let Some(scope) = self.config.scope_by_label(label) else {
807 return ToolExecutionResult::tool_error(format!("Unknown scope '{label}'"));
808 };
809 let idx = self
810 .config
811 .scopes
812 .iter()
813 .position(|s| s.label == scope.label)
814 .unwrap();
815 match fs
816 .read_file(ctx.session_id, &scope.skill_md_vfs(name))
817 .await
818 {
819 Ok(Some(file)) => Some((idx, file.content.unwrap_or_default())),
820 _ => None,
821 }
822 }
823 None => locate_skill(&self.config, fs.as_ref(), ctx.session_id, name).await,
824 };
825
826 let Some((scope_index, content)) = located else {
827 return ToolExecutionResult::tool_error(format!(
828 "Skill '{name}' not found. Use list_skills to see what's available."
829 ));
830 };
831 let scope = &self.config.scopes[scope_index];
832 let display = self.config.resolver.display_dir(scope, name);
833 let files = skill_file_manifest(fs.as_ref(), ctx.session_id, &scope.dir_vfs(name)).await;
834
835 ToolExecutionResult::success(json!({
836 "name": name,
837 "scope": scope.label,
838 "path": format!("{display}/SKILL.md"),
839 "skill_md": content,
840 "files": files,
841 }))
842 }
843}
844
845async fn skill_file_manifest(
848 fs: &dyn SessionFileSystem,
849 session_id: SessionId,
850 dir_vfs: &str,
851) -> Vec<String> {
852 let mut out = Vec::new();
853 let mut queue = vec![dir_vfs.to_string()];
854 while let Some(dir) = queue.pop() {
855 let Ok(entries) = fs.list_directory(session_id, &dir).await else {
856 continue;
857 };
858 for entry in entries {
859 if entry.is_directory {
860 queue.push(entry.path.clone());
861 } else {
862 if let Some(rel) = entry.path.strip_prefix(&format!("{dir_vfs}/"))
863 && rel != "SKILL.md"
864 {
865 out.push(rel.to_string());
866 }
867 if out.len() >= MAX_EXTRA_SKILL_FILES {
868 out.sort();
869 return out;
870 }
871 }
872 }
873 }
874 out.sort();
875 out
876}
877
878fn write_skill_schema() -> Value {
883 json!({
884 "type": "object",
885 "properties": {
886 "scope": { "type": "string", "description": "Writable scope label to install into (e.g. 'workspace' or 'global'). Omit to use the first writable scope." },
887 "name": { "type": "string", "description": "Skill directory name. Must match the SKILL.md frontmatter name." },
888 "skill_md": { "type": "string", "description": "The complete SKILL.md contents, including frontmatter." },
889 "files": {
890 "type": "object",
891 "description": "Optional bundled files keyed by relative path. Do not include SKILL.md here.",
892 "additionalProperties": { "type": "string" }
893 },
894 "overwrite": { "type": "boolean", "description": "Whether to replace an existing skill. Defaults to true." }
895 },
896 "required": ["name", "skill_md"],
897 "additionalProperties": false
898 })
899}
900
901struct WriteSkillTool {
902 config: Arc<SkillsConfig>,
903}
904
905#[async_trait]
906impl Tool for WriteSkillTool {
907 fn name(&self) -> &str {
908 "write_skill"
909 }
910
911 fn display_name(&self) -> Option<&str> {
912 Some("Write Skill")
913 }
914
915 fn description(&self) -> &str {
916 "Install or update a skill in a writable scope. Provide the full SKILL.md \
917 and optional bundled files. Recreates skills from a registry/GitHub \
918 source without requiring an installer."
919 }
920
921 fn parameters_schema(&self) -> Value {
922 write_skill_schema()
923 }
924
925 fn hints(&self) -> ToolHints {
926 ToolHints::default().with_idempotent(true)
927 }
928
929 fn requires_context(&self) -> bool {
930 true
931 }
932
933 async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
934 ToolExecutionResult::tool_error("write_skill requires session context.")
935 }
936
937 async fn execute_with_context(
938 &self,
939 arguments: Value,
940 ctx: &ToolContext,
941 ) -> ToolExecutionResult {
942 let Some(name) = arguments.get("name").and_then(|v| v.as_str()) else {
943 return ToolExecutionResult::tool_error("'name' is required");
944 };
945 if let Err(msg) = validate_requested_skill_name(name) {
946 return ToolExecutionResult::tool_error(msg);
947 }
948 let Some(skill_md) = arguments.get("skill_md").and_then(|v| v.as_str()) else {
949 return ToolExecutionResult::tool_error("'skill_md' is required");
950 };
951 if skill_md.len() > MAX_SKILL_FILE_BYTES {
952 return ToolExecutionResult::tool_error(format!(
953 "'skill_md' exceeds the {MAX_SKILL_FILE_BYTES} byte limit"
954 ));
955 }
956
957 let parsed = match parse_skill_md(skill_md) {
958 Ok(parsed) => parsed,
959 Err(errors) => {
960 return ToolExecutionResult::tool_error(format!(
961 "Invalid SKILL.md: {}",
962 errors.join(", ")
963 ));
964 }
965 };
966 if parsed.name != name {
967 return ToolExecutionResult::tool_error(format!(
968 "'name' must match SKILL.md frontmatter name (got '{}')",
969 parsed.name
970 ));
971 }
972
973 let requested_label = arguments.get("scope").and_then(|v| v.as_str());
974 if let Some(label) = requested_label
977 && let Some(scope) = self.config.scope_by_label(label)
978 && !scope.writable
979 {
980 return ToolExecutionResult::tool_error(format!(
981 "Scope '{label}' is read-only; choose a writable scope"
982 ));
983 }
984 let Some(scope) = self.config.writable_scope(requested_label) else {
985 return ToolExecutionResult::tool_error(match requested_label {
986 Some(label) => format!("No writable scope named '{label}'"),
987 None => "No writable skill scope is configured".to_string(),
988 });
989 };
990
991 let overwrite = arguments
992 .get("overwrite")
993 .and_then(Value::as_bool)
994 .unwrap_or(true);
995 let files = match collect_extra_files(arguments.get("files")) {
996 Ok(files) => files,
997 Err(e) => return ToolExecutionResult::tool_error(e),
998 };
999
1000 let fs = match &ctx.file_store {
1001 Some(fs) => fs,
1002 None => {
1003 return ToolExecutionResult::tool_error(
1004 "File store not available. The session_file_system capability is required.",
1005 );
1006 }
1007 };
1008
1009 let md_path = scope.skill_md_vfs(name);
1010 if !overwrite && matches!(fs.read_file(ctx.session_id, &md_path).await, Ok(Some(_))) {
1011 return ToolExecutionResult::tool_error(format!(
1012 "Skill '{name}' already exists in '{}' scope; pass overwrite=true to update it",
1013 scope.label
1014 ));
1015 }
1016
1017 if let Err(e) = fs
1018 .write_file(ctx.session_id, &md_path, skill_md, "text")
1019 .await
1020 {
1021 return ToolExecutionResult::tool_error(format!("could not write SKILL.md: {e}"));
1022 }
1023 for (rel, content) in &files {
1024 let target = format!("{}/{}", scope.dir_vfs(name), rel.display());
1025 if let Err(e) = fs
1026 .write_file(ctx.session_id, &target, content, "text")
1027 .await
1028 {
1029 return ToolExecutionResult::tool_error(format!(
1030 "could not write '{}': {e}",
1031 rel.display()
1032 ));
1033 }
1034 }
1035
1036 let display = self.config.resolver.display_dir(scope, name);
1037 ToolExecutionResult::success(json!({
1038 "ok": true,
1039 "name": name,
1040 "scope": scope.label,
1041 "path": format!("{display}/SKILL.md"),
1042 "files_written": files.len() + 1,
1043 "message": "skill written; discoverable immediately via list_skills and activate_skill",
1044 }))
1045 }
1046}
1047
1048fn validate_requested_skill_name(name: &str) -> Result<(), String> {
1053 if name.contains("..") || name.contains('/') || name.contains('\\') {
1054 return Err(
1055 "Invalid skill name. Must be a simple directory name without path separators."
1056 .to_string(),
1057 );
1058 }
1059 validate_skill_name(name)
1060 .map_err(|errors| format!("Invalid skill name '{name}': {}", errors.join(", ")))
1061}
1062
1063fn collect_extra_files(value: Option<&Value>) -> Result<Vec<(PathBuf, String)>, String> {
1064 let Some(value) = value else {
1065 return Ok(Vec::new());
1066 };
1067 let Some(map) = value.as_object() else {
1068 return Err("'files' must be an object of relative path to string content".to_string());
1069 };
1070 if map.len() > MAX_EXTRA_SKILL_FILES {
1071 return Err(format!(
1072 "'files' contains too many entries (max {MAX_EXTRA_SKILL_FILES})"
1073 ));
1074 }
1075 let mut out = Vec::with_capacity(map.len());
1076 for (raw_path, value) in map {
1077 let Some(content) = value.as_str() else {
1078 return Err(format!("file '{raw_path}' content must be a string"));
1079 };
1080 if content.len() > MAX_SKILL_FILE_BYTES {
1081 return Err(format!(
1082 "file '{raw_path}' exceeds the {MAX_SKILL_FILE_BYTES} byte limit"
1083 ));
1084 }
1085 out.push((validate_skill_file_path(raw_path)?, content.to_string()));
1086 }
1087 Ok(out)
1088}
1089
1090fn validate_skill_file_path(raw: &str) -> Result<PathBuf, String> {
1092 if raw.trim().is_empty() || raw.contains('\\') {
1093 return Err(format!("invalid skill file path '{raw}'"));
1094 }
1095 let path = PathBuf::from(raw);
1096 if path == Path::new("SKILL.md") || path.is_absolute() {
1097 return Err(format!("invalid skill file path '{raw}'"));
1098 }
1099 for component in path.components() {
1100 if !matches!(component, Component::Normal(_)) {
1101 return Err(format!("invalid skill file path '{raw}'"));
1102 }
1103 }
1104 Ok(path)
1105}
1106
1107#[cfg(test)]
1108mod tests;