use anyhow::{Context, Result};
use once_cell::sync::Lazy;
use std::path::Path;
use tera::{Context as TeraContext, Tera};
use crate::cli::Target;
use crate::types::{Intent, Language, ProjectProfile};
use crate::verify::schema;
const MARKETPLACE_TPL: &str = include_str!("../templates/marketplace.json.tera");
const PLUGIN_TPL: &str = include_str!("../templates/plugin.json.tera");
const SKILL_TPL: &str = include_str!("../templates/SKILL.md.tera");
const CURSOR_RULE_TPL: &str = include_str!("../templates/cursor-rule.mdc.tera");
const OPENCODE_AGENT_TPL: &str = include_str!("../templates/opencode-agent.md.tera");
const COPILOT_INSTRUCTIONS_TPL: &str = include_str!("../templates/copilot-instructions.md.tera");
const AGENTS_MD_TPL: &str = include_str!("../templates/AGENTS.md.tera");
const CLAUDE_MD_TPL: &str = include_str!("../templates/CLAUDE.md.tera");
const GEMINI_MD_TPL: &str = include_str!("../templates/GEMINI.md.tera");
const CONVENTIONS_MD_TPL: &str = include_str!("../templates/CONVENTIONS.md.tera");
const WINDSURF_RULE_TPL: &str = include_str!("../templates/windsurf-rule.md.tera");
const SKILL_BODY_TPL: &str = include_str!("../templates/skill_body.md.tera");
static TERA: Lazy<Tera> = Lazy::new(|| {
let mut tera = Tera::default();
tera.add_raw_template("marketplace.json", MARKETPLACE_TPL)
.expect("marketplace template is valid");
tera.add_raw_template("plugin.json", PLUGIN_TPL)
.expect("plugin template is valid");
tera.add_raw_template("SKILL.md", SKILL_TPL)
.expect("SKILL template is valid");
tera.add_raw_template("cursor-rule.mdc", CURSOR_RULE_TPL)
.expect("cursor rule template is valid");
tera.add_raw_template("opencode-agent.md", OPENCODE_AGENT_TPL)
.expect("opencode agent template is valid");
tera.add_raw_template("skill_body_partial", SKILL_BODY_TPL)
.expect("skill body partial template is valid");
tera.add_raw_template("copilot-instructions.md", COPILOT_INSTRUCTIONS_TPL)
.expect("copilot instructions template is valid");
tera.add_raw_template("AGENTS.md", AGENTS_MD_TPL)
.expect("AGENTS.md template is valid");
tera.add_raw_template("CLAUDE.md", CLAUDE_MD_TPL)
.expect("CLAUDE.md template is valid");
tera.add_raw_template("GEMINI.md", GEMINI_MD_TPL)
.expect("GEMINI.md template is valid");
tera.add_raw_template("CONVENTIONS.md", CONVENTIONS_MD_TPL)
.expect("CONVENTIONS.md template is valid");
tera.add_raw_template("windsurf-rule.md", WINDSURF_RULE_TPL)
.expect("windsurf rule template is valid");
tera
});
#[allow(dead_code)]
pub const OUTPUT_PATHS: [&str; 3] = [
".claude-plugin/marketplace.json",
".claude-plugin/plugin.json",
"skills/<tool>/SKILL.md",
];
pub fn build_context(profile: &ProjectProfile, intent: &Intent) -> TeraContext {
let name = coerce_kebab(&profile.name);
let keywords = Keywords {
inner: derive_keywords(profile, intent),
};
let display_name = name.clone();
let has_cli = profile.has_cli;
let cli_binary = profile
.cli_command
.as_ref()
.and_then(|c| c.first())
.and_then(|cmd| {
std::path::Path::new(cmd)
.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.to_string())
})
.filter(|s| !s.is_empty())
.unwrap_or_else(|| name.clone());
let documented_flags = profile
.cli_help_output
.as_deref()
.map(crate::verify::invocation::extract_flags)
.unwrap_or_default();
let documented_subcommands: Vec<serde_json::Value> = profile
.cli_subcommand_help
.iter()
.map(|(name, help)| {
let flags: Vec<String> = crate::verify::invocation::extract_flags(help)
.into_iter()
.filter(|f| !crate::verify::invocation::is_meta_flag(f))
.collect();
serde_json::json!({ "name": name, "flags": flags })
})
.collect();
let when_concat = intent.when_to_use_phrases.join(", ");
tera::Context::from_serialize(serde_json::json!({
"name": name,
"display_name": display_name,
"one_line_description": one_line_description_yaml(&intent.one_line_description),
"one_line_description_raw": &intent.one_line_description,
"when_to_use_phrases": intent.when_to_use_phrases,
"when_concat": escape_yaml(&when_concat),
"author": intent.author.as_deref().or(profile.authors.as_deref()),
"license": intent.license,
"repo_url": profile.repo_url,
"keywords": keywords,
"version": profile.version.as_deref().unwrap_or_default(),
"has_cli": has_cli,
"cli_binary": cli_binary,
"invocation_command": intent.invocation_command,
"import_pattern": intent.import_pattern,
"documented_flags": documented_flags,
"documented_subcommands": documented_subcommands,
"category_hint": category_hint(profile.language),
"allowed_tools": allowed_tools_hint(profile.language),
"globs": cursor_globs_yaml(profile.language),
"opencode_mode": opencode_mode_hint(profile.language),
"footguns": &intent.footguns,
}))
.expect("Tera context serializes from JSON literal")
}
fn escape_yaml(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\r', "")
.replace('\n', " ")
}
fn one_line_description_yaml(s: &str) -> String {
escape_yaml(s)
}
pub fn render(
profile: &ProjectProfile,
intent: &Intent,
template_dir: Option<&Path>,
) -> Result<Vec<GeneratedFileOutput>> {
let tera = build_tera(template_dir)?;
let mut ctx = build_context(profile, intent);
ctx.insert("noun", "skill");
let name = coerce_kebab(&profile.name);
let marketplace = tera
.render("marketplace.json", &ctx)
.context("rendering marketplace.json")?;
let plugin = tera
.render("plugin.json", &ctx)
.context("rendering plugin.json")?;
let skill = tera
.render("SKILL.md", &ctx)
.context("rendering SKILL.md")?;
Ok(vec![
GeneratedFileOutput {
rel_path: ".claude-plugin/marketplace.json".to_string(),
contents: marketplace,
},
GeneratedFileOutput {
rel_path: ".claude-plugin/plugin.json".to_string(),
contents: plugin,
},
GeneratedFileOutput {
rel_path: format!("skills/{name}/SKILL.md"),
contents: skill,
},
])
}
pub fn render_targets(
profile: &ProjectProfile,
intent: &Intent,
targets: &[Target],
template_dir: Option<&Path>,
) -> Result<Vec<GeneratedFileOutput>> {
let tera = build_tera(template_dir)?;
let ctx = build_context(profile, intent);
let name = coerce_kebab(&profile.name);
let mut out = Vec::new();
let mut seen = std::collections::HashSet::new();
for &target in targets {
if !seen.insert(target) {
continue;
}
out.extend(render_one_target(&tera, &ctx, target, &name)?);
}
Ok(out)
}
pub fn render_all(
profile: &ProjectProfile,
skills: &[(String, Intent)],
targets: &[Target],
template_dir: Option<&Path>,
) -> Result<Vec<GeneratedFileOutput>> {
let tera = build_tera(template_dir)?;
let mut out = Vec::new();
let (primary_name, primary_intent) = &skills[0];
let primary_ctx = build_context(profile, primary_intent);
let primary_dir = coerce_kebab(primary_name);
let mut seen = std::collections::HashSet::new();
for &target in targets {
if !seen.insert(target) {
continue;
}
out.extend(render_one_target(
&tera,
&primary_ctx,
target,
&primary_dir,
)?);
for (skill_name, intent) in &skills[1..] {
let mut ctx = build_context(profile, intent);
let dir = coerce_kebab(skill_name);
ctx.insert("name", &dir);
if let Some(f) = render_skill_file_only(&tera, &ctx, target, &dir)? {
out.push(f);
}
}
}
Ok(out)
}
fn render_one_target(
tera: &tera::Tera,
ctx: &tera::Context,
target: Target,
name: &str,
) -> Result<Vec<GeneratedFileOutput>> {
let mut out = Vec::new();
match target {
Target::Claude => {
let mut c = ctx.clone();
c.insert("noun", "skill");
let marketplace = tera
.render("marketplace.json", &c)
.context("rendering marketplace.json")?;
let plugin = tera
.render("plugin.json", &c)
.context("rendering plugin.json")?;
let skill = tera.render("SKILL.md", &c).context("rendering SKILL.md")?;
out.push(GeneratedFileOutput {
rel_path: ".claude-plugin/marketplace.json".to_string(),
contents: marketplace,
});
out.push(GeneratedFileOutput {
rel_path: ".claude-plugin/plugin.json".to_string(),
contents: plugin,
});
out.push(GeneratedFileOutput {
rel_path: format!("skills/{name}/SKILL.md"),
contents: skill,
});
}
Target::Cursor => {
let mut c = ctx.clone();
c.insert("noun", "rule");
let mdc = tera
.render("cursor-rule.mdc", &c)
.context("rendering cursor-rule.mdc")?;
out.push(GeneratedFileOutput {
rel_path: format!(".cursor/rules/{name}.mdc"),
contents: mdc,
});
}
Target::Codex => {
let mut c = ctx.clone();
c.insert("noun", "skill");
let skill = tera
.render("SKILL.md", &c)
.context("rendering codex SKILL.md")?;
out.push(GeneratedFileOutput {
rel_path: format!(".codex/skills/{name}/SKILL.md"),
contents: skill,
});
}
Target::OpenCode => {
let mut c = ctx.clone();
c.insert("noun", "agent");
let agent = tera
.render("opencode-agent.md", &c)
.context("rendering opencode-agent.md")?;
out.push(GeneratedFileOutput {
rel_path: format!(".opencode/agents/{name}.md"),
contents: agent,
});
}
Target::Copilot => {
let mut c = ctx.clone();
c.insert("noun", "tool");
let instr = tera
.render("copilot-instructions.md", &c)
.context("rendering copilot-instructions.md")?;
out.push(GeneratedFileOutput {
rel_path: ".github/copilot-instructions.md".to_string(),
contents: instr,
});
}
Target::AgentsMd => {
let mut c = ctx.clone();
c.insert("noun", "tool");
let agents = tera
.render("AGENTS.md", &c)
.context("rendering AGENTS.md")?;
out.push(GeneratedFileOutput {
rel_path: schema::AGENTS_MD_PATH.to_string(),
contents: agents,
});
}
Target::ClaudeMd => {
let mut c = ctx.clone();
c.insert("noun", "tool");
let claude_md = tera
.render("CLAUDE.md", &c)
.context("rendering CLAUDE.md")?;
out.push(GeneratedFileOutput {
rel_path: schema::CLAUDE_MD_PATH.to_string(),
contents: claude_md,
});
}
Target::Gemini => {
let mut c = ctx.clone();
c.insert("noun", "tool");
let gemini = tera
.render("GEMINI.md", &c)
.context("rendering GEMINI.md")?;
out.push(GeneratedFileOutput {
rel_path: schema::GEMINI_MD_PATH.to_string(),
contents: gemini,
});
}
Target::Windsurf => {
let mut c = ctx.clone();
c.insert("noun", "rule");
let rule = tera
.render("windsurf-rule.md", &c)
.context("rendering windsurf-rule.md")?;
out.push(GeneratedFileOutput {
rel_path: format!(".windsurf/rules/{name}.md"),
contents: rule,
});
}
Target::Aider => {
let mut c = ctx.clone();
c.insert("noun", "tool");
let conventions = tera
.render("CONVENTIONS.md", &c)
.context("rendering CONVENTIONS.md")?;
out.push(GeneratedFileOutput {
rel_path: schema::CONVENTIONS_MD_PATH.to_string(),
contents: conventions,
});
}
}
Ok(out)
}
fn render_skill_file_only(
tera: &tera::Tera,
ctx: &tera::Context,
target: Target,
name: &str,
) -> Result<Option<GeneratedFileOutput>> {
match target {
Target::Claude | Target::Codex => {
let mut c = ctx.clone();
c.insert("noun", "skill");
let skill = tera.render("SKILL.md", &c).context("rendering SKILL.md")?;
let rel_path = match target {
Target::Claude => format!("skills/{name}/SKILL.md"),
_ => format!(".codex/skills/{name}/SKILL.md"),
};
Ok(Some(GeneratedFileOutput {
rel_path,
contents: skill,
}))
}
Target::Cursor => {
let mut c = ctx.clone();
c.insert("noun", "rule");
let mdc = tera
.render("cursor-rule.mdc", &c)
.context("rendering cursor-rule.mdc")?;
Ok(Some(GeneratedFileOutput {
rel_path: format!(".cursor/rules/{name}.mdc"),
contents: mdc,
}))
}
Target::Windsurf => {
let mut c = ctx.clone();
c.insert("noun", "rule");
let rule = tera
.render("windsurf-rule.md", &c)
.context("rendering windsurf-rule.md")?;
Ok(Some(GeneratedFileOutput {
rel_path: format!(".windsurf/rules/{name}.md"),
contents: rule,
}))
}
Target::OpenCode => {
let mut c = ctx.clone();
c.insert("noun", "agent");
let agent = tera
.render("opencode-agent.md", &c)
.context("rendering opencode-agent.md")?;
Ok(Some(GeneratedFileOutput {
rel_path: format!(".opencode/agents/{name}.md"),
contents: agent,
}))
}
Target::Copilot | Target::AgentsMd | Target::ClaudeMd | Target::Gemini | Target::Aider => {
Ok(None)
}
}
}
const TEMPLATE_MAP: &[(&str, &str)] = &[
("marketplace.json.tera", "marketplace.json"),
("plugin.json.tera", "plugin.json"),
("SKILL.md.tera", "SKILL.md"),
("cursor-rule.mdc.tera", "cursor-rule.mdc"),
("opencode-agent.md.tera", "opencode-agent.md"),
("copilot-instructions.md.tera", "copilot-instructions.md"),
("AGENTS.md.tera", "AGENTS.md"),
("CLAUDE.md.tera", "CLAUDE.md"),
("GEMINI.md.tera", "GEMINI.md"),
("CONVENTIONS.md.tera", "CONVENTIONS.md"),
("windsurf-rule.md.tera", "windsurf-rule.md"),
("skill_body.md.tera", "skill_body_partial"),
];
fn build_tera(template_dir: Option<&Path>) -> Result<Tera> {
let Some(dir) = template_dir else {
return Ok(TERA.clone());
};
let mut tera = Tera::clone(&*TERA);
for (filename, internal_name) in TEMPLATE_MAP {
let path = dir.join(filename);
if let Ok(src) = std::fs::read_to_string(&path) {
tera.add_raw_template(internal_name, &src)
.map_err(|e| anyhow::anyhow!("failed to load template {filename}: {e}"))?;
}
}
Ok(tera)
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(transparent)]
pub struct Keywords {
pub inner: Vec<String>,
}
fn derive_keywords(profile: &ProjectProfile, intent: &Intent) -> Vec<String> {
let mut kws = vec![profile.language.as_str().to_string()];
if profile.has_cli {
kws.push("cli".to_string());
} else {
kws.push("library".to_string());
}
for phrase in &intent.when_to_use_phrases {
if let Some(word) = phrase.split_whitespace().next().map(|w| {
w.trim_matches(|c: char| !c.is_alphanumeric())
.to_lowercase()
}) {
if !word.is_empty() && !kws.contains(&word) {
kws.push(word);
}
}
}
for (sub, _help) in &profile.cli_subcommand_help {
let sub = sub.to_lowercase();
if !sub.is_empty() && !kws.contains(&sub) {
kws.push(sub);
}
}
if let Some(hint) = &profile.description_hint {
let best = hint
.split_whitespace()
.map(|w| w.trim_matches(|c: char| !c.is_alphanumeric()))
.filter(|w| w.len() > 4 && w.chars().all(|c| c.is_alphanumeric()) && !is_stopword(w))
.max_by_key(|w| w.len());
if let Some(w) = best {
let w = w.to_lowercase();
if !w.is_empty() && !kws.contains(&w) {
kws.push(w);
}
}
}
kws
}
fn is_stopword(w: &str) -> bool {
matches!(
w.to_lowercase().as_str(),
"the"
| "this"
| "that"
| "with"
| "from"
| "about"
| "your"
| "have"
| "will"
| "they"
| "them"
| "their"
| "what"
| "when"
| "which"
| "would"
| "could"
| "should"
| "into"
| "onto"
| "over"
| "under"
| "also"
| "just"
| "only"
| "than"
| "then"
| "these"
| "those"
| "using"
| "being"
| "been"
| "more"
| "most"
| "such"
| "some"
)
}
fn category_hint(lang: Language) -> &'static str {
match lang {
Language::Rust => "the Rust tooling",
Language::Node => "the JavaScript/Node tooling",
Language::Python => "the Python tooling",
Language::Go => "the Go tooling",
Language::Ruby => "the Ruby tooling",
Language::Php => "the PHP tooling",
Language::Jvm => "the JVM tooling",
Language::CSharp => "the .NET/C# tooling",
Language::Zig => "the Zig tooling",
Language::Swift => "the Swift tooling",
Language::CCpp => "the C/C++ tooling",
Language::Elixir => "the Elixir tooling",
Language::Deno => "the Deno tooling",
Language::Unknown => "the tooling",
}
}
fn allowed_tools_hint(lang: Language) -> Option<&'static str> {
if let Language::Unknown = lang {
None
} else {
Some("Read, Bash")
}
}
fn cursor_globs_hint(lang: Language) -> Vec<String> {
match lang {
Language::Rust => vec!["*.rs".into()],
Language::Node => vec![
"*.js".into(),
"*.ts".into(),
"*.jsx".into(),
"*.tsx".into(),
"package.json".into(),
],
Language::Python => vec!["*.py".into()],
Language::Go => vec!["*.go".into(), "go.mod".into()],
Language::Ruby => vec!["*.rb".into(), "*.gemspec".into(), "Gemfile".into()],
Language::Php => vec!["*.php".into(), "composer.json".into()],
Language::Jvm => vec![
"*.java".into(),
"*.kt".into(),
"*.scala".into(),
"pom.xml".into(),
"build.gradle".into(),
"build.gradle.kts".into(),
],
Language::CSharp => vec!["*.cs".into(), "*.csproj".into(), "*.sln".into()],
Language::Zig => vec!["*.zig".into(), "build.zig".into(), "build.zig.zon".into()],
Language::Swift => vec!["*.swift".into(), "Package.swift".into()],
Language::CCpp => vec![
"*.c".into(),
"*.cpp".into(),
"*.cc".into(),
"*.h".into(),
"*.hpp".into(),
"CMakeLists.txt".into(),
"Makefile".into(),
],
Language::Elixir => vec!["*.ex".into(), "*.exs".into(), "mix.exs".into()],
Language::Deno => vec![
"*.ts".into(),
"*.js".into(),
"deno.json".into(),
"deno.jsonc".into(),
],
Language::Unknown => vec![],
}
}
fn cursor_globs_yaml(lang: Language) -> String {
cursor_globs_hint(lang)
.iter()
.map(|g| format!("\"{g}\""))
.collect::<Vec<_>>()
.join(", ")
}
fn opencode_mode_hint(lang: Language) -> &'static str {
if let Language::Unknown = lang {
"subagent"
} else {
"primary"
}
}
pub fn coerce_kebab(name: &str) -> String {
let mut out = String::with_capacity(name.len());
let mut prev_dash = false;
for c in name.chars() {
if c.is_ascii_alphanumeric() {
out.push(c.to_ascii_lowercase());
prev_dash = false;
} else if !prev_dash {
out.push('-');
prev_dash = true;
}
}
let mut s = out.trim_matches('-');
while let Some(first) = s.chars().next() {
if first.is_ascii_digit() || first == '-' {
s = s.trim_start_matches(|c: char| c.is_ascii_digit() || c == '-');
} else {
break;
}
}
let s = s.trim_matches('-');
if s.is_empty() || !s.chars().next().unwrap().is_ascii_alphabetic() {
return "tool".to_string();
}
s.to_string()
}
#[derive(Debug, Clone)]
pub struct GeneratedFileOutput {
pub rel_path: String,
pub contents: String,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{Intent, Language, ProjectProfile};
fn cli_profile() -> ProjectProfile {
let mut p = ProjectProfile::test_default();
p.name = "chronicle".into();
p.language = Language::Rust;
p.has_cli = true;
p.cli_command = Some(vec!["chronicle".to_string(), "--help".to_string()]);
p.cli_help_output = Some("Usage: chronicle [OPTIONS]\n --new <entry> Create an entry\n --verbose verbose\n".into());
p.cli_subcommand_help = Vec::new();
p.license = Some("MIT".into());
p
}
fn cli_intent() -> Intent {
Intent {
one_line_description: "Journal events to a chronological log".into(),
when_to_use_phrases: vec!["log a journal entry".into(), "record an incident".into()],
invocation_command: Some("chronicle --new \"entry\"".into()),
import_pattern: None,
author: Some("Mikey".into()),
license: Some("MIT".into()),
..Default::default()
}
}
#[test]
fn renders_three_files_with_valid_paths() {
let p = cli_profile();
let i = cli_intent();
let files = render(&p, &i, None).unwrap();
assert_eq!(files.len(), 3);
assert_eq!(files[0].rel_path, ".claude-plugin/marketplace.json");
assert_eq!(files[1].rel_path, ".claude-plugin/plugin.json");
assert_eq!(files[2].rel_path, "skills/chronicle/SKILL.md");
}
#[test]
fn rendered_marketplace_is_valid_json_and_points_at_dot_slash() {
let p = cli_profile();
let i = cli_intent();
let mp = render(&p, &i, None).unwrap()[0].contents.clone();
let v: serde_json::Value = serde_json::from_str(&mp).unwrap();
assert_eq!(v["plugins"][0]["source"], "./");
assert_eq!(v["plugins"][0]["name"], "chronicle");
}
#[test]
fn rendered_plugin_json_has_kebab_name_and_license() {
let p = cli_profile();
let i = cli_intent();
let pj = render(&p, &i, None).unwrap()[1].contents.clone();
let v: serde_json::Value = serde_json::from_str(&pj).unwrap();
assert_eq!(v["name"], "chronicle");
assert_eq!(v["license"], "MIT");
}
#[test]
fn skill_md_has_description_and_when_to_use_in_frontmatter() {
let p = cli_profile();
let i = cli_intent();
let skill = render(&p, &i, None).unwrap()[2].contents.clone();
assert!(skill.starts_with("---\n"));
assert!(skill.contains("description: \"Journal events to a chronological log\""));
assert!(skill.contains("when_to_use: \"log a journal entry, record an incident\""));
}
#[test]
fn pure_library_renders_import_pattern_not_cli() {
let mut p = cli_profile();
p.has_cli = false;
p.cli_command = None;
p.cli_help_output = None;
let i = Intent {
one_line_description: "Parse CSV files fast".into(),
when_to_use_phrases: vec!["ingest csv".into()],
invocation_command: None,
import_pattern: Some("import { parse } from 'fastcsv'".into()),
license: Some("MIT".into()),
..Default::default()
};
let files = render(&p, &i, None).unwrap();
let skill = &files[2].contents;
assert!(skill.contains("import { parse } from 'fastcsv'"));
assert!(!skill.contains("Invocation"));
}
#[test]
fn coerce_kebab_handles_messy_names() {
assert_eq!(coerce_kebab("My Cool Tool"), "my-cool-tool");
assert_eq!(coerce_kebab("foo__bar--baz"), "foo-bar-baz");
assert_eq!(coerce_kebab("UPPER_CASE"), "upper-case");
assert_eq!(coerce_kebab("a"), "a");
assert_eq!(coerce_kebab("!!!"), "tool");
assert_eq!(coerce_kebab("123foo"), "foo");
assert_eq!(coerce_kebab("123-foo"), "foo");
assert_eq!(coerce_kebab("123-456-tool"), "tool");
assert_eq!(coerce_kebab("123-456-foo-bar"), "foo-bar");
assert_eq!(coerce_kebab("123"), "tool");
assert_eq!(coerce_kebab("123-456"), "tool");
assert_eq!(coerce_kebab("9"), "tool");
}
#[test]
fn idempotent_byte_identical_renders() {
let p = cli_profile();
let i = cli_intent();
let a = render(&p, &i, None).unwrap();
let b = render(&p, &i, None).unwrap();
for (x, y) in a.iter().zip(b.iter()) {
assert_eq!(x.contents, y.contents);
}
}
#[test]
fn empty_when_to_use_emits_empty_not_placeholder() {
let mut p = cli_profile();
p.has_cli = false;
p.cli_command = None;
p.cli_help_output = None;
let i = Intent {
one_line_description: "Do a thing".into(),
when_to_use_phrases: vec![],
invocation_command: None,
import_pattern: Some("import { x } from 'y'".into()),
license: Some("MIT".into()),
..Default::default()
};
let skill = render(&p, &i, None).unwrap()[2].contents.clone();
assert!(
skill.contains("when_to_use: \"\""),
"empty phrases must yield when_to_use: \"\", got:\n{skill}"
);
assert!(
!skill.contains("(unspecified)"),
"the placeholder must not leak into the skill, got:\n{skill}"
);
}
#[test]
fn invocation_block_falls_back_to_cli_binary_when_intent_omits_command() {
let p = cli_profile(); let mut i = cli_intent();
i.invocation_command = None; let skill = render(&p, &i, None).unwrap()[2].contents.clone();
assert!(
skill.contains("## Invocation"),
"CLI project must still emit an Invocation section, got:\n{skill}"
);
assert!(
skill.contains("```\nchronicle\n```"),
"invocation block must fall back to cli_binary `chronicle`, got:\n{skill}"
);
}
#[test]
fn render_all_emits_every_skill_under_its_own_name() {
let p = cli_profile();
let side_intent = Intent {
one_line_description: "Handle auxiliary chores".into(),
when_to_use_phrases: vec!["aux task".into()],
invocation_command: Some("chronicle aux".into()),
import_pattern: None,
author: None,
license: None,
..Default::default()
};
let skills = vec![
("chronicle".to_string(), cli_intent()),
("sidekick".to_string(), side_intent),
];
let targets = vec![
Target::Claude,
Target::Cursor,
Target::Codex,
Target::OpenCode,
Target::Copilot,
Target::AgentsMd,
];
let files = render_all(&p, &skills, &targets, None).unwrap();
let mp_count = files
.iter()
.filter(|f| f.rel_path == ".claude-plugin/marketplace.json")
.count();
let ag_count = files.iter().filter(|f| f.rel_path == "AGENTS.md").count();
assert_eq!(mp_count, 1, "marketplace.json is pack-level, emitted once");
assert_eq!(ag_count, 1, "AGENTS.md is pack-level, emitted once");
for rel in [
"skills/chronicle/SKILL.md",
"skills/sidekick/SKILL.md",
".codex/skills/sidekick/SKILL.md",
".cursor/rules/sidekick.mdc",
".opencode/agents/sidekick.md",
] {
assert!(
files.iter().any(|f| f.rel_path == rel),
"missing expected rel_path {rel}"
);
}
let side = files
.iter()
.find(|f| f.rel_path == "skills/sidekick/SKILL.md")
.unwrap();
assert!(
side.contents.contains("name: sidekick"),
"secondary skill must use its own name, got:\n{}",
side.contents
);
assert!(
side.contents.contains("Handle auxiliary chores"),
"secondary skill must use its own description, got:\n{}",
side.contents
);
let prim = files
.iter()
.find(|f| f.rel_path == "skills/chronicle/SKILL.md")
.unwrap();
assert!(prim.contents.contains("name: chronicle"));
}
#[test]
fn test_renders_custom_footguns_into_guidance() {
let p = cli_profile();
let mut intent = cli_intent();
intent.footguns = vec![
"Do not combine --max-results with -x (fd rejects this).".to_string(),
"Flags like -e and -E are case-sensitive.".to_string(),
];
let files = render_targets(&p, &intent, &[Target::Claude, Target::AgentsMd], None).unwrap();
let agents = files.iter().find(|f| f.rel_path == "AGENTS.md").unwrap();
assert!(agents
.contents
.contains("- Do not combine --max-results with -x (fd rejects this)."));
assert!(agents
.contents
.contains("- Flags like -e and -E are case-sensitive."));
assert!(agents
.contents
.contains("- Verify the tool is installed before relying on it"));
}
}