Skip to main content

jev/
skill.rs

1//! The Agent Skills this crate ships, compiled in: what `jev add skill` writes into a project,
2//! and what dx writes into one when it turns jev on.
3//!
4//! There are two, because there are two ways an agent is given jev and a skill that teaches the
5//! wrong one is worse than none. [`Flavour::Command`] teaches the command — `jev -q questions.json
6//! -f …`, the shell, `jq` — and [`Flavour::Tool`] teaches the same substance for an agent whose
7//! jev is a tool it calls with JSON, which has no shell to run a command line in. Keep the two in
8//! step when either changes.
9//!
10//! They live here rather than in `cli/` so that a crate with only the `command` feature — dx,
11//! which offers both shapes — can write them without the CLI's dependencies.
12
13/// Which jev a skill teaches.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15pub enum Flavour {
16    /// The `jev` command, in a shell. `skills/jev/`.
17    #[default]
18    Command,
19    /// An agent's `jev` tool, called with JSON. `skills/jev-tool/`.
20    Tool,
21}
22
23/// `cargo build` picks up an edit to any of these.
24const COMMAND: [(&str, &str); 2] = [
25    ("SKILL.md", include_str!("../skills/jev/SKILL.md")),
26    ("references/patterns.md", include_str!("../skills/jev/references/patterns.md")),
27];
28
29const TOOL: [(&str, &str); 2] = [
30    ("SKILL.md", include_str!("../skills/jev-tool/SKILL.md")),
31    ("references/patterns.md", include_str!("../skills/jev-tool/references/patterns.md")),
32];
33
34impl Flavour {
35    /// The skill's files, each path relative to the skill's own folder.
36    ///
37    /// Both flavours are named `jev` in their frontmatter and go in a folder called `jev`, so a
38    /// project holds one of them at a time: an agent that had both would be told two different
39    /// ways to ask, and dx would leave the second out as a skill it already has.
40    pub fn files(self) -> [(&'static str, &'static str); 2] {
41        match self {
42            Flavour::Command => COMMAND,
43            Flavour::Tool => TOOL,
44        }
45    }
46
47    /// The other one: what a project carrying the wrong flavour has in it.
48    pub fn other(self) -> Flavour {
49        match self {
50            Flavour::Command => Flavour::Tool,
51            Flavour::Tool => Flavour::Command,
52        }
53    }
54}
55
56/// The folder a skill is written to, under `.agents/skills` or `.claude/skills`. Both flavours
57/// share it, since the frontmatter names both `jev`.
58pub const FOLDER: &str = "jev";
59
60/// Refuses a destination reached through a symbolic link. Walks what exists of `file` below
61/// `root`, since it is the parts already on disk that could redirect a write; std has no way to
62/// open a path without following links, so they are found and refused rather than avoided.
63///
64/// Both writers use it — `jev add skill` and dx, which writes a skill into the project it works
65/// on — because `create_dir_all` and `fs::write` follow links, and a checkout carrying
66/// `.agents/skills -> /somewhere` would otherwise have the skill written outside the project.
67///
68/// Not gated to native: dx's writer takes a folder or a `Vfs` and compiles for both, and on
69/// `wasm32` there is no file system for a link to point through — `symlink_metadata` says as
70/// much and this says yes, which is what the page's files are anyway.
71pub fn refuse_symlinks(root: &std::path::Path, file: &std::path::Path) -> Result<(), String> {
72    let mut at = root.to_path_buf();
73    for part in file.strip_prefix(root).unwrap_or(file) {
74        at.push(part);
75        let Ok(there) = std::fs::symlink_metadata(&at) else { break };
76        if there.file_type().is_symlink() {
77            let shown = at.strip_prefix(root).unwrap_or(&at);
78            return Err(format!("{} is a symbolic link, so writing there would write outside this folder", shown.display()));
79        }
80    }
81    Ok(())
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    /// The frontmatter's `name` has to be the folder's, which the Agent Skills spec requires and
89    /// dx's catalog reads. Checked for both, so a rename can't ship broken.
90    fn declared_name(skill: &str) -> Option<&str> {
91        skill.lines().find_map(|line| line.strip_prefix("name: ").map(str::trim))
92    }
93
94    #[test]
95    fn both_flavours_are_named_for_the_folder_they_go_in() {
96        for flavour in [Flavour::Command, Flavour::Tool] {
97            let files = flavour.files();
98            assert_eq!(declared_name(files[0].1), Some(FOLDER), "{flavour:?}");
99            assert!(!files[1].1.trim().is_empty(), "{flavour:?} has no patterns reference");
100        }
101    }
102
103    #[test]
104    fn the_two_flavours_teach_different_shapes() {
105        let command = Flavour::Command.files()[0].1;
106        let tool = Flavour::Tool.files()[0].1;
107        assert_ne!(command, tool);
108        // The command's shape is a command line; the tool's is a call. Each should teach its own
109        // and not the other's, which is the whole reason there are two.
110        assert!(command.contains("jev -f") && command.contains("jev '"), "the command skill shows no command line");
111        assert!(!tool.contains("jev -f") && !tool.contains("jev '"), "the tool skill teaches a command line the tool can't run");
112    }
113
114    #[test]
115    fn each_flavour_is_the_others_other() {
116        assert_eq!(Flavour::Command.other(), Flavour::Tool);
117        assert_eq!(Flavour::Tool.other(), Flavour::Command);
118        assert_eq!(Flavour::default(), Flavour::Command);
119    }
120}