Skip to main content

agent_exec/
skills.rs

1//! Embedded skill installation support for agent-exec.
2//!
3//! `install-skills` is intentionally narrow: it installs only the built-in
4//! `agent-exec` skill into `.agents/skills/` or `.claude/skills/` and records
5//! the result in `.skill-lock.json`.
6
7use std::path::{Path, PathBuf};
8
9use anyhow::{Context, Result};
10use serde::{Deserialize, Serialize};
11
12/// Embedded content of `skills/agent-exec/SKILL.md`.
13const EMBEDDED_SKILL_MD: &[u8] = include_bytes!("../skills/agent-exec/SKILL.md");
14const EMBEDDED_CLI_CONTRACT_MD: &[u8] =
15    include_bytes!("../skills/agent-exec/references/cli-contract.md");
16const EMBEDDED_COMPLETION_EVENTS_MD: &[u8] =
17    include_bytes!("../skills/agent-exec/references/completion-events.md");
18const EMBEDDED_OPENCLAW_MD: &[u8] = include_bytes!("../skills/agent-exec/references/openclaw.md");
19
20/// Represents a single embedded file: relative path within the skill dir and content.
21pub struct EmbeddedFile {
22    pub relative_path: &'static str,
23    pub content: &'static [u8],
24}
25
26/// All embedded files for the built-in `agent-exec` skill.
27pub static EMBEDDED_AGENT_EXEC_FILES: &[EmbeddedFile] = &[
28    EmbeddedFile {
29        relative_path: "SKILL.md",
30        content: EMBEDDED_SKILL_MD,
31    },
32    EmbeddedFile {
33        relative_path: "references/cli-contract.md",
34        content: EMBEDDED_CLI_CONTRACT_MD,
35    },
36    EmbeddedFile {
37        relative_path: "references/completion-events.md",
38        content: EMBEDDED_COMPLETION_EVENTS_MD,
39    },
40    EmbeddedFile {
41        relative_path: "references/openclaw.md",
42        content: EMBEDDED_OPENCLAW_MD,
43    },
44];
45
46/// A single entry in `.skill-lock.json`.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct LockEntry {
49    /// Skill name (directory name under `.agents/skills/`).
50    pub name: String,
51    /// Source type string used when the skill was installed.
52    pub source_type: String,
53    /// RFC 3339 timestamp of installation.
54    pub installed_at: String,
55    /// Absolute path to the installed skill directory.
56    pub path: String,
57}
58
59/// Represents the `.agents/.skill-lock.json` file.
60#[derive(Debug, Clone, Default, Serialize, Deserialize)]
61pub struct LockFile {
62    /// Ordered list of installed skills.
63    pub skills: Vec<LockEntry>,
64}
65
66impl LockFile {
67    pub fn read(path: &Path) -> Result<Self> {
68        if !path.exists() {
69            return Ok(LockFile::default());
70        }
71        let raw = std::fs::read_to_string(path)
72            .with_context(|| format!("read lock file {}", path.display()))?;
73        if let Ok(lock) = serde_json::from_str::<LockFile>(&raw) {
74            return Ok(lock);
75        }
76        if let Ok(map) = serde_json::from_str::<serde_json::Value>(&raw)
77            && let Some(obj) = map.get("skills").and_then(|v| v.as_object())
78        {
79            let mut skills = Vec::new();
80            for (name, val) in obj {
81                if let Ok(entry) = serde_json::from_value::<LockEntry>(val.clone()) {
82                    let mut e = entry;
83                    e.name = name.clone();
84                    skills.push(e);
85                }
86            }
87            return Ok(LockFile { skills });
88        }
89        Ok(LockFile::default())
90    }
91
92    pub fn write(&self, path: &Path) -> Result<()> {
93        if let Some(parent) = path.parent() {
94            std::fs::create_dir_all(parent)
95                .with_context(|| format!("create dirs for {}", path.display()))?;
96        }
97        let json = serde_json::to_string_pretty(self).context("serialize lock file")?;
98        std::fs::write(path, json).with_context(|| format!("write lock file {}", path.display()))
99    }
100
101    pub fn upsert(&mut self, entry: LockEntry) {
102        if let Some(existing) = self.skills.iter_mut().find(|e| e.name == entry.name) {
103            *existing = entry;
104        } else {
105            self.skills.push(entry);
106        }
107    }
108}
109
110/// Result of installing the built-in skill.
111#[derive(Debug, Clone)]
112pub struct InstalledSkill {
113    pub name: String,
114    pub path: PathBuf,
115    pub source_type: String,
116}
117
118/// Install the built-in `agent-exec` skill into `agents_dir/skills/agent-exec/`.
119pub fn install_builtin(agents_dir: &Path) -> Result<InstalledSkill> {
120    let name = "agent-exec";
121    let dest = agents_dir.join("skills").join(name);
122    std::fs::create_dir_all(&dest)
123        .with_context(|| format!("create skill dir {}", dest.display()))?;
124    for file in EMBEDDED_AGENT_EXEC_FILES {
125        let file_dest = dest.join(file.relative_path);
126        if let Some(parent) = file_dest.parent() {
127            std::fs::create_dir_all(parent)
128                .with_context(|| format!("create parent dir {}", parent.display()))?;
129        }
130        std::fs::write(&file_dest, file.content)
131            .with_context(|| format!("write embedded file {}", file_dest.display()))?;
132    }
133    Ok(InstalledSkill {
134        name: name.to_string(),
135        path: dest,
136        source_type: "embedded".to_string(),
137    })
138}
139
140/// Resolve the root directory for skill installation.
141pub fn resolve_root_dir(global: bool, claude: bool) -> Result<PathBuf> {
142    let root_name = if claude { ".claude" } else { ".agents" };
143    if global {
144        let home = directories::UserDirs::new()
145            .ok_or_else(|| anyhow::anyhow!("cannot determine home directory"))?
146            .home_dir()
147            .to_path_buf();
148        Ok(home.join(root_name))
149    } else {
150        let cwd = std::env::current_dir().context("get current directory")?;
151        Ok(cwd.join(root_name))
152    }
153}
154
155/// Get the timestamp in RFC 3339 format.
156pub fn now_rfc3339() -> String {
157    use std::time::{SystemTime, UNIX_EPOCH};
158    let secs = SystemTime::now()
159        .duration_since(UNIX_EPOCH)
160        .unwrap_or_default()
161        .as_secs();
162    let s = secs;
163    let sec = s % 60;
164    let s = s / 60;
165    let min = s % 60;
166    let s = s / 60;
167    let hour = s % 24;
168    let days = s / 24;
169    let (year, month, day) = days_to_ymd(days);
170    format!(
171        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
172        year, month, day, hour, min, sec
173    )
174}
175
176fn days_to_ymd(mut days: u64) -> (u64, u64, u64) {
177    let mut year = 1970u64;
178    loop {
179        let leap = is_leap(year);
180        let days_in_year = if leap { 366 } else { 365 };
181        if days < days_in_year {
182            break;
183        }
184        days -= days_in_year;
185        year += 1;
186    }
187    let leap = is_leap(year);
188    let months = [
189        31u64,
190        if leap { 29 } else { 28 },
191        31,
192        30,
193        31,
194        30,
195        31,
196        31,
197        30,
198        31,
199        30,
200        31,
201    ];
202    let mut month = 1u64;
203    for &dim in &months {
204        if days < dim {
205            break;
206        }
207        days -= dim;
208        month += 1;
209    }
210    (year, month, days + 1)
211}
212
213fn is_leap(year: u64) -> bool {
214    (year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400)
215}