1pub 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
34struct Artifact {
36 path: PathBuf,
37 content: Content,
38}
39
40enum Content {
41 Text(String),
43 AgentsBlock(String),
45}
46
47pub 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
73fn 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
97fn 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 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
158fn 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
187fn 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
253fn 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
264fn 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 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
310fn 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}