Skip to main content

everruns_core/
skill.rs

1// Skill domain types and SKILL.md parser
2//
3// Skills are portable instruction packages following the agentskills.io format.
4// A skill consists of a SKILL.md file (YAML frontmatter + markdown body)
5// with optional bundled scripts, references, and assets.
6
7use chrono::{DateTime, Utc};
8use regex::Regex;
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::sync::LazyLock;
12use tracing::warn;
13
14/// Cached regex for `$ARGUMENTS[N]` indexed placeholder substitution.
15static INDEXED_ARGS_RE: LazyLock<Regex> =
16    LazyLock::new(|| Regex::new(r"\$ARGUMENTS\[([0-9]+)\]").unwrap());
17
18/// Cached regex for ``!`command` `` dynamic command injection syntax.
19static COMMAND_INJECTION_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"!`([^`]+)`").unwrap());
20
21use crate::typed_id::SkillId;
22
23#[cfg(feature = "openapi")]
24use utoipa::ToSchema;
25
26/// Skill source type
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
28#[cfg_attr(feature = "openapi", derive(ToSchema))]
29#[serde(rename_all = "lowercase")]
30pub enum SkillSourceType {
31    /// Single SKILL.md file (instructions only)
32    Markdown,
33    /// ZIP archive with SKILL.md + scripts/references/assets
34    Archive,
35}
36
37impl std::fmt::Display for SkillSourceType {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        match self {
40            SkillSourceType::Markdown => write!(f, "markdown"),
41            SkillSourceType::Archive => write!(f, "archive"),
42        }
43    }
44}
45
46impl From<&str> for SkillSourceType {
47    fn from(s: &str) -> Self {
48        match s {
49            "archive" => SkillSourceType::Archive,
50            _ => SkillSourceType::Markdown,
51        }
52    }
53}
54
55/// Skill lifecycle status
56#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
57#[cfg_attr(feature = "openapi", derive(ToSchema))]
58#[serde(rename_all = "lowercase")]
59pub enum SkillStatus {
60    Active,
61    Disabled,
62    Archived,
63    Deleted,
64}
65
66impl std::fmt::Display for SkillStatus {
67    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68        match self {
69            SkillStatus::Active => write!(f, "active"),
70            SkillStatus::Disabled => write!(f, "disabled"),
71            SkillStatus::Archived => write!(f, "archived"),
72            SkillStatus::Deleted => write!(f, "deleted"),
73        }
74    }
75}
76
77impl From<&str> for SkillStatus {
78    fn from(s: &str) -> Self {
79        match s {
80            "disabled" => SkillStatus::Disabled,
81            "archived" => SkillStatus::Archived,
82            "deleted" => SkillStatus::Deleted,
83            _ => SkillStatus::Active,
84        }
85    }
86}
87
88/// Skill entity (API response type)
89#[derive(Debug, Clone, Serialize, Deserialize)]
90#[cfg_attr(feature = "openapi", derive(ToSchema))]
91pub struct Skill {
92    /// Prefixed public identifier. See [ID Schema](https://docs.everruns.com/advanced/id-schema/).
93    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "skill_01933b5a00007000800000000000001"))]
94    pub id: SkillId,
95    /// Stable kebab-case slug used to invoke the skill (e.g. `/pdf-processing` in chat). Safe to render in user-facing messages.
96    #[cfg_attr(feature = "openapi", schema(example = "pdf-processing"))]
97    pub name: String,
98    /// Short, agent- and user-readable summary of what the skill does and when to use it.
99    #[cfg_attr(
100        feature = "openapi",
101        schema(example = "Extract text and tables from PDF files.")
102    )]
103    pub description: String,
104    /// License string as declared by the skill author (e.g. `MIT`, `Apache-2.0`). Informational; not enforced.
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub license: Option<String>,
107    /// Compatibility marker describing host-runtime requirements declared by the skill (e.g. min platform version). Informational.
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub compatibility: Option<String>,
110    /// Free-form metadata declared by the skill author.
111    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
112    pub metadata: HashMap<String, serde_json::Value>,
113    /// Comma-separated list of tool patterns this skill may invoke. `None` means inherit from the harness.
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub allowed_tools: Option<String>,
116    /// How the skill content is sourced (filesystem, URL, embedded). Determines reload semantics.
117    pub source_type: SkillSourceType,
118    /// Current lifecycle status (`active`, `archived`, `deleted`).
119    pub status: SkillStatus,
120    /// Semver string declared by the skill author. Free-form; sorted lexicographically when comparing.
121    pub version: String,
122    /// Whether this skill appears as a `/`-prefixed slash command for end users in chat UIs.
123    #[serde(default = "default_true")]
124    pub user_invocable: bool,
125    /// When `true`, the LLM is prevented from auto-invoking this skill; only the user can trigger it explicitly.
126    #[serde(default)]
127    pub disable_model_invocation: bool,
128    /// Timestamp when this skill was created (RFC 3339).
129    pub created_at: DateTime<Utc>,
130    /// Timestamp when this skill was last updated (RFC 3339).
131    pub updated_at: DateTime<Utc>,
132    /// Timestamp when this skill was archived, if any (RFC 3339). Archived skills are hidden from default list views.
133    #[serde(skip_serializing_if = "Option::is_none")]
134    pub archived_at: Option<DateTime<Utc>>,
135    /// Timestamp when this skill was hard-deleted, if any (RFC 3339).
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub deleted_at: Option<DateTime<Utc>>,
138}
139
140/// Skill execution context mode.
141///
142/// Determines whether the skill runs inline in the current session
143/// or in an isolated subagent context.
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145#[serde(rename_all = "lowercase")]
146#[derive(Default)]
147pub enum SkillContext {
148    /// Run inline in the current session (default)
149    #[default]
150    Inline,
151    /// Run in an isolated subagent session
152    Fork,
153}
154
155impl std::fmt::Display for SkillContext {
156    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
157        match self {
158            SkillContext::Inline => write!(f, "inline"),
159            SkillContext::Fork => write!(f, "fork"),
160        }
161    }
162}
163
164/// Parsed SKILL.md content
165#[derive(Debug, Clone)]
166pub struct ParsedSkillMd {
167    /// Skill name from frontmatter
168    pub name: String,
169    /// Description from frontmatter
170    pub description: String,
171    /// License from frontmatter
172    pub license: Option<String>,
173    /// Compatibility from frontmatter
174    pub compatibility: Option<String>,
175    /// Arbitrary metadata from frontmatter
176    pub metadata: HashMap<String, serde_json::Value>,
177    /// Allowed tools from frontmatter
178    pub allowed_tools: Option<String>,
179    /// Version from metadata or default
180    pub version: String,
181    /// Markdown body (after frontmatter)
182    pub instructions: String,
183    /// Whether this skill appears as a /slash command for users (default: true)
184    pub user_invocable: bool,
185    /// Whether the model is prevented from auto-invoking this skill (default: false)
186    pub disable_model_invocation: bool,
187    /// Hint string for autocomplete (e.g., `"<issue-number>"`)
188    pub argument_hint: Option<String>,
189    /// Execution context: inline (default) or fork (subagent)
190    pub context: SkillContext,
191    /// Subagent type when context is fork (e.g., "Explore", "Plan"). Default: "general-purpose"
192    pub agent: Option<String>,
193    /// LLM model override for this skill (e.g., "claude-haiku-4-5-20251001")
194    pub model: Option<String>,
195}
196
197/// YAML frontmatter structure
198#[derive(Debug, Deserialize)]
199struct SkillFrontmatter {
200    name: Option<String>,
201    description: Option<String>,
202    license: Option<String>,
203    compatibility: Option<String>,
204    #[serde(default)]
205    metadata: HashMap<String, serde_json::Value>,
206    #[serde(rename = "allowed-tools")]
207    allowed_tools: Option<String>,
208    /// Whether this skill appears as a /slash command (default: true)
209    #[serde(rename = "user-invocable", default = "default_true")]
210    user_invocable: bool,
211    /// Whether the model is prevented from auto-invoking this skill (default: false)
212    #[serde(rename = "disable-model-invocation", default)]
213    disable_model_invocation: bool,
214    /// Hint string shown in autocomplete for expected arguments
215    #[serde(rename = "argument-hint")]
216    argument_hint: Option<String>,
217    /// Execution context: "fork" runs in isolated subagent, absent/other = inline
218    context: Option<String>,
219    /// Subagent type when context is fork (e.g., "Explore", "Plan")
220    agent: Option<String>,
221    /// LLM model override for this skill
222    model: Option<String>,
223}
224
225fn default_true() -> bool {
226    true
227}
228
229/// Skill content response (for /content endpoint)
230#[derive(Debug, Clone, Serialize, Deserialize)]
231#[cfg_attr(feature = "openapi", derive(ToSchema))]
232pub struct SkillContent {
233    pub skill_md: String,
234    pub files: Vec<SkillFileEntry>,
235}
236
237/// A file entry in a skill archive
238#[derive(Debug, Clone, Serialize, Deserialize)]
239#[cfg_attr(feature = "openapi", derive(ToSchema))]
240pub struct SkillFileEntry {
241    pub path: String,
242    pub content: String,
243}
244
245/// Number of agents and harnesses that reference a skill via its
246/// `skill:{uuid}` capability id. The `/v1/skills/usage` endpoint returns this
247/// keyed by public `SkillId`; skills with no references are omitted from the
248/// map and the UI defaults missing entries to zero.
249#[derive(Debug, Clone, Default, Serialize, Deserialize)]
250#[cfg_attr(feature = "openapi", derive(ToSchema))]
251pub struct SkillUsage {
252    pub agents: u64,
253    pub harnesses: u64,
254}
255
256/// Validation result for SKILL.md
257#[derive(Debug, Clone, Serialize, Deserialize)]
258#[cfg_attr(feature = "openapi", derive(ToSchema))]
259pub struct SkillValidationResult {
260    /// `true` when the candidate SKILL.md parsed and passes all hard checks; `false` if any error was found.
261    pub valid: bool,
262    /// Parsed skill slug from the front matter. `None` when the input could not be parsed enough to extract a name.
263    #[serde(skip_serializing_if = "Option::is_none")]
264    pub name: Option<String>,
265    /// Parsed skill description. `None` when not present in the input or unparseable.
266    #[serde(skip_serializing_if = "Option::is_none")]
267    pub description: Option<String>,
268    /// Hard validation errors. Non-empty if and only if `valid` is `false`.
269    #[serde(default, skip_serializing_if = "Vec::is_empty")]
270    pub errors: Vec<String>,
271    /// Non-fatal warnings (style, deprecated patterns, optional fields missing). Emitted alongside a `valid` result.
272    #[serde(default, skip_serializing_if = "Vec::is_empty")]
273    pub warnings: Vec<String>,
274}
275
276// ============================================================================
277// SKILL.md Parser
278// ============================================================================
279
280/// Parse a SKILL.md string into structured data.
281///
282/// Uses a two-pass strategy: strict `serde_yaml` first, then a lenient
283/// fallback that auto-fixes common issues (unquoted colons, special chars)
284/// before rejecting the skill entirely. Logs a warning when fallback is used.
285pub fn parse_skill_md(content: &str) -> Result<ParsedSkillMd, Vec<String>> {
286    let (frontmatter_str, body) = extract_frontmatter(content)?;
287    let fm: SkillFrontmatter = match serde_yaml::from_str(&frontmatter_str) {
288        Ok(fm) => fm,
289        Err(strict_err) => match try_lenient_yaml_parse(&frontmatter_str) {
290            Ok(fm) => {
291                warn!(
292                    strict_error = %strict_err,
293                    "SKILL.md YAML frontmatter required lenient parsing; skill authors should fix their YAML."
294                );
295                fm
296            }
297            Err(_) => {
298                return Err(vec![format!("invalid YAML frontmatter: {strict_err}")]);
299            }
300        },
301    };
302
303    let mut errors = Vec::new();
304
305    let name = match &fm.name {
306        Some(n) => {
307            if let Err(name_errors) = validate_skill_name(n) {
308                errors.extend(name_errors);
309            }
310            n.clone()
311        }
312        None => {
313            errors.push("name: required field missing".to_string());
314            String::new()
315        }
316    };
317
318    let description = match &fm.description {
319        Some(d) if d.trim().is_empty() => {
320            errors.push("description: must not be empty".to_string());
321            String::new()
322        }
323        Some(d) if d.len() > 1024 => {
324            errors.push("description: exceeds 1024 character limit".to_string());
325            d.clone()
326        }
327        Some(d) => d.clone(),
328        None => {
329            errors.push("description: required field missing".to_string());
330            String::new()
331        }
332    };
333
334    if let Some(ref license) = fm.license
335        && license.len() > 500
336    {
337        errors.push("license: exceeds 500 character limit".to_string());
338    }
339
340    if let Some(ref compat) = fm.compatibility
341        && compat.len() > 500
342    {
343        errors.push("compatibility: exceeds 500 character limit".to_string());
344    }
345
346    if let Some(ref hint) = fm.argument_hint
347        && hint.len() > 128
348    {
349        errors.push("argument-hint: exceeds 128 character limit".to_string());
350    }
351
352    // Parse context field
353    let context = match fm.context.as_deref() {
354        Some("fork") => SkillContext::Fork,
355        Some("inline") | None => SkillContext::Inline,
356        Some(other) => {
357            errors.push(format!(
358                "context: invalid value \"{other}\", must be \"fork\" or \"inline\""
359            ));
360            SkillContext::Inline
361        }
362    };
363
364    // Validate agent field only meaningful with context: fork
365    if fm.agent.is_some() && context != SkillContext::Fork {
366        errors.push("agent: field is only meaningful when context is \"fork\"".to_string());
367    }
368
369    if body.len() > 100 * 1024 {
370        errors.push("instructions: exceeds 100 KB limit".to_string());
371    }
372
373    if !errors.is_empty() {
374        return Err(errors);
375    }
376
377    let version = fm
378        .metadata
379        .get("version")
380        .and_then(|v| v.as_str())
381        .unwrap_or("1.0")
382        .to_string();
383
384    Ok(ParsedSkillMd {
385        name,
386        description,
387        license: fm.license,
388        compatibility: fm.compatibility,
389        metadata: fm.metadata,
390        allowed_tools: fm.allowed_tools,
391        version,
392        instructions: body,
393        user_invocable: fm.user_invocable,
394        disable_model_invocation: fm.disable_model_invocation,
395        argument_hint: fm.argument_hint,
396        context,
397        agent: fm.agent,
398        model: fm.model,
399    })
400}
401
402/// Validate a SKILL.md and return a SkillValidationResult
403pub fn validate_skill_md(content: &str) -> SkillValidationResult {
404    match parse_skill_md(content) {
405        Ok(parsed) => {
406            let mut warnings = Vec::new();
407            let line_count = parsed.instructions.lines().count();
408            if line_count > 500 {
409                warnings.push(format!(
410                    "Instructions exceed 500 lines ({line_count} lines). Consider splitting into references."
411                ));
412            }
413            if !parsed.user_invocable && parsed.disable_model_invocation {
414                warnings.push(
415                    "Skill is unreachable: user-invocable is false and disable-model-invocation is true. \
416                     Neither users nor the model can invoke this skill."
417                        .to_string(),
418                );
419            }
420            if parsed.context == SkillContext::Fork && parsed.agent.is_none() {
421                warnings.push(
422                    "context: fork without agent field — will use default \"general-purpose\" agent."
423                        .to_string(),
424                );
425            }
426            if parsed.model.is_some() && parsed.context != SkillContext::Fork {
427                warnings.push(
428                    "model: field is only supported with context: fork. \
429                     Inline skills ignore the model override."
430                        .to_string(),
431                );
432            }
433            SkillValidationResult {
434                valid: true,
435                name: Some(parsed.name),
436                description: Some(parsed.description),
437                errors: vec![],
438                warnings,
439            }
440        }
441        Err(errors) => SkillValidationResult {
442            valid: false,
443            name: None,
444            description: None,
445            errors,
446            warnings: vec![],
447        },
448    }
449}
450
451/// Validate a skill name per agentskills.io spec
452pub fn validate_skill_name(name: &str) -> Result<(), Vec<String>> {
453    let mut errors = Vec::new();
454
455    if name.is_empty() || name.len() > 64 {
456        errors.push("name: must be 1-64 characters".to_string());
457    }
458
459    if !name
460        .chars()
461        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
462    {
463        errors.push("name: must contain only lowercase letters, numbers, and hyphens".to_string());
464    }
465
466    if name.starts_with('-') || name.ends_with('-') {
467        errors.push("name: must not start or end with hyphen".to_string());
468    }
469
470    if name.contains("--") {
471        errors.push("name: must not contain consecutive hyphens".to_string());
472    }
473
474    if errors.is_empty() {
475        Ok(())
476    } else {
477        Err(errors)
478    }
479}
480
481/// Extract YAML frontmatter and body from a SKILL.md string.
482/// Frontmatter is delimited by `---` lines.
483fn extract_frontmatter(content: &str) -> Result<(String, String), Vec<String>> {
484    let trimmed = content.trim_start();
485    if !trimmed.starts_with("---") {
486        return Err(vec![
487            "SKILL.md must start with YAML frontmatter (--- delimiter)".to_string(),
488        ]);
489    }
490
491    // Find the closing ---
492    let after_first = &trimmed[3..];
493    let closing = after_first
494        .find("\n---")
495        .ok_or_else(|| vec!["SKILL.md frontmatter missing closing --- delimiter".to_string()])?;
496
497    let frontmatter = &after_first[..closing];
498    let body_start = closing + 4; // skip "\n---"
499    let body = if body_start < after_first.len() {
500        after_first[body_start..]
501            .trim_start_matches('\n')
502            .to_string()
503    } else {
504        String::new()
505    };
506
507    Ok((frontmatter.to_string(), body))
508}
509
510/// Attempt lenient YAML parsing by auto-fixing common issues:
511/// - Unquoted values containing colons (e.g., `description: Use this: it works`)
512/// - Unquoted values with special YAML characters (`{`, `}`, `[`, `]`, `#`)
513/// - Strip invalid control characters (except tab; newlines consumed by line iteration)
514fn try_lenient_yaml_parse(frontmatter: &str) -> Result<SkillFrontmatter, serde_yaml::Error> {
515    let fixed = fix_yaml_values(frontmatter);
516    serde_yaml::from_str(&fixed)
517}
518
519/// Auto-quote YAML values that contain problematic characters.
520///
521/// For each line that looks like `key: value`, if the value is not already
522/// quoted and contains characters that break strict YAML parsing (`:`, `{`,
523/// `}`, `[`, `]`, `#`), wrap it in double quotes (escaping inner quotes).
524/// Also strips control characters (except `\t`; `\n` is consumed by line iteration).
525fn fix_yaml_values(frontmatter: &str) -> String {
526    let problematic_chars: &[char] = &[':', '{', '}', '[', ']', '#'];
527
528    frontmatter
529        .lines()
530        .map(|line| {
531            // Strip invalid control characters (keep \t)
532            let line: String = line
533                .chars()
534                .filter(|c| !c.is_control() || *c == '\t')
535                .collect();
536
537            // Match `key: value` pattern (top-level only, no leading whitespace for nested)
538            if let Some(colon_pos) = line.find(": ") {
539                let key = &line[..colon_pos];
540                let value = line[colon_pos + 2..].trim();
541
542                // Skip if already quoted, empty, or a nested/list structure
543                if value.is_empty()
544                    || value.starts_with('"')
545                    || value.starts_with('\'')
546                    || value.starts_with('|')
547                    || value.starts_with('>')
548                    || key.starts_with(' ')
549                    || key.starts_with('\t')
550                {
551                    return line;
552                }
553
554                // If value contains problematic chars, quote it.
555                // Skip values that look like YAML flow collections (start with { or [).
556                if value.contains(problematic_chars)
557                    && !value.starts_with('{')
558                    && !value.starts_with('[')
559                {
560                    let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
561                    return format!("{key}: \"{escaped}\"");
562                }
563            }
564
565            line
566        })
567        .collect::<Vec<_>>()
568        .join("\n")
569}
570
571// ============================================================================
572// Skill Argument Substitution
573// ============================================================================
574
575/// Split arguments respecting quoted strings.
576///
577/// Splits on whitespace, treating `"hello world"` or `'hello world'` as single tokens.
578/// Quotes are stripped from the result.
579fn split_skill_args(raw: &str) -> Vec<String> {
580    let mut args = Vec::new();
581    let mut current = String::new();
582    let mut in_quote: Option<char> = None;
583    for c in raw.chars() {
584        match (c, in_quote) {
585            ('"' | '\'', None) => in_quote = Some(c),
586            (q, Some(open)) if q == open => in_quote = None,
587            (c, Some(_)) => current.push(c),
588            (c, None) if c.is_whitespace() => {
589                if !current.is_empty() {
590                    args.push(std::mem::take(&mut current));
591                }
592            }
593            (c, None) => current.push(c),
594        }
595    }
596    if !current.is_empty() {
597        args.push(current);
598    }
599    args
600}
601
602/// Expand positional argument placeholders in skill content.
603///
604/// Substitution variables (processed in this order):
605/// 1. `$ARGUMENTS[N]` → Nth positional argument (0-based)
606/// 2. `$ARGUMENTS` → full argument string
607/// 3. `$N` (single digit 0-9) → shorthand for `$ARGUMENTS[N]`
608///
609/// If no placeholders are found and arguments are non-empty, appends `ARGUMENTS: <value>`.
610/// Out-of-bounds indices resolve to empty string.
611pub fn expand_skill_arguments(content: &str, raw_args: &str) -> String {
612    if raw_args.is_empty() {
613        return content.to_string();
614    }
615
616    let args = split_skill_args(raw_args);
617    let mut result = content.to_string();
618    let mut had_placeholder = false;
619
620    // 1. Replace $ARGUMENTS[N] (must be before $ARGUMENTS to avoid partial match)
621    if INDEXED_ARGS_RE.is_match(&result) {
622        had_placeholder = true;
623        result = INDEXED_ARGS_RE
624            .replace_all(&result, |caps: &regex::Captures| {
625                let idx: usize = caps[1].parse().unwrap_or(usize::MAX);
626                args.get(idx).cloned().unwrap_or_default()
627            })
628            .to_string();
629    }
630
631    // 2. Replace $ARGUMENTS (full string)
632    if result.contains("$ARGUMENTS") {
633        had_placeholder = true;
634        result = result.replace("$ARGUMENTS", raw_args);
635    }
636
637    // 3. Replace $N shorthand (single digit, not followed by word chars)
638    let chars: Vec<char> = result.chars().collect();
639    let mut new_result = String::with_capacity(result.len());
640    let mut found_shorthand = false;
641    let mut i = 0;
642
643    while i < chars.len() {
644        if chars[i] == '$' && i + 1 < chars.len() && chars[i + 1].is_ascii_digit() {
645            let digit = chars[i + 1];
646            let next_is_word = i + 2 < chars.len()
647                && (chars[i + 2].is_ascii_alphanumeric() || chars[i + 2] == '_');
648
649            if !next_is_word {
650                found_shorthand = true;
651                let idx = (digit as u8 - b'0') as usize;
652                new_result.push_str(args.get(idx).map(|s| s.as_str()).unwrap_or(""));
653            } else {
654                new_result.push('$');
655                new_result.push(digit);
656            }
657            i += 2;
658        } else {
659            new_result.push(chars[i]);
660            i += 1;
661        }
662    }
663
664    if found_shorthand {
665        had_placeholder = true;
666        result = new_result;
667    }
668
669    // Fallback: append if no placeholders found
670    if !had_placeholder {
671        result.push_str(&format!("\n\nARGUMENTS: {}", raw_args));
672    }
673
674    result
675}
676
677// ============================================================================
678// Environment Variable Substitution
679// ============================================================================
680
681/// Substitute activation-time placeholders in skill content.
682///
683/// Replaces:
684/// - `${SESSION_ID}` → current session's prefixed ID (e.g. `session_01abc...`)
685/// - `${SKILL_DIR}` → absolute path to the skill's directory
686///
687/// Called after `$ARGUMENTS`/`$N` substitution, before `!command` preprocessing.
688pub fn substitute_activation_vars(content: &str, session_id: &str, skill_dir: &str) -> String {
689    content
690        .replace("${SESSION_ID}", session_id)
691        .replace("${SKILL_DIR}", skill_dir)
692}
693
694// ============================================================================
695// Dynamic Context Injection: !`command` preprocessing
696// ============================================================================
697//
698// TRUSTED-SOURCE GATE: `preprocess_command_injections` executes shell commands
699// embedded in SKILL.md, which is RCE if the SKILL.md came from an attacker.
700// Callers MUST only invoke this function when the SKILL.md originates from a
701// non-user-spoofable source (e.g. a capability/registry-owned virtual mount).
702//
703// `SessionFile::is_readonly` is NOT a valid trust signal: it is user-settable
704// via the session-files HTTP API and via `InitialFile` configuration. A
705// future platform-controlled provenance field (for example, a
706// `mount_capability_id` populated only by mount application code) is needed
707// before this function can be re-enabled for any source. Until then, the
708// single caller (`ActivateSkillFromVfsTool::execute_with_context`) keeps the
709// gate forced off; the function itself is preserved for its unit tests and
710// to simplify a future re-enable PR.
711//
712// The default `ProcessCommandExecutor` spawns `bash -c` on the worker host.
713// That is deliberately dormant: when command substitution is re-enabled, the
714// executor MUST be replaced with a session-sandbox-backed implementation so
715// commands run against virtual bash (bashkit / managed session sandbox) and
716// the session virtual filesystem rather than the worker. Flipping the trust
717// gate without that replacement would still be RCE against the worker host.
718//
719// See `specs/skills-registry.md` ("Activation Substitution Pipeline") and
720// `specs/threat-model.md` entry TM-TOOL-020 for the rationale.
721
722/// Result of executing a shell command during skill preprocessing.
723pub struct CommandResult {
724    pub stdout: String,
725    pub exit_code: i32,
726}
727
728/// Trait for executing shell commands during skill preprocessing.
729///
730/// Commands in `!`...`` syntax would be executed before the skill content
731/// is sent to the model, replacing each placeholder with command output.
732///
733/// This path is intentionally not reached at runtime today; see the
734/// trust-gate note at the top of this module.
735#[async_trait::async_trait]
736pub trait CommandExecutor: Send + Sync {
737    async fn execute_command(&self, command: &str) -> CommandResult;
738}
739
740/// Default executor using `tokio::process::Command` with bash.
741pub struct ProcessCommandExecutor {
742    /// Timeout per command in seconds (default: 30).
743    pub timeout_secs: u64,
744}
745
746impl Default for ProcessCommandExecutor {
747    fn default() -> Self {
748        Self { timeout_secs: 30 }
749    }
750}
751
752#[async_trait::async_trait]
753impl CommandExecutor for ProcessCommandExecutor {
754    async fn execute_command(&self, command: &str) -> CommandResult {
755        let timeout = std::time::Duration::from_secs(self.timeout_secs);
756        let child = tokio::process::Command::new("bash")
757            .arg("-c")
758            .arg(command)
759            .stdout(std::process::Stdio::piped())
760            .stderr(std::process::Stdio::piped())
761            .spawn();
762
763        let child = match child {
764            Ok(c) => c,
765            Err(_) => {
766                return CommandResult {
767                    stdout: String::new(),
768                    exit_code: -1,
769                };
770            }
771        };
772
773        match tokio::time::timeout(timeout, child.wait_with_output()).await {
774            Ok(Ok(output)) => CommandResult {
775                stdout: String::from_utf8_lossy(&output.stdout).to_string(),
776                exit_code: output.status.code().unwrap_or(-1),
777            },
778            Ok(Err(_)) => CommandResult {
779                stdout: String::new(),
780                exit_code: -1,
781            },
782            Err(_) => CommandResult {
783                stdout: format!(
784                    "[Command timed out after {}s: {command}]",
785                    self.timeout_secs
786                ),
787                exit_code: -1,
788            },
789        }
790    }
791}
792
793/// Maximum number of `!`command`` placeholders expanded per activation.
794///
795/// Excess placeholders are replaced with a sentinel error; they are not
796/// executed. Bounds the shell-process fan-out even for a trusted SKILL.md.
797pub const MAX_COMMAND_PLACEHOLDERS_PER_SKILL: usize = 32;
798
799/// Maximum number of `!`command`` placeholders executed concurrently within
800/// a single activation. Keeps worker process pressure bounded under load.
801const COMMAND_EXECUTION_CONCURRENCY: usize = 4;
802
803/// Preprocess `!`command`` placeholders in skill content.
804///
805/// Each `!`command`` is executed via the provided executor and replaced with
806/// its stdout. Execution is bounded: at most
807/// [`MAX_COMMAND_PLACEHOLDERS_PER_SKILL`] placeholders are expanded per call
808/// (extras are replaced with `[Too many command placeholders: limit is N]`
809/// sentinels), and at most `COMMAND_EXECUTION_CONCURRENCY` commands run
810/// concurrently.
811///
812/// Substitution pipeline order (caller is responsible for prior steps):
813/// 1. `$ARGUMENTS` / `$N` substitution (sync)
814/// 2. `${SESSION_ID}` / `${SKILL_DIR}` env substitution (sync)
815/// 3. `!`command`` preprocessing (async) — this function
816///
817/// SECURITY: This function spawns shell processes on the worker host. It MUST
818/// only be called for skill content that came from a trusted source (see the
819/// trust-gate note at the top of this module). Untrusted content must bypass
820/// this step and be used verbatim.
821pub async fn preprocess_command_injections(
822    content: &str,
823    executor: &dyn CommandExecutor,
824) -> String {
825    use futures::stream::StreamExt;
826
827    let all_matches: Vec<(String, std::ops::Range<usize>)> = COMMAND_INJECTION_RE
828        .captures_iter(content)
829        .map(|cap| {
830            let full = cap.get(0).unwrap();
831            let cmd = cap[1].to_string();
832            (cmd, full.start()..full.end())
833        })
834        .collect();
835
836    if all_matches.is_empty() {
837        return content.to_string();
838    }
839
840    // Partition at the cap: the first N are executed, the rest get a sentinel
841    // replacement so the content still carries a visible marker but no extra
842    // shell processes are spawned.
843    let exec_count = all_matches.len().min(MAX_COMMAND_PLACEHOLDERS_PER_SKILL);
844
845    // `buffered` preserves input order, so results line up with
846    // `all_matches[..exec_count]` positionally. We collect owned command
847    // strings so the stream items are `'static`, side-stepping a borrow-
848    // across-await lifetime that the compiler otherwise rejects.
849    let cmds_to_run: Vec<String> = all_matches[..exec_count]
850        .iter()
851        .map(|(cmd, _)| cmd.clone())
852        .collect();
853    let results: Vec<CommandResult> = futures::stream::iter(cmds_to_run)
854        .map(|cmd| async move { executor.execute_command(&cmd).await })
855        .buffered(COMMAND_EXECUTION_CONCURRENCY)
856        .collect()
857        .await;
858
859    let mut result = content.to_string();
860    // Walk all matches in reverse so byte ranges remain valid as we splice.
861    // For positions below exec_count, use the command result; for positions
862    // above (only possible when exceeded_cap), substitute the cap sentinel.
863    for (idx, (cmd, range)) in all_matches.iter().enumerate().rev() {
864        let replacement = if idx < exec_count {
865            let cmd_result = &results[idx];
866            if cmd_result.exit_code != 0 && cmd_result.stdout.starts_with('[') {
867                cmd_result.stdout.clone()
868            } else if cmd_result.exit_code != 0 {
869                format!(
870                    "[Command failed: {} (exit code {})]",
871                    cmd, cmd_result.exit_code
872                )
873            } else if cmd_result.stdout.is_empty() {
874                "[No output]".to_string()
875            } else {
876                cmd_result.stdout.trim_end().to_string()
877            }
878        } else {
879            format!(
880                "[Too many command placeholders: limit is {}]",
881                MAX_COMMAND_PLACEHOLDERS_PER_SKILL
882            )
883        };
884        result.replace_range(range.clone(), &replacement);
885    }
886
887    result
888}
889
890#[cfg(test)]
891mod tests {
892    use super::*;
893
894    #[test]
895    fn test_parse_valid_skill_md() {
896        let content = r#"---
897name: pdf-processing
898description: Extract text from PDF files.
899---
900
901# PDF Processing
902
903Use pdfplumber to extract text.
904"#;
905        let parsed = parse_skill_md(content).unwrap();
906        assert_eq!(parsed.name, "pdf-processing");
907        assert_eq!(parsed.description, "Extract text from PDF files.");
908        assert!(parsed.instructions.contains("# PDF Processing"));
909        assert_eq!(parsed.version, "1.0");
910    }
911
912    #[test]
913    fn test_parse_with_optional_fields() {
914        let content = r#"---
915name: data-analysis
916description: Analyze datasets.
917license: MIT
918compatibility: Python 3.10+
919metadata:
920  version: "2.0"
921  author: test
922allowed-tools: bash python
923---
924
925Instructions here.
926"#;
927        let parsed = parse_skill_md(content).unwrap();
928        assert_eq!(parsed.name, "data-analysis");
929        assert_eq!(parsed.license.as_deref(), Some("MIT"));
930        assert_eq!(parsed.compatibility.as_deref(), Some("Python 3.10+"));
931        assert_eq!(parsed.version, "2.0");
932        assert_eq!(parsed.allowed_tools.as_deref(), Some("bash python"));
933    }
934
935    #[test]
936    fn test_parse_missing_name() {
937        let content = r#"---
938description: No name here.
939---
940
941Body.
942"#;
943        let err = parse_skill_md(content).unwrap_err();
944        assert!(err.iter().any(|e| e.contains("name: required")));
945    }
946
947    #[test]
948    fn test_parse_missing_description() {
949        let content = r#"---
950name: test-skill
951---
952
953Body.
954"#;
955        let err = parse_skill_md(content).unwrap_err();
956        assert!(err.iter().any(|e| e.contains("description: required")));
957    }
958
959    #[test]
960    fn test_parse_no_frontmatter() {
961        let content = "# Just markdown, no frontmatter";
962        let err = parse_skill_md(content).unwrap_err();
963        assert!(err.iter().any(|e| e.contains("frontmatter")));
964    }
965
966    #[test]
967    fn test_validate_name_valid() {
968        assert!(validate_skill_name("pdf-processing").is_ok());
969        assert!(validate_skill_name("a").is_ok());
970        assert!(validate_skill_name("my-skill-123").is_ok());
971    }
972
973    #[test]
974    fn test_validate_name_invalid() {
975        assert!(validate_skill_name("").is_err());
976        assert!(validate_skill_name("-leading").is_err());
977        assert!(validate_skill_name("trailing-").is_err());
978        assert!(validate_skill_name("double--hyphen").is_err());
979        assert!(validate_skill_name("UPPERCASE").is_err());
980        assert!(validate_skill_name("has spaces").is_err());
981        assert!(validate_skill_name("has_underscores").is_err());
982    }
983
984    #[test]
985    fn test_validate_skill_md() {
986        let content = r#"---
987name: test-skill
988description: A test skill.
989---
990
991Instructions.
992"#;
993        let result = validate_skill_md(content);
994        assert!(result.valid);
995        assert_eq!(result.name.as_deref(), Some("test-skill"));
996        assert!(result.errors.is_empty());
997    }
998
999    #[test]
1000    fn test_validate_skill_md_invalid() {
1001        let content = r#"---
1002name: INVALID
1003---
1004
1005Body.
1006"#;
1007        let result = validate_skill_md(content);
1008        assert!(!result.valid);
1009        assert!(!result.errors.is_empty());
1010    }
1011
1012    #[test]
1013    fn test_parse_user_invocable_default_true() {
1014        let content = r#"---
1015name: my-skill
1016description: A skill without explicit invocable field.
1017---
1018
1019Instructions.
1020"#;
1021        let parsed = parse_skill_md(content).unwrap();
1022        assert!(
1023            parsed.user_invocable,
1024            "user_invocable should default to true"
1025        );
1026    }
1027
1028    #[test]
1029    fn test_parse_user_invocable_explicit_true() {
1030        let content = r#"---
1031name: my-skill
1032description: An invocable skill.
1033user-invocable: true
1034---
1035
1036Instructions.
1037"#;
1038        let parsed = parse_skill_md(content).unwrap();
1039        assert!(parsed.user_invocable);
1040    }
1041
1042    #[test]
1043    fn test_parse_user_invocable_false() {
1044        let content = r#"---
1045name: background-context
1046description: Context the agent should know but not a user command.
1047user-invocable: false
1048---
1049
1050Instructions.
1051"#;
1052        let parsed = parse_skill_md(content).unwrap();
1053        assert!(!parsed.user_invocable);
1054    }
1055
1056    #[test]
1057    fn test_parse_disable_model_invocation_default_false() {
1058        let content = r#"---
1059name: my-skill
1060description: A skill without disable-model-invocation field.
1061---
1062
1063Instructions.
1064"#;
1065        let parsed = parse_skill_md(content).unwrap();
1066        assert!(
1067            !parsed.disable_model_invocation,
1068            "disable_model_invocation should default to false"
1069        );
1070    }
1071
1072    #[test]
1073    fn test_parse_disable_model_invocation_true() {
1074        let content = r#"---
1075name: manual-only
1076description: A skill that cannot be auto-invoked by the model.
1077disable-model-invocation: true
1078---
1079
1080Instructions.
1081"#;
1082        let parsed = parse_skill_md(content).unwrap();
1083        assert!(parsed.disable_model_invocation);
1084        assert!(parsed.user_invocable); // default true
1085    }
1086
1087    #[test]
1088    fn test_validate_warns_unreachable_skill() {
1089        let content = r#"---
1090name: unreachable
1091description: Neither user nor model can invoke.
1092user-invocable: false
1093disable-model-invocation: true
1094---
1095
1096Instructions.
1097"#;
1098        let result = validate_skill_md(content);
1099        assert!(result.valid);
1100        assert!(
1101            result.warnings.iter().any(|w| w.contains("unreachable")),
1102            "Should warn about unreachable skill"
1103        );
1104    }
1105
1106    #[test]
1107    fn test_skill_source_type_display() {
1108        assert_eq!(SkillSourceType::Markdown.to_string(), "markdown");
1109        assert_eq!(SkillSourceType::Archive.to_string(), "archive");
1110    }
1111
1112    #[test]
1113    fn test_skill_status_display() {
1114        assert_eq!(SkillStatus::Active.to_string(), "active");
1115        assert_eq!(SkillStatus::Disabled.to_string(), "disabled");
1116    }
1117
1118    #[test]
1119    fn test_skill_source_type_from_str() {
1120        assert_eq!(SkillSourceType::from("archive"), SkillSourceType::Archive);
1121        assert_eq!(SkillSourceType::from("markdown"), SkillSourceType::Markdown);
1122        assert_eq!(SkillSourceType::from("other"), SkillSourceType::Markdown);
1123    }
1124
1125    #[test]
1126    fn test_parse_argument_hint() {
1127        let content = r#"---
1128name: fix-issue
1129description: Fix a GitHub issue.
1130argument-hint: "<issue-number>"
1131---
1132
1133Fix issue $ARGUMENTS.
1134"#;
1135        let parsed = parse_skill_md(content).unwrap();
1136        assert_eq!(parsed.argument_hint.as_deref(), Some("<issue-number>"));
1137    }
1138
1139    #[test]
1140    fn test_parse_argument_hint_default_none() {
1141        let content = r#"---
1142name: my-skill
1143description: A skill.
1144---
1145
1146Body.
1147"#;
1148        let parsed = parse_skill_md(content).unwrap();
1149        assert!(parsed.argument_hint.is_none());
1150    }
1151
1152    // ========================================================================
1153    // context and agent frontmatter tests
1154    // ========================================================================
1155
1156    #[test]
1157    fn test_parse_context_fork() {
1158        let content = r#"---
1159name: deep-research
1160description: Research a topic thoroughly.
1161context: fork
1162---
1163
1164Research $ARGUMENTS.
1165"#;
1166        let parsed = parse_skill_md(content).unwrap();
1167        assert_eq!(parsed.context, SkillContext::Fork);
1168        assert!(parsed.agent.is_none());
1169    }
1170
1171    #[test]
1172    fn test_parse_context_fork_with_agent() {
1173        let content = r#"---
1174name: explore-code
1175description: Explore codebase.
1176context: fork
1177agent: Explore
1178---
1179
1180Explore $ARGUMENTS.
1181"#;
1182        let parsed = parse_skill_md(content).unwrap();
1183        assert_eq!(parsed.context, SkillContext::Fork);
1184        assert_eq!(parsed.agent.as_deref(), Some("Explore"));
1185    }
1186
1187    #[test]
1188    fn test_parse_context_inline_explicit() {
1189        let content = r#"---
1190name: my-skill
1191description: A skill.
1192context: inline
1193---
1194
1195Body.
1196"#;
1197        let parsed = parse_skill_md(content).unwrap();
1198        assert_eq!(parsed.context, SkillContext::Inline);
1199    }
1200
1201    #[test]
1202    fn test_parse_context_default_inline() {
1203        let content = r#"---
1204name: my-skill
1205description: A skill.
1206---
1207
1208Body.
1209"#;
1210        let parsed = parse_skill_md(content).unwrap();
1211        assert_eq!(parsed.context, SkillContext::Inline);
1212        assert!(parsed.agent.is_none());
1213    }
1214
1215    #[test]
1216    fn test_parse_context_invalid_value() {
1217        let content = r#"---
1218name: my-skill
1219description: A skill.
1220context: parallel
1221---
1222
1223Body.
1224"#;
1225        let err = parse_skill_md(content).unwrap_err();
1226        assert!(err.iter().any(|e| e.contains("context: invalid value")));
1227    }
1228
1229    #[test]
1230    fn test_parse_agent_without_fork_is_error() {
1231        let content = r#"---
1232name: my-skill
1233description: A skill.
1234agent: Explore
1235---
1236
1237Body.
1238"#;
1239        let err = parse_skill_md(content).unwrap_err();
1240        assert!(
1241            err.iter()
1242                .any(|e| e.contains("agent: field is only meaningful"))
1243        );
1244    }
1245
1246    #[test]
1247    fn test_validate_warns_fork_without_agent() {
1248        let content = r#"---
1249name: my-skill
1250description: A skill.
1251context: fork
1252---
1253
1254Body.
1255"#;
1256        let result = validate_skill_md(content);
1257        assert!(result.valid);
1258        assert!(
1259            result
1260                .warnings
1261                .iter()
1262                .any(|w| w.contains("general-purpose"))
1263        );
1264    }
1265
1266    #[test]
1267    fn test_skill_context_display() {
1268        assert_eq!(SkillContext::Inline.to_string(), "inline");
1269        assert_eq!(SkillContext::Fork.to_string(), "fork");
1270    }
1271
1272    #[test]
1273    fn test_skill_context_default() {
1274        assert_eq!(SkillContext::default(), SkillContext::Inline);
1275    }
1276
1277    // -- model frontmatter tests --
1278
1279    #[test]
1280    fn test_parse_model_with_fork() {
1281        let content = r#"---
1282name: quick-lint
1283description: Fast lint check.
1284context: fork
1285model: claude-haiku-4-5-20251001
1286---
1287
1288Lint instructions.
1289"#;
1290        let parsed = parse_skill_md(content).unwrap();
1291        assert_eq!(parsed.model.as_deref(), Some("claude-haiku-4-5-20251001"));
1292        assert_eq!(parsed.context, SkillContext::Fork);
1293    }
1294
1295    #[test]
1296    fn test_parse_model_without_fork() {
1297        let content = r#"---
1298name: my-skill
1299description: A skill.
1300model: gpt-4o
1301---
1302
1303Body.
1304"#;
1305        let parsed = parse_skill_md(content).unwrap();
1306        assert_eq!(parsed.model.as_deref(), Some("gpt-4o"));
1307        assert_eq!(parsed.context, SkillContext::Inline);
1308    }
1309
1310    #[test]
1311    fn test_parse_no_model_field() {
1312        let content = r#"---
1313name: my-skill
1314description: A skill.
1315---
1316
1317Body.
1318"#;
1319        let parsed = parse_skill_md(content).unwrap();
1320        assert!(parsed.model.is_none());
1321    }
1322
1323    #[test]
1324    fn test_validate_warns_model_without_fork() {
1325        let content = r#"---
1326name: my-skill
1327description: A skill.
1328model: gpt-4o
1329---
1330
1331Body.
1332"#;
1333        let result = validate_skill_md(content);
1334        assert!(result.valid);
1335        assert!(
1336            result
1337                .warnings
1338                .iter()
1339                .any(|w| w.contains("model:") && w.contains("context: fork"))
1340        );
1341    }
1342
1343    #[test]
1344    fn test_validate_no_warning_model_with_fork() {
1345        let content = r#"---
1346name: my-skill
1347description: A skill.
1348context: fork
1349agent: Explore
1350model: claude-haiku-4-5-20251001
1351---
1352
1353Body.
1354"#;
1355        let result = validate_skill_md(content);
1356        assert!(result.valid);
1357        assert!(
1358            !result
1359                .warnings
1360                .iter()
1361                .any(|w| w.contains("model:") && w.contains("context: fork"))
1362        );
1363    }
1364
1365    // ========================================================================
1366    // expand_skill_arguments tests
1367    // ========================================================================
1368
1369    #[test]
1370    fn test_expand_full_arguments() {
1371        let content = "Process $ARGUMENTS now.";
1372        let result = expand_skill_arguments(content, "SearchBar React");
1373        assert_eq!(result, "Process SearchBar React now.");
1374    }
1375
1376    #[test]
1377    fn test_expand_indexed_arguments() {
1378        let content = "Migrate $ARGUMENTS[0] from $ARGUMENTS[1] to $ARGUMENTS[2].";
1379        let result = expand_skill_arguments(content, "SearchBar React Vue");
1380        assert_eq!(result, "Migrate SearchBar from React to Vue.");
1381    }
1382
1383    #[test]
1384    fn test_expand_shorthand_arguments() {
1385        let content = "Component: $0, from: $1, to: $2.";
1386        let result = expand_skill_arguments(content, "SearchBar React Vue");
1387        assert_eq!(result, "Component: SearchBar, from: React, to: Vue.");
1388    }
1389
1390    #[test]
1391    fn test_expand_quoted_arguments() {
1392        let content = "File: $0, message: $1.";
1393        let result = expand_skill_arguments(content, "app.js \"hello world\"");
1394        assert_eq!(result, "File: app.js, message: hello world.");
1395    }
1396
1397    #[test]
1398    fn test_expand_out_of_bounds() {
1399        let content = "A: $0, B: $1, C: $5.";
1400        let result = expand_skill_arguments(content, "only-one");
1401        assert_eq!(result, "A: only-one, B: , C: .");
1402    }
1403
1404    #[test]
1405    fn test_expand_no_placeholders_appends() {
1406        let content = "Do the thing.";
1407        let result = expand_skill_arguments(content, "some args");
1408        assert_eq!(result, "Do the thing.\n\nARGUMENTS: some args");
1409    }
1410
1411    #[test]
1412    fn test_expand_empty_args() {
1413        let content = "Content with $ARGUMENTS placeholder.";
1414        let result = expand_skill_arguments(content, "");
1415        assert_eq!(result, "Content with $ARGUMENTS placeholder.");
1416    }
1417
1418    #[test]
1419    fn test_expand_shorthand_no_word_collision() {
1420        // $NAME should NOT be replaced (not $0-$9 pattern)
1421        let content = "Variable $NAME and $0.";
1422        let result = expand_skill_arguments(content, "first");
1423        assert_eq!(result, "Variable $NAME and first.");
1424    }
1425
1426    #[test]
1427    fn test_expand_dollar_followed_by_multi_digit() {
1428        // $10 should NOT match $1 + "0" — only single-digit shorthand
1429        let content = "Value: $10 and $1.";
1430        let result = expand_skill_arguments(content, "a b");
1431        // $10 is not a valid shorthand (digit followed by digit), $1 = "b"
1432        assert_eq!(result, "Value: $10 and b.");
1433    }
1434
1435    #[test]
1436    fn test_split_skill_args_basic() {
1437        let args = split_skill_args("a b c");
1438        assert_eq!(args, vec!["a", "b", "c"]);
1439    }
1440
1441    #[test]
1442    fn test_split_skill_args_quoted() {
1443        let args = split_skill_args("\"hello world\" foo 'bar baz'");
1444        assert_eq!(args, vec!["hello world", "foo", "bar baz"]);
1445    }
1446
1447    #[test]
1448    fn test_split_skill_args_empty() {
1449        let args = split_skill_args("");
1450        assert!(args.is_empty());
1451    }
1452
1453    #[test]
1454    fn test_split_skill_args_extra_whitespace() {
1455        let args = split_skill_args("  a   b  ");
1456        assert_eq!(args, vec!["a", "b"]);
1457    }
1458
1459    // ========================================================================
1460    // substitute_activation_vars tests
1461    // ========================================================================
1462
1463    #[test]
1464    fn test_substitute_session_id() {
1465        let content = "Session: ${SESSION_ID}";
1466        let result = substitute_activation_vars(content, "session_01abc123", "/some/dir");
1467        assert_eq!(result, "Session: session_01abc123");
1468    }
1469
1470    #[test]
1471    fn test_substitute_skill_dir_filesystem() {
1472        let content = "Dir: ${SKILL_DIR}";
1473        let result = substitute_activation_vars(content, "session_x", "/home/user/skills/my-skill");
1474        assert_eq!(result, "Dir: /home/user/skills/my-skill");
1475    }
1476
1477    #[test]
1478    fn test_substitute_skill_dir_db_backed() {
1479        let content = "Dir: ${SKILL_DIR}";
1480        let result = substitute_activation_vars(content, "session_x", "/.agents/skills/my-skill");
1481        assert_eq!(result, "Dir: /.agents/skills/my-skill");
1482    }
1483
1484    #[test]
1485    fn test_substitute_both_vars() {
1486        let content = "Run: ${SKILL_DIR}/run.sh --session ${SESSION_ID}";
1487        let result =
1488            substitute_activation_vars(content, "session_01abc", "/.agents/skills/data-tool");
1489        assert_eq!(
1490            result,
1491            "Run: /.agents/skills/data-tool/run.sh --session session_01abc"
1492        );
1493    }
1494
1495    #[test]
1496    fn test_substitute_no_vars() {
1497        let content = "No variables here.";
1498        let result = substitute_activation_vars(content, "session_x", "/dir");
1499        assert_eq!(result, "No variables here.");
1500    }
1501
1502    #[test]
1503    fn test_substitute_multiple_occurrences() {
1504        let content = "${SESSION_ID} and ${SESSION_ID} again";
1505        let result = substitute_activation_vars(content, "session_abc", "/dir");
1506        assert_eq!(result, "session_abc and session_abc again");
1507    }
1508
1509    // ========================================================================
1510    // preprocess_command_injections tests
1511    // ========================================================================
1512
1513    /// Mock executor for testing command injection preprocessing.
1514    struct MockExecutor {
1515        responses: std::collections::HashMap<String, CommandResult>,
1516    }
1517
1518    impl MockExecutor {
1519        fn new() -> Self {
1520            Self {
1521                responses: std::collections::HashMap::new(),
1522            }
1523        }
1524
1525        fn add_response(&mut self, cmd: &str, stdout: &str, exit_code: i32) {
1526            self.responses.insert(
1527                cmd.to_string(),
1528                CommandResult {
1529                    stdout: stdout.to_string(),
1530                    exit_code,
1531                },
1532            );
1533        }
1534    }
1535
1536    #[async_trait::async_trait]
1537    impl CommandExecutor for MockExecutor {
1538        async fn execute_command(&self, command: &str) -> CommandResult {
1539            self.responses
1540                .get(command)
1541                .map(|r| CommandResult {
1542                    stdout: r.stdout.clone(),
1543                    exit_code: r.exit_code,
1544                })
1545                .unwrap_or(CommandResult {
1546                    stdout: String::new(),
1547                    exit_code: 127,
1548                })
1549        }
1550    }
1551
1552    #[tokio::test]
1553    async fn test_preprocess_single_command() {
1554        let mut exec = MockExecutor::new();
1555        exec.add_response("echo hello", "hello\n", 0);
1556
1557        let content = "Output: !`echo hello`";
1558        let result = preprocess_command_injections(content, &exec).await;
1559        assert_eq!(result, "Output: hello");
1560    }
1561
1562    #[tokio::test]
1563    async fn test_preprocess_multiple_commands() {
1564        let mut exec = MockExecutor::new();
1565        exec.add_response("git status", "clean\n", 0);
1566        exec.add_response("date", "2026-03-19\n", 0);
1567
1568        let content = "Status: !`git status`\nDate: !`date`";
1569        let result = preprocess_command_injections(content, &exec).await;
1570        assert_eq!(result, "Status: clean\nDate: 2026-03-19");
1571    }
1572
1573    #[tokio::test]
1574    async fn test_preprocess_command_failure() {
1575        let mut exec = MockExecutor::new();
1576        exec.add_response("bad-cmd", "error output\n", 1);
1577
1578        let content = "Result: !`bad-cmd`";
1579        let result = preprocess_command_injections(content, &exec).await;
1580        assert_eq!(result, "Result: [Command failed: bad-cmd (exit code 1)]");
1581    }
1582
1583    #[tokio::test]
1584    async fn test_preprocess_empty_output() {
1585        let mut exec = MockExecutor::new();
1586        exec.add_response("true", "", 0);
1587
1588        let content = "Result: !`true`";
1589        let result = preprocess_command_injections(content, &exec).await;
1590        assert_eq!(result, "Result: [No output]");
1591    }
1592
1593    #[tokio::test]
1594    async fn test_preprocess_no_commands() {
1595        let exec = MockExecutor::new();
1596
1597        let content = "No commands here. Just `code` and text.";
1598        let result = preprocess_command_injections(content, &exec).await;
1599        assert_eq!(result, content);
1600    }
1601
1602    #[tokio::test]
1603    async fn test_preprocess_preserves_regular_backticks() {
1604        let mut exec = MockExecutor::new();
1605        exec.add_response("echo hi", "hi\n", 0);
1606
1607        let content = "Use `code` and !`echo hi` here.";
1608        let result = preprocess_command_injections(content, &exec).await;
1609        assert_eq!(result, "Use `code` and hi here.");
1610    }
1611
1612    #[tokio::test]
1613    async fn test_preprocess_with_process_executor() {
1614        let exec = ProcessCommandExecutor::default();
1615
1616        let content = "Result: !`echo hello world`";
1617        let result = preprocess_command_injections(content, &exec).await;
1618        assert_eq!(result, "Result: hello world");
1619    }
1620
1621    #[tokio::test]
1622    async fn test_preprocess_command_not_found() {
1623        let exec = MockExecutor::new(); // No responses registered
1624
1625        let content = "Result: !`unknown-cmd`";
1626        let result = preprocess_command_injections(content, &exec).await;
1627        assert!(result.contains("[Command failed: unknown-cmd"));
1628    }
1629
1630    // -- lenient YAML fallback tests --
1631
1632    #[test]
1633    fn test_lenient_parse_unquoted_colon_in_description() {
1634        let content = r#"---
1635name: my-skill
1636description: Use this skill: it handles edge cases
1637---
1638
1639Instructions.
1640"#;
1641        let parsed = parse_skill_md(content).unwrap();
1642        assert_eq!(parsed.name, "my-skill");
1643        assert_eq!(parsed.description, "Use this skill: it handles edge cases");
1644    }
1645
1646    #[test]
1647    fn test_lenient_parse_hash_in_value() {
1648        let content = "---\nname: my-skill\ndescription: Process C# files\n---\n\nBody.\n";
1649        let parsed = parse_skill_md(content).unwrap();
1650        assert_eq!(parsed.description, "Process C# files");
1651    }
1652
1653    #[test]
1654    fn test_lenient_parse_brackets_in_value() {
1655        let content =
1656            "---\nname: my-skill\ndescription: Parse [markdown] and {templates}\n---\n\nBody.\n";
1657        let parsed = parse_skill_md(content).unwrap();
1658        assert_eq!(parsed.description, "Parse [markdown] and {templates}");
1659    }
1660
1661    #[test]
1662    fn test_lenient_parse_already_quoted_value_unchanged() {
1663        let content = "---\nname: my-skill\ndescription: \"Already quoted: value\"\n---\n\nBody.\n";
1664        let parsed = parse_skill_md(content).unwrap();
1665        assert_eq!(parsed.description, "Already quoted: value");
1666    }
1667
1668    #[test]
1669    fn test_fix_yaml_values_preserves_clean_yaml() {
1670        let input = "name: my-skill\ndescription: A simple skill";
1671        assert_eq!(fix_yaml_values(input), input);
1672    }
1673
1674    #[test]
1675    fn test_fix_yaml_values_quotes_colons() {
1676        let input = "name: my-skill\ndescription: Use this: it works";
1677        let fixed = fix_yaml_values(input);
1678        assert!(fixed.contains("description: \"Use this: it works\""));
1679    }
1680
1681    #[test]
1682    fn test_fix_yaml_values_escapes_inner_quotes() {
1683        let input = "name: my-skill\ndescription: Say \"hello\": world";
1684        let fixed = fix_yaml_values(input);
1685        assert!(fixed.contains(r#"description: "Say \"hello\": world""#));
1686    }
1687
1688    #[test]
1689    fn test_fix_yaml_values_skips_nested_keys() {
1690        let input = "metadata:\n  version: 1.0\n  key: value: nested";
1691        let fixed = fix_yaml_values(input);
1692        // Nested keys (indented) should not be modified
1693        assert!(fixed.contains("  version: 1.0"));
1694        assert!(fixed.contains("  key: value: nested"));
1695    }
1696
1697    #[test]
1698    fn test_fix_yaml_values_preserves_flow_collections() {
1699        let input = "name: my-skill\nmetadata: { version: \"1.0\" }\ntags: [a, b]";
1700        let fixed = fix_yaml_values(input);
1701        // Flow collections should NOT be quoted
1702        assert!(fixed.contains("metadata: { version: \"1.0\" }"));
1703        assert!(fixed.contains("tags: [a, b]"));
1704    }
1705}