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