Skip to main content

adk_skill/
writer.rs

1//! Writing skills to disk.
2//!
3//! Everything else in this crate reads: [`discover_skill_files`](crate::discover_skill_files)
4//! walks a root, [`parse_skill_markdown`](crate::parse_skill_markdown) turns a file into a
5//! [`ParsedSkill`](crate::ParsedSkill), and [`SkillIndex`](crate::SkillIndex) holds the result.
6//! There was no write path, so an agent could not persist a skill it derived at runtime and an
7//! operator could not generate one programmatically.
8//!
9//! [`SkillWriter`] closes that, writing into the `.skills` directory
10//! [`load_skill_index`](crate::load_skill_index) already discovers.
11
12use std::collections::HashMap;
13use std::io::Write;
14use std::path::{Path, PathBuf};
15
16use serde::Serialize;
17use serde_json::Value;
18
19use crate::error::{SkillError, SkillResult};
20
21/// Longest permitted skill name, from the `agentskills.io` field definition.
22const MAX_NAME_LEN: usize = 64;
23
24/// Directory, relative to a writer's root, that skills are written into.
25const SKILLS_DIR: &str = ".skills";
26
27/// Checks that `name` is a valid skill identifier.
28///
29/// The specification allows 1–64 characters of lowercase letters, digits, and hyphens. A name may
30/// not begin or end with a hyphen.
31///
32/// This is also the path-safety boundary: because a name becomes a filename, rejecting everything
33/// outside `[a-z0-9-]` is what prevents a caller from escaping the skills directory with `..` or a
34/// path separator.
35///
36/// # Errors
37///
38/// Returns [`SkillError::Validation`] naming the specific rule that failed.
39///
40/// # Example
41///
42/// ```rust
43/// use adk_skill::validate_skill_name;
44///
45/// assert!(validate_skill_name("disk-triage").is_ok());
46/// assert!(validate_skill_name("../escape").is_err());
47/// ```
48pub fn validate_skill_name(name: &str) -> SkillResult<()> {
49    if name.is_empty() {
50        return Err(SkillError::Validation("skill name must not be empty".to_string()));
51    }
52
53    if name.chars().count() > MAX_NAME_LEN {
54        return Err(SkillError::Validation(format!(
55            "skill name must be at most {MAX_NAME_LEN} characters, got {}",
56            name.chars().count()
57        )));
58    }
59
60    if let Some(bad) = name.chars().find(|c| !matches!(c, 'a'..='z' | '0'..='9' | '-')) {
61        return Err(SkillError::Validation(format!(
62            "skill name must contain only lowercase letters, digits, and hyphens; found {bad:?} \
63             in {name:?}"
64        )));
65    }
66
67    if name.starts_with('-') || name.ends_with('-') {
68        return Err(SkillError::Validation(format!(
69            "skill name must not begin or end with a hyphen: {name:?}"
70        )));
71    }
72
73    Ok(())
74}
75
76/// Frontmatter as written, omitting anything unset so a generated file stays readable.
77///
78/// Separate from [`SkillFrontmatter`](crate::SkillFrontmatter), which is a parse target and
79/// serializes every field including empty ones.
80#[derive(Debug, Serialize)]
81struct FrontmatterOut<'a> {
82    name: &'a str,
83    description: &'a str,
84    #[serde(skip_serializing_if = "Option::is_none")]
85    version: &'a Option<String>,
86    #[serde(skip_serializing_if = "Option::is_none")]
87    license: &'a Option<String>,
88    #[serde(skip_serializing_if = "Option::is_none")]
89    compatibility: &'a Option<String>,
90    #[serde(skip_serializing_if = "Vec::is_empty")]
91    tags: &'a Vec<String>,
92    #[serde(rename = "allowed-tools", skip_serializing_if = "Vec::is_empty")]
93    allowed_tools: &'a Vec<String>,
94    #[serde(skip_serializing_if = "Vec::is_empty")]
95    references: &'a Vec<String>,
96    #[serde(skip_serializing_if = "is_false")]
97    trigger: bool,
98    #[serde(skip_serializing_if = "Option::is_none")]
99    hint: &'a Option<String>,
100    #[serde(skip_serializing_if = "HashMap::is_empty")]
101    metadata: &'a HashMap<String, Value>,
102    #[serde(skip_serializing_if = "Vec::is_empty")]
103    triggers: &'a Vec<String>,
104}
105
106fn is_false(value: &bool) -> bool {
107    !*value
108}
109
110/// A skill to be written to disk.
111///
112/// `name` and `description` are required by the specification; everything else is optional and is
113/// omitted from the generated file when unset.
114///
115/// # Example
116///
117/// ```rust
118/// use adk_skill::SkillDraft;
119///
120/// let draft = SkillDraft::new("disk-triage", "Diagnose low disk space. Use when a disk alert fires.")
121///     .with_body("1. Check the largest directories.\n2. Report growth rate.")
122///     .with_tags(["ops"])
123///     .with_allowed_tools(["read_file"]);
124///
125/// assert_eq!(draft.name(), "disk-triage");
126/// ```
127#[derive(Debug, Clone, Default)]
128pub struct SkillDraft {
129    name: String,
130    description: String,
131    body: String,
132    version: Option<String>,
133    license: Option<String>,
134    compatibility: Option<String>,
135    tags: Vec<String>,
136    allowed_tools: Vec<String>,
137    references: Vec<String>,
138    trigger: bool,
139    hint: Option<String>,
140    metadata: HashMap<String, Value>,
141    triggers: Vec<String>,
142}
143
144impl SkillDraft {
145    /// Creates a draft with the two required fields.
146    pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
147        Self { name: name.into(), description: description.into(), ..Default::default() }
148    }
149
150    /// Sets the instructional Markdown body.
151    pub fn with_body(mut self, body: impl Into<String>) -> Self {
152        self.body = body.into();
153        self
154    }
155
156    /// Sets the version identifier.
157    pub fn with_version(mut self, version: impl Into<String>) -> Self {
158        self.version = Some(version.into());
159        self
160    }
161
162    /// Sets the license identifier.
163    pub fn with_license(mut self, license: impl Into<String>) -> Self {
164        self.license = Some(license.into());
165        self
166    }
167
168    /// Sets the environment requirements.
169    pub fn with_compatibility(mut self, compatibility: impl Into<String>) -> Self {
170        self.compatibility = Some(compatibility.into());
171        self
172    }
173
174    /// Sets the discovery tags.
175    pub fn with_tags<I, S>(mut self, tags: I) -> Self
176    where
177        I: IntoIterator<Item = S>,
178        S: Into<String>,
179    {
180        self.tags = tags.into_iter().map(Into::into).collect();
181        self
182    }
183
184    /// Sets the pre-approved tool names.
185    pub fn with_allowed_tools<I, S>(mut self, tools: I) -> Self
186    where
187        I: IntoIterator<Item = S>,
188        S: Into<String>,
189    {
190        self.allowed_tools = tools.into_iter().map(Into::into).collect();
191        self
192    }
193
194    /// Sets the supporting resource paths.
195    pub fn with_references<I, S>(mut self, references: I) -> Self
196    where
197        I: IntoIterator<Item = S>,
198        S: Into<String>,
199    {
200        self.references = references.into_iter().map(Into::into).collect();
201        self
202    }
203
204    /// Requires explicit invocation by name rather than automatic selection.
205    pub fn with_trigger(mut self, trigger: bool) -> Self {
206        self.trigger = trigger;
207        self
208    }
209
210    /// Sets the guided-input hint.
211    pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
212        self.hint = Some(hint.into());
213        self
214    }
215
216    /// Sets the extension metadata.
217    ///
218    /// Useful for recording provenance — which incident a learned skill came from, when it was
219    /// promoted, and what evidence supported it.
220    pub fn with_metadata(mut self, metadata: HashMap<String, Value>) -> Self {
221        self.metadata = metadata;
222        self
223    }
224
225    /// Adds one extension metadata entry.
226    pub fn with_metadata_entry(mut self, key: impl Into<String>, value: Value) -> Self {
227        self.metadata.insert(key.into(), value);
228        self
229    }
230
231    /// Sets the file glob patterns that activate this skill.
232    pub fn with_triggers<I, S>(mut self, triggers: I) -> Self
233    where
234        I: IntoIterator<Item = S>,
235        S: Into<String>,
236    {
237        self.triggers = triggers.into_iter().map(Into::into).collect();
238        self
239    }
240
241    /// The skill's name.
242    pub fn name(&self) -> &str {
243        &self.name
244    }
245
246    /// Checks the draft against the specification.
247    ///
248    /// # Errors
249    ///
250    /// Returns [`SkillError::Validation`] when the name is invalid or the description is empty.
251    pub fn validate(&self) -> SkillResult<()> {
252        validate_skill_name(&self.name)?;
253
254        if self.description.trim().is_empty() {
255            return Err(SkillError::Validation(format!(
256                "skill {:?} must have a non-empty description; it is what an agent matches on",
257                self.name
258            )));
259        }
260
261        Ok(())
262    }
263
264    /// Renders the draft as skill Markdown: YAML frontmatter, then the body.
265    ///
266    /// The output is what [`parse_skill_markdown`](crate::parse_skill_markdown) accepts, so a
267    /// draft written and reparsed yields equivalent content.
268    ///
269    /// # Errors
270    ///
271    /// Returns an error if the draft fails [`validate`](Self::validate) or the frontmatter cannot
272    /// be serialized.
273    pub fn to_markdown(&self) -> SkillResult<String> {
274        self.validate()?;
275
276        let frontmatter = serde_yaml::to_string(&FrontmatterOut {
277            name: &self.name,
278            description: &self.description,
279            version: &self.version,
280            license: &self.license,
281            compatibility: &self.compatibility,
282            tags: &self.tags,
283            allowed_tools: &self.allowed_tools,
284            references: &self.references,
285            trigger: self.trigger,
286            hint: &self.hint,
287            metadata: &self.metadata,
288            triggers: &self.triggers,
289        })?;
290
291        Ok(format!("---\n{}---\n\n{}\n", frontmatter, self.body.trim()))
292    }
293}
294
295/// Writes skills into a root's `.skills` directory.
296///
297/// The destination is the directory [`load_skill_index`](crate::load_skill_index) already
298/// discovers, so a skill written here is picked up by the next index load.
299///
300/// # Example
301///
302/// ```rust
303/// use adk_skill::{SkillDraft, SkillWriter, load_skill_index};
304///
305/// # fn main() -> adk_skill::SkillResult<()> {
306/// let root = tempfile::tempdir().unwrap();
307/// let writer = SkillWriter::new(root.path());
308///
309/// writer.write(&SkillDraft::new("disk-triage", "Diagnose low disk space.")
310///     .with_body("Check the largest directories first."))?;
311///
312/// let index = load_skill_index(root.path())?;
313/// assert!(index.find_by_name("disk-triage").is_some());
314/// # Ok(())
315/// # }
316/// ```
317#[derive(Debug, Clone)]
318pub struct SkillWriter {
319    root: PathBuf,
320}
321
322impl SkillWriter {
323    /// Creates a writer targeting `<root>/.skills`.
324    ///
325    /// Pass the same root given to [`load_skill_index`](crate::load_skill_index).
326    pub fn new(root: impl Into<PathBuf>) -> Self {
327        Self { root: root.into() }
328    }
329
330    /// The directory skills are written into.
331    pub fn skills_dir(&self) -> PathBuf {
332        self.root.join(SKILLS_DIR)
333    }
334
335    /// The path a skill of this name occupies.
336    ///
337    /// # Errors
338    ///
339    /// Returns [`SkillError::Validation`] if the name is not a valid identifier.
340    pub fn path_for(&self, name: &str) -> SkillResult<PathBuf> {
341        validate_skill_name(name)?;
342        Ok(self.skills_dir().join(format!("{name}.md")))
343    }
344
345    /// Writes `draft`, replacing any existing skill of the same name, and returns its path.
346    ///
347    /// The write goes to a temporary file in the same directory and is then renamed, so a crash
348    /// mid-write cannot leave a half-written skill that fails to parse and breaks the whole index
349    /// load. Missing directories are created.
350    ///
351    /// # Errors
352    ///
353    /// Returns [`SkillError::Validation`] if the draft is invalid, or an IO error if the write
354    /// fails.
355    pub fn write(&self, draft: &SkillDraft) -> SkillResult<PathBuf> {
356        let rendered = draft.to_markdown()?;
357        let path = self.path_for(&draft.name)?;
358
359        let dir = self.skills_dir();
360        std::fs::create_dir_all(&dir)?;
361
362        // `NamedTempFile::persist` uses replace-existing semantics on Windows as well as Unix.
363        // A unique name also lets independent writers update different skills concurrently.
364        let mut temporary = tempfile::NamedTempFile::new_in(&dir)?;
365        temporary.write_all(rendered.as_bytes())?;
366        temporary.as_file().sync_all()?;
367        temporary.persist(&path).map_err(|error| SkillError::Io(error.error))?;
368
369        // On Unix, syncing the directory makes the rename durable across a power loss. Windows'
370        // directory handles do not support this operation, while `persist` still supplies the
371        // required atomic replace semantics there.
372        #[cfg(unix)]
373        std::fs::File::open(&dir)?.sync_all()?;
374
375        tracing::debug!(skill = %draft.name, path = %path.display(), "wrote skill");
376        Ok(path)
377    }
378
379    /// Removes the skill of this name, returning whether a file was present.
380    ///
381    /// # Errors
382    ///
383    /// Returns [`SkillError::Validation`] if the name is invalid, or an IO error if removal fails
384    /// for a reason other than the file being absent.
385    pub fn remove(&self, name: &str) -> SkillResult<bool> {
386        let path = self.path_for(name)?;
387
388        match std::fs::remove_file(&path) {
389            Ok(()) => {
390                tracing::debug!(skill = %name, "removed skill");
391                Ok(true)
392            }
393            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false),
394            Err(err) => Err(SkillError::Io(err)),
395        }
396    }
397
398    /// Whether a skill of this name exists.
399    ///
400    /// # Errors
401    ///
402    /// Returns [`SkillError::Validation`] if the name is invalid.
403    pub fn exists(&self, name: &str) -> SkillResult<bool> {
404        Ok(self.path_for(name)?.is_file())
405    }
406
407    /// The root this writer targets.
408    pub fn root(&self) -> &Path {
409        &self.root
410    }
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416    use crate::index::load_skill_index;
417    use crate::parser::parse_skill_markdown;
418
419    #[test]
420    fn valid_names_are_accepted() {
421        for name in ["a", "disk-triage", "sweep-2", "0", &"x".repeat(MAX_NAME_LEN)] {
422            assert!(validate_skill_name(name).is_ok(), "should accept {name:?}");
423        }
424    }
425
426    #[test]
427    fn invalid_names_are_rejected() {
428        for name in [
429            "",
430            "Disk-Triage",
431            "disk triage",
432            "disk_triage",
433            "-leading",
434            "trailing-",
435            "../escape",
436            "nested/name",
437            "dot.name",
438            &"x".repeat(MAX_NAME_LEN + 1),
439        ] {
440            assert!(validate_skill_name(name).is_err(), "should reject {name:?}");
441        }
442    }
443
444    #[test]
445    fn a_draft_round_trips_through_the_parser() {
446        let draft = SkillDraft::new("disk-triage", "Diagnose low disk space")
447            .with_body("1. Check largest directories.\n2. Report growth rate.")
448            .with_version("1.2.3")
449            .with_license("Apache-2.0")
450            .with_compatibility("Requires read access to the filesystem")
451            .with_tags(["ops", "storage"])
452            .with_allowed_tools(["read_file", "run_command"])
453            .with_references(["references/thresholds.md"])
454            .with_trigger(true)
455            .with_hint("name a mount point")
456            .with_triggers(["*.log"])
457            .with_metadata_entry("incident", Value::from("INC-42"));
458
459        let rendered = draft.to_markdown().expect("renders");
460        let parsed = parse_skill_markdown(Path::new("disk-triage.md"), &rendered).expect("parses");
461
462        assert_eq!(parsed.name, "disk-triage");
463        assert_eq!(parsed.description, "Diagnose low disk space");
464        assert_eq!(parsed.version.as_deref(), Some("1.2.3"));
465        assert_eq!(parsed.license.as_deref(), Some("Apache-2.0"));
466        assert_eq!(parsed.compatibility.as_deref(), Some("Requires read access to the filesystem"));
467        assert_eq!(parsed.tags, vec!["ops", "storage"]);
468        assert_eq!(parsed.allowed_tools, vec!["read_file", "run_command"]);
469        assert_eq!(parsed.references, vec!["references/thresholds.md"]);
470        assert!(parsed.trigger);
471        assert_eq!(parsed.hint.as_deref(), Some("name a mount point"));
472        assert_eq!(parsed.triggers, vec!["*.log"]);
473        assert_eq!(parsed.metadata.get("incident"), Some(&Value::from("INC-42")));
474        assert_eq!(parsed.body, "1. Check largest directories.\n2. Report growth rate.");
475    }
476
477    #[test]
478    fn a_minimal_draft_omits_unset_fields() {
479        let rendered = SkillDraft::new("minimal", "Only the required fields")
480            .with_body("Body.")
481            .to_markdown()
482            .expect("renders");
483
484        for absent in ["version:", "license:", "tags:", "allowed-tools:", "trigger:", "metadata:"] {
485            assert!(!rendered.contains(absent), "{absent} should be omitted from:\n{rendered}");
486        }
487        assert!(parse_skill_markdown(Path::new("minimal.md"), &rendered).is_ok());
488    }
489
490    #[test]
491    fn an_empty_description_is_rejected() {
492        let error = SkillDraft::new("named", "   ")
493            .with_body("Body.")
494            .to_markdown()
495            .expect_err("an empty description must not be written");
496
497        assert!(error.to_string().contains("description"), "got {error}");
498    }
499
500    #[test]
501    fn an_invalid_name_is_rejected_before_any_file_is_touched() {
502        let root = tempfile::tempdir().expect("tempdir");
503        let writer = SkillWriter::new(root.path());
504
505        assert!(writer.write(&SkillDraft::new("../escape", "Traversal attempt")).is_err());
506        assert!(
507            !root.path().join(SKILLS_DIR).exists(),
508            "a rejected name must not create the skills directory"
509        );
510    }
511
512    #[test]
513    fn a_written_skill_is_discovered_by_the_index() {
514        let root = tempfile::tempdir().expect("tempdir");
515        let writer = SkillWriter::new(root.path());
516
517        let path = writer
518            .write(
519                &SkillDraft::new("disk-triage", "Diagnose low disk space")
520                    .with_body("Check the largest directories."),
521            )
522            .expect("writes");
523
524        assert!(path.is_file());
525        let index = load_skill_index(root.path()).expect("index loads");
526        let found = index.find_by_name("disk-triage").expect("skill is indexed");
527        assert_eq!(found.description, "Diagnose low disk space");
528        assert_eq!(found.body, "Check the largest directories.");
529    }
530
531    #[test]
532    fn writing_the_same_name_replaces_the_previous_skill() {
533        let root = tempfile::tempdir().expect("tempdir");
534        let writer = SkillWriter::new(root.path());
535
536        writer.write(&SkillDraft::new("sweep", "First").with_body("One.")).expect("first write");
537        writer.write(&SkillDraft::new("sweep", "Second").with_body("Two.")).expect("second write");
538
539        let index = load_skill_index(root.path()).expect("index loads");
540        assert_eq!(index.len(), 1, "replacing must not leave a duplicate");
541        assert_eq!(index.find_by_name("sweep").expect("present").description, "Second");
542    }
543
544    #[test]
545    fn concurrent_writers_do_not_share_a_temporary_path() {
546        let root = tempfile::tempdir().expect("tempdir");
547        let writer = SkillWriter::new(root.path());
548
549        std::thread::scope(|scope| {
550            let first = writer.clone();
551            scope.spawn(move || {
552                first
553                    .write(&SkillDraft::new("first", "First skill").with_body("One."))
554                    .expect("first write");
555            });
556            let second = writer.clone();
557            scope.spawn(move || {
558                second
559                    .write(&SkillDraft::new("second", "Second skill").with_body("Two."))
560                    .expect("second write");
561            });
562        });
563
564        let index = load_skill_index(root.path()).expect("index loads");
565        assert!(index.find_by_name("first").is_some());
566        assert!(index.find_by_name("second").is_some());
567    }
568
569    #[test]
570    fn writing_leaves_no_temporary_file_behind() {
571        let root = tempfile::tempdir().expect("tempdir");
572        let writer = SkillWriter::new(root.path());
573        writer.write(&SkillDraft::new("sweep", "Description").with_body("Body.")).expect("writes");
574
575        let leftovers: Vec<_> = std::fs::read_dir(writer.skills_dir())
576            .expect("read dir")
577            .filter_map(|entry| entry.ok())
578            .map(|entry| entry.file_name().to_string_lossy().to_string())
579            .filter(|name| name.ends_with(".tmp"))
580            .collect();
581
582        assert!(leftovers.is_empty(), "found temporary files: {leftovers:?}");
583    }
584
585    #[test]
586    fn remove_reports_whether_a_skill_was_present() {
587        let root = tempfile::tempdir().expect("tempdir");
588        let writer = SkillWriter::new(root.path());
589        writer.write(&SkillDraft::new("sweep", "Description").with_body("Body.")).expect("writes");
590
591        assert!(writer.exists("sweep").expect("exists"));
592        assert!(writer.remove("sweep").expect("removes"), "the skill was present");
593        assert!(!writer.remove("sweep").expect("second remove"), "already gone");
594        assert!(!writer.exists("sweep").expect("exists"));
595    }
596
597    #[test]
598    fn remove_rejects_an_invalid_name() {
599        let root = tempfile::tempdir().expect("tempdir");
600        let writer = SkillWriter::new(root.path());
601
602        assert!(writer.remove("../escape").is_err());
603    }
604}