Skip to main content

agent_exec/
skills.rs

1//! Skill installation support for agent-exec.
2//!
3//! Provides:
4//! - `Source` enum for parsing skill source specifications
5//! - Embedded skill data for the built-in `agent-exec` skill
6//! - Lock file reading/writing (`.agents/.skill-lock.json`)
7//! - Skill expansion (copy to `.agents/skills/<name>/`)
8
9use std::path::{Path, PathBuf};
10
11use anyhow::{Context, Result, bail};
12use serde::{Deserialize, Serialize};
13
14// ---------------------------------------------------------------------------
15// Error types
16// ---------------------------------------------------------------------------
17
18/// Error returned when an unrecognised source scheme is provided.
19#[derive(Debug)]
20pub struct UnknownSourceScheme(pub String);
21
22impl std::fmt::Display for UnknownSourceScheme {
23    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
24        write!(
25            f,
26            "unknown_source_scheme: {:?} (supported: self, local:<path>)",
27            self.0
28        )
29    }
30}
31
32impl std::error::Error for UnknownSourceScheme {}
33
34// ---------------------------------------------------------------------------
35// Embedded skill files
36// ---------------------------------------------------------------------------
37
38/// Embedded content of `skills/agent-exec/SKILL.md`.
39const EMBEDDED_SKILL_MD: &[u8] = include_bytes!("../skills/agent-exec/SKILL.md");
40
41/// Represents a single embedded file: relative path within the skill dir and content.
42pub struct EmbeddedFile {
43    pub relative_path: &'static str,
44    pub content: &'static [u8],
45}
46
47/// All embedded files for the built-in `agent-exec` skill.
48pub static EMBEDDED_AGENT_EXEC_FILES: &[EmbeddedFile] = &[EmbeddedFile {
49    relative_path: "SKILL.md",
50    content: EMBEDDED_SKILL_MD,
51}];
52
53// ---------------------------------------------------------------------------
54// Source
55// ---------------------------------------------------------------------------
56
57/// Skill installation source.
58#[derive(Debug, Clone)]
59pub enum Source {
60    /// The built-in `agent-exec` skill (embedded in the binary).
61    SelfEmbedded,
62    /// A skill directory on the local filesystem.
63    Local(PathBuf),
64}
65
66impl Source {
67    /// Parse a source string such as `"self"` or `"local:/path/to/skill"`.
68    ///
69    /// Returns an error with `unknown_source_scheme` context when the scheme
70    /// is not recognised.
71    pub fn parse(s: &str) -> Result<Self> {
72        if s == "self" {
73            return Ok(Source::SelfEmbedded);
74        }
75        if let Some(path) = s.strip_prefix("local:") {
76            return Ok(Source::Local(PathBuf::from(path)));
77        }
78        bail!(UnknownSourceScheme(s.to_string()));
79    }
80}
81
82// ---------------------------------------------------------------------------
83// Lock file
84// ---------------------------------------------------------------------------
85
86/// A single entry in `.skill-lock.json`.
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct LockEntry {
89    /// Skill name (directory name under `.agents/skills/`).
90    pub name: String,
91    /// Source type string used when the skill was installed (e.g. "self", "local").
92    pub source_type: String,
93    /// RFC 3339 timestamp of installation.
94    pub installed_at: String,
95    /// Absolute path to the installed skill directory.
96    pub path: String,
97}
98
99/// Represents the `.agents/.skill-lock.json` file.
100#[derive(Debug, Clone, Default, Serialize, Deserialize)]
101pub struct LockFile {
102    /// Ordered list of installed skills.
103    pub skills: Vec<LockEntry>,
104}
105
106impl LockFile {
107    /// Read the lock file from disk.  Returns an empty lock file if it does
108    /// not exist.  Supports the legacy map format by ignoring unknown shapes
109    /// gracefully.
110    pub fn read(path: &Path) -> Result<Self> {
111        if !path.exists() {
112            return Ok(LockFile::default());
113        }
114        let raw = std::fs::read_to_string(path)
115            .with_context(|| format!("read lock file {}", path.display()))?;
116        // Try the canonical array format first, then the legacy map format.
117        if let Ok(lock) = serde_json::from_str::<LockFile>(&raw) {
118            return Ok(lock);
119        }
120        // Legacy: the file might be a JSON object with a "skills" key that is
121        // a map { name -> entry }.  Attempt to convert it.
122        if let Ok(map) = serde_json::from_str::<serde_json::Value>(&raw)
123            && let Some(obj) = map.get("skills").and_then(|v| v.as_object())
124        {
125            let mut skills = Vec::new();
126            for (name, val) in obj {
127                if let Ok(entry) = serde_json::from_value::<LockEntry>(val.clone()) {
128                    let mut e = entry;
129                    e.name = name.clone();
130                    skills.push(e);
131                }
132            }
133            return Ok(LockFile { skills });
134        }
135        Ok(LockFile::default())
136    }
137
138    /// Write the lock file to disk (canonical array format).
139    pub fn write(&self, path: &Path) -> Result<()> {
140        if let Some(parent) = path.parent() {
141            std::fs::create_dir_all(parent)
142                .with_context(|| format!("create dirs for {}", path.display()))?;
143        }
144        let json = serde_json::to_string_pretty(self).context("serialize lock file")?;
145        std::fs::write(path, json).with_context(|| format!("write lock file {}", path.display()))
146    }
147
148    /// Update or insert an entry for `name`.
149    pub fn upsert(&mut self, entry: LockEntry) {
150        if let Some(existing) = self.skills.iter_mut().find(|e| e.name == entry.name) {
151            *existing = entry;
152        } else {
153            self.skills.push(entry);
154        }
155    }
156}
157
158// ---------------------------------------------------------------------------
159// Installation
160// ---------------------------------------------------------------------------
161
162/// Result of installing a single skill.
163#[derive(Debug, Clone)]
164pub struct InstalledSkill {
165    /// Skill name (directory name under `.agents/skills/`).
166    pub name: String,
167    /// Absolute path to the installed skill directory.
168    pub path: PathBuf,
169    /// Source string used.
170    pub source_str: String,
171}
172
173/// Install a skill from `source` into `agents_dir/skills/<name>/`.
174///
175/// `agents_dir` is the `.agents/` directory (local or global).
176///
177/// Returns information about the installed skill.
178pub fn install(source: &Source, agents_dir: &Path) -> Result<InstalledSkill> {
179    let skills_dir = agents_dir.join("skills");
180    match source {
181        Source::SelfEmbedded => {
182            let name = "agent-exec";
183            let dest = skills_dir.join(name);
184            std::fs::create_dir_all(&dest)
185                .with_context(|| format!("create skill dir {}", dest.display()))?;
186            for file in EMBEDDED_AGENT_EXEC_FILES {
187                let file_dest = dest.join(file.relative_path);
188                if let Some(parent) = file_dest.parent() {
189                    std::fs::create_dir_all(parent)
190                        .with_context(|| format!("create parent dir {}", parent.display()))?;
191                }
192                std::fs::write(&file_dest, file.content)
193                    .with_context(|| format!("write embedded file {}", file_dest.display()))?;
194            }
195            Ok(InstalledSkill {
196                name: name.to_string(),
197                path: dest,
198                source_str: "self".to_string(),
199            })
200        }
201        Source::Local(src_path) => {
202            let name = src_path
203                .file_name()
204                .and_then(|n| n.to_str())
205                .ok_or_else(|| {
206                    anyhow::anyhow!(
207                        "cannot determine skill name from path: {}",
208                        src_path.display()
209                    )
210                })?;
211            let dest = skills_dir.join(name);
212            copy_dir_local(src_path, &dest)?;
213            Ok(InstalledSkill {
214                name: name.to_string(),
215                path: dest,
216                source_str: format!("local:{}", src_path.display()),
217            })
218        }
219    }
220}
221
222/// Copy a directory from `src` to `dst`.
223///
224/// On Unix, attempts to create a symlink first; falls back to recursive copy.
225/// On Windows, always performs a recursive copy.
226fn copy_dir_local(src: &Path, dst: &Path) -> Result<()> {
227    #[cfg(unix)]
228    {
229        use std::os::unix::fs::symlink;
230        // Remove existing target if present.
231        if dst.exists() || dst.symlink_metadata().is_ok() {
232            if dst.is_symlink() || dst.is_file() {
233                std::fs::remove_file(dst).ok();
234            } else if dst.is_dir() {
235                std::fs::remove_dir_all(dst).ok();
236            }
237        }
238        // Canonicalize source for a stable symlink target.
239        let abs_src = src
240            .canonicalize()
241            .with_context(|| format!("canonicalize local skill source path {}", src.display()))?;
242        if symlink(&abs_src, dst).is_ok() {
243            return Ok(());
244        }
245        // Symlink failed; fall back to copy.
246        copy_dir_recursive(src, dst)
247    }
248    #[cfg(not(unix))]
249    {
250        copy_dir_recursive(src, dst)
251    }
252}
253
254/// Recursively copy all files and directories from `src` to `dst`.
255fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
256    std::fs::create_dir_all(dst).with_context(|| format!("create dir {}", dst.display()))?;
257    for entry in std::fs::read_dir(src).with_context(|| format!("read dir {}", src.display()))? {
258        let entry = entry.with_context(|| format!("iterate dir {}", src.display()))?;
259        let file_type = entry.file_type().context("get file type")?;
260        let src_path = entry.path();
261        let dst_path = dst.join(entry.file_name());
262        if file_type.is_dir() {
263            copy_dir_recursive(&src_path, &dst_path)?;
264        } else {
265            std::fs::copy(&src_path, &dst_path).with_context(|| {
266                format!("copy {} to {}", src_path.display(), dst_path.display())
267            })?;
268        }
269    }
270    Ok(())
271}
272
273/// Resolve the `.agents/` directory.
274///
275/// If `global` is true, returns `~/.agents/`.
276/// Otherwise returns `<cwd>/.agents/`.
277pub fn resolve_agents_dir(global: bool) -> Result<PathBuf> {
278    if global {
279        let home = directories::UserDirs::new()
280            .ok_or_else(|| anyhow::anyhow!("cannot determine home directory"))?
281            .home_dir()
282            .to_path_buf();
283        Ok(home.join(".agents"))
284    } else {
285        let cwd = std::env::current_dir().context("get current directory")?;
286        Ok(cwd.join(".agents"))
287    }
288}
289
290/// Get the timestamp in RFC 3339 format.
291pub fn now_rfc3339() -> String {
292    // Use a simple implementation without external chrono dependency.
293    // std::time provides SystemTime which we format manually.
294    use std::time::{SystemTime, UNIX_EPOCH};
295    let secs = SystemTime::now()
296        .duration_since(UNIX_EPOCH)
297        .unwrap_or_default()
298        .as_secs();
299    // Format as RFC 3339 UTC: YYYY-MM-DDTHH:MM:SSZ
300    let s = secs;
301    let sec = s % 60;
302    let s = s / 60;
303    let min = s % 60;
304    let s = s / 60;
305    let hour = s % 24;
306    let days = s / 24;
307    // Convert days since epoch to date.
308    let (year, month, day) = days_to_ymd(days);
309    format!(
310        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
311        year, month, day, hour, min, sec
312    )
313}
314
315fn days_to_ymd(mut days: u64) -> (u64, u64, u64) {
316    // Gregorian calendar computation.
317    let mut year = 1970u64;
318    loop {
319        let leap = is_leap(year);
320        let days_in_year = if leap { 366 } else { 365 };
321        if days < days_in_year {
322            break;
323        }
324        days -= days_in_year;
325        year += 1;
326    }
327    let leap = is_leap(year);
328    let months = [
329        31u64,
330        if leap { 29 } else { 28 },
331        31,
332        30,
333        31,
334        30,
335        31,
336        31,
337        30,
338        31,
339        30,
340        31,
341    ];
342    let mut month = 1u64;
343    for &dim in &months {
344        if days < dim {
345            break;
346        }
347        days -= dim;
348        month += 1;
349    }
350    (year, month, days + 1)
351}
352
353fn is_leap(year: u64) -> bool {
354    (year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400)
355}