Skip to main content

claudex_cli/skill/
mod.rs

1//! `claudex skills` — generate or install an agent skill describing claudex.
2//!
3//! `generate` writes files into a directory for review; `install` writes them
4//! into live harness configuration locations (project by default, or the
5//! user-level config with `--global`). One shared skill body is wrapped in
6//! target-appropriate frontmatter (see [`templates`]).
7
8pub mod templates;
9
10use std::fs;
11use std::path::{Path, PathBuf};
12
13use anyhow::{Context, Result, bail};
14use serde_json::{Value, json};
15
16use crate::cli::{SkillArgs, SkillCommand, SkillTarget};
17use templates::Flavor;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20enum Mode {
21    Generate,
22    Install,
23}
24
25impl Mode {
26    fn label(self) -> &'static str {
27        match self {
28            Mode::Generate => "generate",
29            Mode::Install => "install",
30        }
31    }
32}
33
34/// A single file to write.
35struct Artifact {
36    path: PathBuf,
37    content: Content,
38}
39
40enum Content {
41    /// A text file written verbatim (honors `--force` for overwrites).
42    Text(String),
43    /// A markdown block spliced idempotently into a shared `AGENTS.md`.
44    AgentsBlock(String),
45}
46
47/// Entry point dispatched from `main`. `root` is the live clap command tree,
48/// used to derive the always-accurate command list.
49pub fn execute(command: SkillCommand, root: &clap::Command) -> Result<()> {
50    let (mode, args) = match command {
51        SkillCommand::Generate(args) => (Mode::Generate, args),
52        SkillCommand::Install(args) => (Mode::Install, args),
53    };
54
55    let targets = expand_targets(&args.target);
56    let command_list = templates::command_list(root);
57
58    let mut artifacts = Vec::new();
59    for target in targets {
60        artifacts.extend(plan_target(target, mode, &args, &command_list)?);
61    }
62
63    let mut written = Vec::new();
64    for artifact in &artifacts {
65        write_artifact(artifact, args.force)?;
66        written.push(artifact.path.display().to_string());
67    }
68
69    render_summary(args.json, mode, &written);
70    Ok(())
71}
72
73/// Expand `all` and de-duplicate while preserving order.
74fn expand_targets(requested: &[SkillTarget]) -> Vec<SkillTarget> {
75    let mut targets = Vec::new();
76    let push = |target: SkillTarget, targets: &mut Vec<SkillTarget>| {
77        if !targets.contains(&target) {
78            targets.push(target);
79        }
80    };
81
82    for &target in requested {
83        match target {
84            SkillTarget::All => {
85                push(SkillTarget::ClaudeCode, &mut targets);
86                push(SkillTarget::Codex, &mut targets);
87                push(SkillTarget::Pi, &mut targets);
88                push(SkillTarget::OpenClaw, &mut targets);
89                push(SkillTarget::AgentsMd, &mut targets);
90            }
91            other => push(other, &mut targets),
92        }
93    }
94    targets
95}
96
97/// Build the artifacts for one target.
98fn plan_target(
99    target: SkillTarget,
100    mode: Mode,
101    args: &SkillArgs,
102    command_list: &str,
103) -> Result<Vec<Artifact>> {
104    match target {
105        SkillTarget::ClaudeCode => {
106            let base = base_dir(mode, args, ".claude", ".claude")?;
107            Ok(vec![Artifact {
108                path: base.join("skills").join("claudex").join("SKILL.md"),
109                content: Content::Text(templates::skill_md(Flavor::ClaudeCode, command_list)),
110            }])
111        }
112        SkillTarget::Codex => {
113            let base = base_dir(mode, args, ".agents", ".agents")?;
114            Ok(vec![Artifact {
115                path: base.join("skills").join("claudex").join("SKILL.md"),
116                content: Content::Text(templates::skill_md(Flavor::Codex, command_list)),
117            }])
118        }
119        SkillTarget::Pi => {
120            let base = base_dir(mode, args, ".pi", ".pi")?;
121            Ok(vec![Artifact {
122                path: base.join("skills").join("claudex").join("SKILL.md"),
123                content: Content::Text(templates::skill_md(Flavor::Pi, command_list)),
124            }])
125        }
126        SkillTarget::OpenClaw => {
127            let base = openclaw_base_dir(mode, args)?;
128            Ok(vec![Artifact {
129                path: base.join("skills").join("claudex").join("SKILL.md"),
130                content: Content::Text(templates::skill_md(Flavor::OpenClaw, command_list)),
131            }])
132        }
133        SkillTarget::AgentsMd => {
134            let base = base_dir(mode, args, "", ".codex")?;
135            Ok(vec![Artifact {
136                path: base.join("AGENTS.md"),
137                content: Content::AgentsBlock(templates::agents_block(command_list)),
138            }])
139        }
140        SkillTarget::Plugin => {
141            // The plugin is a project artifact; `--global` does not relocate it.
142            let base = bundle_base(mode, args).join("claudex");
143            Ok(vec![
144                Artifact {
145                    path: base.join(".claude-plugin").join("plugin.json"),
146                    content: Content::Text(templates::plugin_json()),
147                },
148                Artifact {
149                    path: base.join("skills").join("claudex").join("SKILL.md"),
150                    content: Content::Text(templates::skill_md(Flavor::ClaudeCode, command_list)),
151                },
152            ])
153        }
154        SkillTarget::All => unreachable!("expand_targets removes All"),
155    }
156}
157
158/// Directory that contains a target's `skills/` (or holds `AGENTS.md`).
159fn base_dir(
160    mode: Mode,
161    args: &SkillArgs,
162    project_prefix: &str,
163    global_prefix: &str,
164) -> Result<PathBuf> {
165    match mode {
166        Mode::Generate => {
167            let root = args
168                .dir
169                .clone()
170                .unwrap_or_else(|| PathBuf::from("claudex-skills"));
171            Ok(join_prefix(root, project_prefix))
172        }
173        Mode::Install if args.global => {
174            let root = match &args.dir {
175                Some(dir) => dir.clone(),
176                None => home_dir()?,
177            };
178            Ok(join_prefix(root, global_prefix))
179        }
180        Mode::Install => {
181            let root = args.dir.clone().unwrap_or_else(|| PathBuf::from("."));
182            Ok(join_prefix(root, project_prefix))
183        }
184    }
185}
186
187/// Root for the project-shaped plugin bundle that `--global` does not move.
188fn bundle_base(mode: Mode, args: &SkillArgs) -> PathBuf {
189    let default = match mode {
190        Mode::Generate => PathBuf::from("claudex-skills"),
191        Mode::Install => PathBuf::from("."),
192    };
193    args.dir.clone().unwrap_or(default)
194}
195
196fn openclaw_base_dir(mode: Mode, args: &SkillArgs) -> Result<PathBuf> {
197    match mode {
198        Mode::Generate => Ok(args
199            .dir
200            .clone()
201            .unwrap_or_else(|| PathBuf::from("claudex-skills"))),
202        Mode::Install if args.global => {
203            if let Some(dir) = &args.dir {
204                return Ok(dir.clone());
205            }
206            if let Ok(state_dir) = std::env::var("OPENCLAW_STATE_DIR")
207                && !state_dir.trim().is_empty()
208            {
209                return expand_home(state_dir.trim());
210            }
211            Ok(home_dir()?.join(".openclaw"))
212        }
213        Mode::Install => Ok(args.dir.clone().unwrap_or_else(|| PathBuf::from("."))),
214    }
215}
216
217fn join_prefix(root: PathBuf, prefix: &str) -> PathBuf {
218    if prefix.is_empty() {
219        root
220    } else {
221        root.join(prefix)
222    }
223}
224
225fn home_dir() -> Result<PathBuf> {
226    dirs::home_dir().context("could not determine your home directory for --global install")
227}
228
229fn expand_home(value: &str) -> Result<PathBuf> {
230    if let Some(rest) = value.strip_prefix("~/") {
231        Ok(home_dir()?.join(rest))
232    } else if value == "~" {
233        home_dir()
234    } else {
235        Ok(PathBuf::from(value))
236    }
237}
238
239fn write_artifact(artifact: &Artifact, force: bool) -> Result<()> {
240    if let Some(parent) = artifact.path.parent()
241        && !parent.as_os_str().is_empty()
242    {
243        fs::create_dir_all(parent)
244            .with_context(|| format!("could not create {}", parent.display()))?;
245    }
246
247    match &artifact.content {
248        Content::Text(text) => write_new_file(&artifact.path, text.as_bytes(), force),
249        Content::AgentsBlock(block) => splice_agents(&artifact.path, block),
250    }
251}
252
253/// Write a file, refusing to clobber an existing one unless `force` is set.
254fn write_new_file(path: &Path, bytes: &[u8], force: bool) -> Result<()> {
255    if path.exists() && !force {
256        bail!(
257            "{} already exists (use --force to overwrite)",
258            path.display()
259        );
260    }
261    fs::write(path, bytes).with_context(|| format!("could not write {}", path.display()))
262}
263
264/// Splice the claudex block into `AGENTS.md`, replacing an existing marked block
265/// in place so re-running never duplicates it.
266fn splice_agents(path: &Path, block: &str) -> Result<()> {
267    let existing = fs::read_to_string(path).unwrap_or_default();
268
269    let updated = match (
270        existing.find(templates::AGENTS_START),
271        existing.find(templates::AGENTS_END),
272    ) {
273        (Some(start), Some(end)) if end > start => {
274            let end = end + templates::AGENTS_END.len();
275            format!("{}{}{}", &existing[..start], block, &existing[end..])
276        }
277        _ if existing.trim().is_empty() => format!("{block}\n"),
278        _ => format!("{}\n\n{block}\n", existing.trim_end()),
279    };
280
281    fs::write(path, updated).with_context(|| format!("could not write {}", path.display()))
282}
283
284fn render_summary(json: bool, mode: Mode, written: &[String]) {
285    // `generate` writes a review copy; nudge the user toward activating it.
286    let hint = (mode == Mode::Generate).then(install_hint);
287
288    if json {
289        let mut payload = json!({ "mode": mode.label(), "written": written });
290        if let Some(hint) = &hint {
291            payload["hint"] = Value::String(hint.clone());
292        }
293        println!(
294            "{}",
295            serde_json::to_string_pretty(&payload).unwrap_or_default()
296        );
297        return;
298    }
299
300    println!("Wrote {} file(s) ({}):", written.len(), mode.label());
301    for path in written {
302        println!("  {path}");
303    }
304    if let Some(hint) = &hint {
305        println!();
306        println!("{hint}");
307    }
308}
309
310/// Guidance shown after `generate`: how to turn the review copy into a live
311/// skill, with a manual-copy escape hatch for non-standard layouts.
312fn install_hint() -> String {
313    "These files are for review. Activate the skill with `claudex skills install` \
314     (add --global for user-level, or --target to choose a harness), or copy them \
315     into your harness configuration directories manually."
316        .to_string()
317}