1use std::path::{Path, PathBuf};
10
11use anyhow::{Context, Result, bail};
12use serde::{Deserialize, Serialize};
13
14#[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
34const 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
46pub struct EmbeddedFile {
48 pub relative_path: &'static str,
49 pub content: &'static [u8],
50}
51
52pub 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#[derive(Debug, Clone)]
78pub enum Source {
79 SelfEmbedded,
81 Local(PathBuf),
83}
84
85impl Source {
86 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#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct LockEntry {
108 pub name: String,
110 pub source_type: String,
112 pub installed_at: String,
114 pub path: String,
116}
117
118#[derive(Debug, Clone, Default, Serialize, Deserialize)]
120pub struct LockFile {
121 pub skills: Vec<LockEntry>,
123}
124
125impl LockFile {
126 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 if let Ok(lock) = serde_json::from_str::<LockFile>(&raw) {
137 return Ok(lock);
138 }
139 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 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 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#[derive(Debug, Clone)]
183pub struct InstalledSkill {
184 pub name: String,
186 pub path: PathBuf,
188 pub source_str: String,
190}
191
192pub 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
241fn copy_dir_local(src: &Path, dst: &Path) -> Result<()> {
246 #[cfg(unix)]
247 {
248 use std::os::unix::fs::symlink;
249 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 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 copy_dir_recursive(src, dst)
266 }
267 #[cfg(not(unix))]
268 {
269 copy_dir_recursive(src, dst)
270 }
271}
272
273fn 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
292pub 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
309pub fn now_rfc3339() -> String {
311 use std::time::{SystemTime, UNIX_EPOCH};
314 let secs = SystemTime::now()
315 .duration_since(UNIX_EPOCH)
316 .unwrap_or_default()
317 .as_secs();
318 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 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 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}