use anyhow::{Context, Result};
use once_cell::sync::Lazy;
use tera::{Context as TeraContext, Tera};
use crate::cli::Target;
use crate::types::{Intent, Language, ProjectProfile};
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 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
});
#[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),
}))
.expect("Tera context serializes from JSON literal")
}
fn escape_yaml(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}
fn one_line_description_yaml(s: &str) -> String {
escape_yaml(s)
}
pub fn render(profile: &ProjectProfile, intent: &Intent) -> Result<Vec<GeneratedFileOutput>> {
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],
) -> Result<Vec<GeneratedFileOutput>> {
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;
}
match target {
Target::Claude => out.extend(render(profile, intent)?),
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,
});
}
}
}
Ok(out)
}
#[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());
}
if let Some(first) = intent.when_to_use_phrases.first() {
let kw = first
.split_whitespace()
.next()
.unwrap_or("")
.trim_matches(|c: char| !c.is_alphanumeric())
.to_lowercase();
if !kw.is_empty() && !kws.contains(&kw) {
kws.push(kw);
}
}
kws
}
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::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::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 s = out.trim_matches('-');
let s = s.trim_start_matches(|c: char| c.is_ascii_digit());
let s = s.trim_matches('-');
if s.is_empty() {
return "tool".to_string();
}
if s.len() == 1 {
return s.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()),
}
}
#[test]
fn renders_three_files_with_valid_paths() {
let p = cli_profile();
let i = cli_intent();
let files = render(&p, &i).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).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).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).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()),
author: None,
license: Some("MIT".into()),
};
let files = render(&p, &i).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"), "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).unwrap();
let b = render(&p, &i).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()),
author: None,
license: Some("MIT".into()),
};
let skill = render(&p, &i).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}"
);
}
}