Skip to main content

everruns_core/capabilities/
skills_scoped.rs

1// Configurable, multi-scope Skills Capability (built on the session VFS)
2//
3// `SkillsCapability` (see `skills.rs`) scans a single VFS path
4// (`/.agents/skills`) and exposes exactly `list_skills` + `activate_skill`.
5// That is the right default for the hosted server, where the only skill
6// source is the session virtual filesystem and extra skills arrive via the
7// mount mechanism (`attach_skill.rs`).
8//
9// Embedders that want **multiple labeled skill sources** — for example a
10// terminal coding agent with workspace, per-user "global", and binary-bundled
11// "system" scopes — need three things this default does not provide: merged
12// discovery across scopes with precedence, the ability to install/update a
13// skill (`write_skill`), and a way to render `${SKILL_DIR}` in whatever
14// namespace the embedder's shell actually runs in. `ScopedSkillsCapability`
15// adds exactly those, while reusing the upstream `crate::skill` parser,
16// validator, and substitution verbatim.
17//
18// SECURITY — sources are VFS-bound by construction. A scope is a *labeled VFS
19// root* (e.g. `/.agents/skills`), never a host filesystem path. Every read,
20// write, and discovery call goes through the injected `SessionFileSystem`
21// (`ToolContext::file_store`), so the capability can never be pointed at the
22// worker host: it can only ever see what that file store exposes. The embedder
23// decides how each VFS root maps to storage (a sandboxed overlay on the server;
24// a real on-disk directory in a single-user CLI), and that mapping lives behind
25// the file store — it is not a configuration knob on this capability. There is
26// deliberately no API here that accepts a host path.
27//
28// COMMAND-SUBSTITUTION GATE — unchanged from `skills.rs`. `!`cmd`` placeholders
29// are never expanded; activating a skill never spawns a shell. Re-enabling that
30// is gated upstream behind a non-user-spoofable provenance signal and a
31// session-sandbox-backed executor (EVE-388 / TM-TOOL-020) and is intentionally
32// out of scope here.
33
34use 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
50/// Default VFS root for the workspace scope (matches `skills.rs`).
51const DEFAULT_WORKSPACE_ROOT: &str = "/.agents/skills";
52/// Workspace prefix for agent-facing display paths.
53const WORKSPACE_PREFIX: &str = "/workspace";
54/// Max skills listed in the system prompt (the rest reachable via `list_skills`).
55const MAX_SKILLS_IN_PROMPT: usize = 15;
56/// Max skill directories scanned per scope when building the system prompt.
57const MAX_SKILLS_SCAN_PER_SCOPE: usize = 64;
58/// Max description length in the system-prompt listing (truncated with "…").
59const MAX_DESCRIPTION_CHARS: usize = 76;
60/// Defensive cap for extra files installed alongside a skill.
61const MAX_EXTRA_SKILL_FILES: usize = 64;
62/// Defensive cap for one skill file body.
63const MAX_SKILL_FILE_BYTES: usize = 1024 * 1024;
64/// Registry kind used to make `activate_skill` idempotent within a session.
65const 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// ============================================================================
80// Scopes, resolver, and configuration
81// ============================================================================
82
83/// One labeled skill source: a VFS root plus whether the agent may write to it.
84///
85/// `vfs_root` is always a session-VFS path (e.g. `/.agents/skills`). It is never
86/// a host filesystem path — see the module security note.
87#[derive(Clone, Debug)]
88pub struct SkillScope {
89    /// Stable label surfaced to the model (e.g. `workspace`, `global`, `system`).
90    pub label: String,
91    /// VFS root scanned for `<name>/SKILL.md` skill directories.
92    pub vfs_root: String,
93    /// Whether `write_skill` may install/update skills in this scope.
94    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    /// The VFS path of a skill directory under this scope.
107    fn dir_vfs(&self, name: &str) -> String {
108        format!("{}/{name}", self.vfs_root.trim_end_matches('/'))
109    }
110
111    /// The VFS path of a skill's `SKILL.md` under this scope.
112    fn skill_md_vfs(&self, name: &str) -> String {
113        format!("{}/SKILL.md", self.dir_vfs(name))
114    }
115}
116
117/// Resolves the on-execution path of a skill directory.
118///
119/// `${SKILL_DIR}` (and the agent-facing display path) must be valid in the
120/// namespace where the skill's commands and bundled-file reads actually run.
121/// The default ([`VfsSkillDirResolver`]) keeps everything in the session VFS,
122/// which is correct when the shell shares that namespace (the hosted server's
123/// session sandbox). An embedder whose shell runs elsewhere — e.g. a CLI whose
124/// `bash` runs on the host — implements this to return a path valid there.
125pub trait SkillDirResolver: Send + Sync {
126    /// Value substituted for `${SKILL_DIR}` during activation.
127    fn skill_dir(&self, scope: &SkillScope, name: &str) -> String;
128
129    /// Agent-facing display path for the skill directory (shown in
130    /// `list_skills` / `read_skill`). Defaults to the workspace-prefixed VFS
131    /// path, matching the `file_system` capability's display convention.
132    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
142/// Default resolver: `${SKILL_DIR}` and the display path stay in the VFS.
143pub 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/// Configuration for [`ScopedSkillsCapability`].
152#[derive(Clone)]
153pub struct SkillsConfig {
154    /// Skill scopes in precedence order, highest first. A skill directory name
155    /// found in an earlier scope shadows the same name in a later one.
156    pub scopes: Vec<SkillScope>,
157    /// Resolver for `${SKILL_DIR}` and display paths.
158    pub resolver: Arc<dyn SkillDirResolver>,
159    /// When `true`, expose the `read_skill` and `write_skill` management tools.
160    /// Off by default so the management surface is strictly opt-in.
161    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    /// Locate the first writable scope matching `label` (or any writable scope
176    /// when `label` is `None`).
177    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
188// ============================================================================
189// Capability
190// ============================================================================
191
192const 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
197/// Configurable, multi-scope skills capability. Discovers, activates, and
198/// (optionally) manages skills across the configured scopes, entirely through
199/// the session `SessionFileSystem`.
200pub 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
369// ============================================================================
370// Discovery
371// ============================================================================
372
373/// One discovered skill after parsing its `SKILL.md`.
374struct 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
386/// Discover every unique skill across scopes, in precedence order. The first
387/// occurrence of a directory name wins; later (farther-scope) duplicates drop.
388/// `scan_limit` bounds the number of skill directories read per scope. It is
389/// `Some(MAX_SKILLS_SCAN_PER_SCOPE)` only when building the system prompt (to
390/// bound per-turn reads); `list_skills` passes `None` so the full set is
391/// returned, matching the existing `SkillsCapability` behavior.
392async 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, // absent scope is silently empty
404        };
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
449/// Locate the activatable skill `name`, honoring precedence. Returns the scope
450/// index and the raw `SKILL.md` content.
451async 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
465// ============================================================================
466// list_skills
467// ============================================================================
468
469fn 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        // `list_skills` is uncapped (scan_limit = None); the per-scope scan cap
531        // applies only to system-prompt building.
532        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
561// ============================================================================
562// activate_skill
563// ============================================================================
564
565fn 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        // Idempotence (EVE-337): re-activating the same skill within a session
644        // re-emits the cached result with `already_active: true`.
645        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                // Substitution pipeline: arguments then activation vars. Command
675                // injection (`!`cmd``) is intentionally NOT expanded — see the
676                // module-level gate note.
677                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
724// ============================================================================
725// read_skill
726// ============================================================================
727
728fn 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
845/// Relative file manifest (excluding `SKILL.md`) for a skill directory, walked
846/// through the VFS. Bounded by `MAX_EXTRA_SKILL_FILES`.
847async 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
878// ============================================================================
879// write_skill
880// ============================================================================
881
882fn 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        // Reject a known-but-non-writable scope explicitly (e.g. a read-only
975        // bundled "system" scope) rather than silently falling through.
976        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
1048// ============================================================================
1049// Validation helpers
1050// ============================================================================
1051
1052fn 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
1090/// Reject absolute paths, traversal, backslashes, and `SKILL.md` itself.
1091fn 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;