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}