echo_execution 0.1.2

Execution layer for echo-agent framework (sandbox, skills, tools)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
//! Skill Loader -- multi-scope discovery and agentskills.io-compliant parsing.
//!
//! Supports the standard [agentskills.io](https://agentskills.io/specification) directory
//! convention as well as the legacy echo-agent SKILL.md format (auto-detected with fallback).
//!
//! # Discovery scopes
//!
//! | Scope | Paths scanned |
//! |-------|--------------|
//! | Project | `./skills/`, `./.agents/skills/` |
//! | User | `~/.agents/skills/` |
//! | Custom | Any user-specified path |
//!
//! Project-level skills override user-level skills when names collide.

use std::collections::HashMap;
use std::path::{Path, PathBuf};

use tracing::{debug, info, warn};

use echo_core::error::{ReactError, Result};

use super::types::{RawFrontmatter, SkillDescriptor};

const SKILL_FILE: &str = "SKILL.md";
const MAX_SCAN_DEPTH: usize = 4;

/// Directories to skip during scanning.
const SKIP_DIRS: &[&str] = &[
    ".git",
    "node_modules",
    "target",
    "__pycache__",
    ".venv",
    "dist",
    "build",
];

// -- DiscoveryScope --

/// Where to scan for skills.
#[derive(Debug, Clone)]
pub enum DiscoveryScope {
    /// Project-level: `<root>/skills/` and `<root>/.agents/skills/`
    Project(PathBuf),
    /// User-level: `~/.agents/skills/`
    User,
    /// Custom path (scanned as-is)
    Custom(PathBuf),
}

// -- SkillLoader --

/// Multi-scope skill loader with agentskills.io-compliant parsing.
///
/// # Parsing behavior
///
/// - **Standard format**: YAML frontmatter (`name`, `description` required),
///   Markdown body = instructions.
/// - **Legacy format**: If frontmatter contains `instructions:` or `resources:`,
///   those are used instead of the body. A deprecation warning is logged.
/// - **Lenient validation**: Name/description issues produce warnings but don't
///   block loading (except missing `description`, which skips the skill).
pub struct SkillLoader {
    /// Discovered descriptors keyed by skill name.
    descriptors: HashMap<String, SkillDescriptor>,
    /// Legacy instructions from frontmatter, keyed by skill name.
    /// Preserved for activation when SKILL.md body is empty.
    legacy_instructions: HashMap<String, String>,
}

impl SkillLoader {
    pub fn new() -> Self {
        Self {
            descriptors: HashMap::new(),
            legacy_instructions: HashMap::new(),
        }
    }

    /// Discover skills from multiple scopes.
    ///
    /// Returns all successfully parsed `SkillDescriptor`s. Name collisions
    /// are resolved by order: earlier scopes take precedence. A warning is
    /// logged when a skill is shadowed.
    pub async fn discover(&mut self, scopes: &[DiscoveryScope]) -> Result<Vec<SkillDescriptor>> {
        let mut results = Vec::new();

        for scope in scopes {
            let dirs = scope_to_dirs(scope);
            for dir in dirs {
                if !dir.exists() {
                    debug!(
                        "Skill directory does not exist, skipping: {}",
                        dir.display()
                    );
                    continue;
                }
                let found = self.scan_directory(&dir, 0).await?;
                for (desc, legacy_instr) in found {
                    if let Some(existing) = self.descriptors.get(&desc.name) {
                        warn!(
                            "Skill '{}' at '{}' shadowed by existing at '{}'",
                            desc.name,
                            desc.location.display(),
                            existing.location.display()
                        );
                    } else {
                        if !legacy_instr.is_empty() {
                            self.legacy_instructions
                                .insert(desc.name.clone(), legacy_instr);
                        }
                        self.descriptors.insert(desc.name.clone(), desc.clone());
                        results.push(desc);
                    }
                }
            }
        }

        info!("Skill discovery complete: {} skills found", results.len());
        Ok(results)
    }

    /// Convenience: discover from a single directory path (backward-compatible).
    pub async fn discover_from_dir(
        &mut self,
        dir: impl Into<PathBuf>,
    ) -> Result<Vec<SkillDescriptor>> {
        self.discover(&[DiscoveryScope::Custom(dir.into())]).await
    }

    /// Scan a single directory for SKILL.md files.
    async fn scan_directory(
        &self,
        dir: &Path,
        depth: usize,
    ) -> Result<Vec<(SkillDescriptor, String)>> {
        if depth > MAX_SCAN_DEPTH {
            return Ok(vec![]);
        }

        let mut found = Vec::new();

        let mut entries = tokio::fs::read_dir(dir).await.map_err(|e| {
            ReactError::Other(format!("Cannot read directory '{}': {}", dir.display(), e))
        })?;

        while let Some(entry) = entries
            .next_entry()
            .await
            .map_err(|e| ReactError::Other(format!("Error reading directory entry: {}", e)))?
        {
            let path = entry.path();
            if !path.is_dir() {
                continue;
            }

            let dir_name = match path.file_name().and_then(|n| n.to_str()) {
                Some(n) => n.to_string(),
                None => continue,
            };

            if SKIP_DIRS.contains(&dir_name.as_str()) {
                continue;
            }

            let skill_file = path.join(SKILL_FILE);
            if skill_file.exists() {
                match parse_skill_file(&skill_file, &dir_name).await {
                    Ok((desc, legacy_instr)) => {
                        info!(
                            "Discovered skill '{}' at {}",
                            desc.name,
                            skill_file.display()
                        );
                        found.push((desc, legacy_instr));
                    }
                    Err(e) => {
                        warn!(
                            "Failed to parse '{}', skipping: {}",
                            skill_file.display(),
                            e
                        );
                    }
                }
            }
        }

        Ok(found)
    }

    /// Get a descriptor by name.
    pub fn get_descriptor(&self, name: &str) -> Option<&SkillDescriptor> {
        self.descriptors.get(name)
    }

    /// List all discovered descriptors.
    pub fn list_descriptors(&self) -> Vec<&SkillDescriptor> {
        let mut descs: Vec<&SkillDescriptor> = self.descriptors.values().collect();
        descs.sort_by_key(|d| &d.name);
        descs
    }

    /// Consume the loader and return all descriptors.
    pub fn into_descriptors(self) -> Vec<SkillDescriptor> {
        let mut descs: Vec<SkillDescriptor> = self.descriptors.into_values().collect();
        descs.sort_by(|a, b| a.name.cmp(&b.name));
        descs
    }

    /// Number of discovered skills.
    pub fn skill_count(&self) -> usize {
        self.descriptors.len()
    }

    /// Get legacy instructions for a skill by name, if any.
    pub fn get_legacy_instructions(&self, name: &str) -> Option<&String> {
        self.legacy_instructions.get(name)
    }
}

impl Default for SkillLoader {
    fn default() -> Self {
        Self::new()
    }
}

// -- Parsing --

/// Parse a single SKILL.md file into a `SkillDescriptor` and optional legacy instructions.
///
/// Implements lenient validation per agentskills.io integration guide:
/// - Name mismatch with parent directory -> warn, load anyway
/// - Name exceeds 64 chars -> warn, load anyway
/// - Description missing/empty -> skip (return error)
/// - Unparseable YAML -> skip (return error)
///
/// Returns `(descriptor, legacy_instructions)` where `legacy_instructions`
/// is empty if the skill uses the standard format.
async fn parse_skill_file(path: &Path, parent_dir_name: &str) -> Result<(SkillDescriptor, String)> {
    let content = tokio::fs::read_to_string(path)
        .await
        .map_err(|e| ReactError::Other(format!("Failed to read '{}': {}", path.display(), e)))?;

    let raw = parse_frontmatter(&content)?;

    // Lenient validation
    if raw.description.trim().is_empty() {
        return Err(ReactError::Other(format!(
            "Skill at '{}': description is empty (required per spec)",
            path.display()
        )));
    }

    // Extract legacy instructions before consuming raw
    let legacy_instr = raw.instructions.clone().unwrap_or_default();

    let descriptor = raw.clone().into_descriptor(
        path.to_path_buf()
            .canonicalize()
            .unwrap_or_else(|_| path.to_path_buf()),
    );

    // Warn on name issues
    if descriptor.name != parent_dir_name {
        warn!(
            "Skill '{}' name does not match directory '{}' (loading anyway)",
            descriptor.name, parent_dir_name
        );
    }

    for warning in descriptor.validate_name() {
        warn!("Skill '{}': {}", descriptor.name, warning);
    }

    if raw.is_legacy_format() {
        warn!(
            "Skill '{}' uses legacy SKILL.md format (instructions/resources in frontmatter). \
             Consider migrating to agentskills.io format where the body is the instructions.",
            descriptor.name
        );
    }

    Ok((descriptor, legacy_instr))
}

/// Parse YAML frontmatter from a SKILL.md file.
///
/// Handles the common edge case of unquoted colons in values by retrying
/// with the problematic value wrapped in quotes.
/// Parse YAML frontmatter from a SKILL.md string into a `SkillDescriptor`.
///
/// Useful for manual/programmatic parsing of skill files.
pub fn parse_skill_md(content: &str) -> Result<SkillDescriptor> {
    let raw = parse_frontmatter(content)?;
    Ok(raw.into_descriptor(std::path::PathBuf::new()))
}

fn parse_frontmatter(content: &str) -> Result<RawFrontmatter> {
    let trimmed = content.trim_start();

    if !trimmed.starts_with("---") {
        return Err(ReactError::Other(
            "SKILL.md must begin with YAML frontmatter (---)".to_string(),
        ));
    }

    // Skip the opening --- and the newline after it
    let after_open = trimmed
        .get(3..)
        .unwrap_or("")
        .trim_start_matches('\r')
        .trim_start_matches('\n');

    // Find the closing --- which must be on its own line.
    // This prevents markdown horizontal rules (e.g., `---` mid-document)
    // from being mistaken for the frontmatter terminator.
    // The closing --- must appear at the start of a line.
    let close_idx = after_open
        .find("\n---")
        .ok_or_else(|| ReactError::Other("SKILL.md frontmatter missing closing ---".to_string()))?;

    // Verify the closing --- is actually at the start of a line (not mid-line)
    let yaml_str = &after_open[..close_idx];

    // Ensure there's no trailing content on the closing --- line
    // (the --- should be followed only by whitespace, \r, or \n)
    let after_close_start = &after_open[close_idx + 4..]; // skip "\n---"
    // The first non-whitespace after "---" should be the markdown body or end of file
    // If there's text on the same line as "---", it's not a proper separator
    let close_line_remainder = &after_close_start[..after_close_start
        .find('\n')
        .unwrap_or(after_close_start.len())];
    if !close_line_remainder.trim().is_empty() {
        return Err(ReactError::Other(
            "SKILL.md frontmatter closing --- has trailing content on same line".to_string(),
        ));
    }

    serde_yaml::from_str(yaml_str)
        .map_err(|e| ReactError::Other(format!("SKILL.md YAML parse error: {}", e)))
}

/// Extract the Markdown body from a SKILL.md file (strip frontmatter).
///
/// If the frontmatter contains a legacy `instructions` field, returns that
/// instead of the body.
pub fn extract_instructions(content: &str) -> String {
    if let Ok(raw) = parse_frontmatter(content)
        && let Some(instructions) = raw.instructions
    {
        return instructions;
    }

    let trimmed = content.trim_start();
    if !trimmed.starts_with("---") {
        return content.to_string();
    }

    let after_open = trimmed
        .get(3..)
        .unwrap_or("")
        .trim_start_matches('\r')
        .trim_start_matches('\n');

    if let Some(close_idx) = after_open.find("\n---") {
        let after_close = &after_open[close_idx + 4..];
        after_close
            .trim_start_matches('\r')
            .trim_start_matches('\n')
            .to_string()
    } else {
        content.to_string()
    }
}

// -- Scope resolution --

/// Resolve a `DiscoveryScope` into concrete directory paths to scan.
fn scope_to_dirs(scope: &DiscoveryScope) -> Vec<PathBuf> {
    match scope {
        DiscoveryScope::Project(root) => {
            vec![root.join("skills"), root.join(".agents").join("skills")]
        }
        DiscoveryScope::User => {
            if let Some(home) = dirs::home_dir() {
                vec![home.join(".agents").join("skills")]
            } else {
                warn!("Cannot determine home directory for user-level skill discovery");
                vec![]
            }
        }
        DiscoveryScope::Custom(path) => {
            vec![path.clone()]
        }
    }
}

// -- Tests --

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_parse_frontmatter_standard() {
        let content = r#"---
name: pdf-processing
description: Extract PDF text, fill forms, merge files. Use when handling PDFs.
license: Apache-2.0
metadata:
  author: example-org
  version: "1.0"
---

# PDF Processing

Instructions here.
"#;
        let raw = parse_frontmatter(content).unwrap();
        assert_eq!(raw.name, "pdf-processing");
        assert_eq!(raw.license, Some("Apache-2.0".into()));
        assert!(!raw.is_legacy_format());
    }

    #[test]
    fn test_parse_frontmatter_legacy() {
        let content = r#"---
name: code_review
version: "1.0.0"
description: "Code review skill"
author: "team"
tags: [code, review]
instructions: |
  Review the code carefully.
resources:
  - name: checklist
    path: checklist.md
    description: "Review checklist"
---
"#;
        let raw = parse_frontmatter(content).unwrap();
        assert_eq!(raw.name, "code_review");
        assert!(raw.is_legacy_format());
        assert!(raw.instructions.is_some());
    }

    #[test]
    fn test_parse_frontmatter_missing_description() {
        let content = "---\nname: test\ndescription: \"\"\n---\n";
        let raw = parse_frontmatter(content).unwrap();
        assert!(raw.description.is_empty());
    }

    #[test]
    fn test_parse_frontmatter_no_frontmatter() {
        let content = "# Just markdown";
        assert!(parse_frontmatter(content).is_err());
    }

    #[test]
    fn test_parse_frontmatter_unclosed() {
        let content = "---\nname: test\ndescription: Test\n";
        assert!(parse_frontmatter(content).is_err());
    }

    #[test]
    fn test_extract_instructions_body() {
        let content = "---\nname: test\ndescription: Test\n---\n\n# Instructions\n\nDo stuff.";
        let body = extract_instructions(content);
        assert_eq!(body, "# Instructions\n\nDo stuff.");
    }

    #[test]
    fn test_extract_instructions_legacy() {
        let content =
            "---\nname: test\ndescription: Test\ninstructions: |\n  Do stuff.\n---\n\n# Body";
        let body = extract_instructions(content);
        assert_eq!(body.trim(), "Do stuff.");
    }

    #[test]
    fn test_scope_to_dirs_project() {
        let dirs = scope_to_dirs(&DiscoveryScope::Project(PathBuf::from("/my/project")));
        assert_eq!(dirs.len(), 2);
        assert_eq!(dirs[0], PathBuf::from("/my/project/skills"));
        assert_eq!(dirs[1], PathBuf::from("/my/project/.agents/skills"));
    }

    #[test]
    fn test_scope_to_dirs_custom() {
        let dirs = scope_to_dirs(&DiscoveryScope::Custom(PathBuf::from("/custom/path")));
        assert_eq!(dirs, vec![PathBuf::from("/custom/path")]);
    }

    #[test]
    fn test_allowed_tools_string() {
        let content = "---\nname: test\ndescription: Test\nallowed-tools: Bash(git:*) Read\n---\n";
        let raw = parse_frontmatter(content).unwrap();
        let desc = raw.into_descriptor(PathBuf::from("/test/SKILL.md"));
        assert_eq!(desc.allowed_tools, vec!["Bash(git:*)", "Read"]);
    }
}