use std::path::{Path, PathBuf};
use clap::{Args, ValueEnum};
use serde::Serialize;
use crate::cli::SkillAction;
use crate::error::CliError;
use crate::output::{emit_json, OutputFormat};
pub const SKILL_SCHEMA_VERSION: u32 = 1;
const CLI_VERSION: &str = env!("CARGO_PKG_VERSION");
pub struct BundledSkill {
pub name: &'static str,
pub description: &'static str,
pub template: &'static str,
pub path_in_repo: &'static str,
}
pub const CATALOG: &[BundledSkill] = &[
BundledSkill {
name: "oss-architecture",
description:
"Opt-in architecture docs of the /oss-* family: emit a matklad-style \
ARCHITECTURE.md code map, scaffold an ADR log + docs site, act on the `docs_site` \
config field. Never a readiness gate; ADRs come from /worktree-technical-decision.",
template: include_str!("../skills/oss-architecture/SKILL.template.md"),
path_in_repo: "crates/ossctl-cli/skills/oss-architecture/SKILL.template.md",
},
BundledSkill {
name: "oss-init",
description:
"Generator of a project's OSS-RELEASE.md config: read repo facts, infer the dials, \
write a human-reviewable draft (a thin skill over `ossctl facts` + `contract validate`).",
template: include_str!("../skills/oss-init/SKILL.template.md"),
path_in_repo: "crates/ossctl-cli/skills/oss-init/SKILL.template.md",
},
BundledSkill {
name: "oss-release",
description:
"Orchestrator/router of the /oss-* family: read the contract, score readiness, \
sequence members, cut the release.",
template: include_str!("../skills/oss-release/SKILL.template.md"),
path_in_repo: "crates/ossctl-cli/skills/oss-release/SKILL.template.md",
},
BundledSkill {
name: "oss-ci",
description:
"Generator of a repo's contribution-quality CI: read the contract, emit the \
tier/ecosystem-tuned GitHub Actions workflow + dep-bot/pre-commit/security-lint \
gates (a thin skill over `ossctl contract show`).",
template: include_str!("../skills/oss-ci/SKILL.template.md"),
path_in_repo: "crates/ossctl-cli/skills/oss-ci/SKILL.template.md",
},
BundledSkill {
name: "oss-security-policy",
description:
"Threat-gated generator of SECURITY.md: detect an enumerated set of threat signals \
from repo inspection + `ossctl facts`/`contract show`, and emit a full \
coordinated-disclosure policy when the surface warrants, else a minimal pointer.",
template: include_str!("../skills/oss-security-policy/SKILL.template.md"),
path_in_repo: "crates/ossctl-cli/skills/oss-security-policy/SKILL.template.md",
},
BundledSkill {
name: "oss-changelog",
description:
"Establish + maintain CHANGELOG.md (sole writer): Keep-a-Changelog skeleton, \
marker-anchored [Unreleased] ops, and release finalize — reads changelog.mode from \
`ossctl contract show`, compiles fragments via `issuectl changelog`.",
template: include_str!("../skills/oss-changelog/SKILL.template.md"),
path_in_repo: "crates/ossctl-cli/skills/oss-changelog/SKILL.template.md",
},
BundledSkill {
name: "oss-readiness",
description:
"Score OSS-release readiness and turn the gap report into a prioritized action list \
(a thin skill over `ossctl audit`).",
template: include_str!("../skills/oss-readiness/SKILL.template.md"),
path_in_repo: "crates/ossctl-cli/skills/oss-readiness/SKILL.template.md",
},
BundledSkill {
name: "oss-readme",
description:
"Generator of a project's README.md front door + LICENSE: read the contract \
(license/ecosystems/targets) and facts, emit a maturity-tiered slotted README and \
an SPDX-correct LICENSE (a thin skill over `ossctl contract show` + `facts`).",
template: include_str!("../skills/oss-readme/SKILL.template.md"),
path_in_repo: "crates/ossctl-cli/skills/oss-readme/SKILL.template.md",
},
BundledSkill {
name: "oss-contributing",
description:
"Generator of a project's contributor-onboarding docs: CONTRIBUTING.md plus \
tier-gated code of conduct, issue forms, PR template, and governance — templated \
emission tuned to the contract (a thin skill over `ossctl contract show`).",
template: include_str!("../skills/oss-contributing/SKILL.template.md"),
path_in_repo: "crates/ossctl-cli/skills/oss-contributing/SKILL.template.md",
},
];
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
#[value(rename_all = "kebab-case")]
pub enum Agent {
Claude,
Pi,
Codex,
All,
}
impl Agent {
fn runtimes(self) -> &'static [Runtime] {
match self {
Agent::Claude => &[Runtime::Claude],
Agent::Pi => &[Runtime::Pi],
Agent::Codex => &[Runtime::Codex],
Agent::All => &[Runtime::Claude, Runtime::Pi, Runtime::Codex],
}
}
}
fn selected_runtimes(agent: Option<Agent>) -> &'static [Runtime] {
match agent {
None => &[Runtime::Claude, Runtime::Pi],
Some(a) => a.runtimes(),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Runtime {
Claude,
Pi,
Codex,
}
impl Runtime {
fn label(self) -> &'static str {
match self {
Runtime::Claude => "claude",
Runtime::Pi => "pi",
Runtime::Codex => "codex",
}
}
fn path_under(self, root: &Path, name: &str) -> PathBuf {
match self {
Runtime::Claude | Runtime::Pi => root.join(name).join("SKILL.md"),
Runtime::Codex => root.join(format!("{name}.md")),
}
}
fn default_root(self, home: &Path) -> PathBuf {
match self {
Runtime::Claude => home.join(".claude/skills"),
Runtime::Pi => home.join(".pi/agent/skills"),
Runtime::Codex => home.join(".codex/prompts"),
}
}
}
#[derive(Args, Debug)]
pub struct InstallArgs {
#[arg(value_name = "NAME")]
pub name: Option<String>,
#[arg(long, value_enum)]
pub agent: Option<Agent>,
#[arg(long, value_name = "PATH")]
pub dest: Option<PathBuf>,
#[arg(long)]
pub force: bool,
}
#[derive(Args, Debug)]
pub struct PrintArgs {
#[arg(value_name = "NAME")]
pub name: String,
}
pub fn dispatch(action: SkillAction, format: OutputFormat) -> Result<(), CliError> {
match action {
SkillAction::List => list(format),
SkillAction::Install(args) => install(&args, format),
SkillAction::Print(args) => print(&args, format),
}
}
fn lookup(name: &str) -> Result<&'static BundledSkill, CliError> {
CATALOG.iter().find(|s| s.name == name).ok_or_else(|| {
let known: Vec<&str> = CATALOG.iter().map(|s| s.name).collect();
CliError::user("unknown_skill", format!("no bundled skill named `{name}`"))
.with_invalid_value(name)
.with_expected(serde_json::json!(known))
})
}
fn render(skill: &BundledSkill) -> String {
skill
.template
.replace("{{CLI_VERSION}}", CLI_VERSION)
.replace(
"{{SKILL_SCHEMA_VERSION}}",
&SKILL_SCHEMA_VERSION.to_string(),
)
}
#[derive(Serialize)]
struct ListEntry<'a> {
name: &'a str,
description: &'a str,
cli_version: &'a str,
schema_version: u32,
path_in_repo: &'a str,
}
#[derive(Serialize)]
struct ListPayload<'a> {
skills: Vec<ListEntry<'a>>,
}
fn list(format: OutputFormat) -> Result<(), CliError> {
let skills: Vec<ListEntry> = CATALOG
.iter()
.map(|s| ListEntry {
name: s.name,
description: s.description,
cli_version: CLI_VERSION,
schema_version: SKILL_SCHEMA_VERSION,
path_in_repo: s.path_in_repo,
})
.collect();
match format {
OutputFormat::Json => emit_json(&ListPayload { skills }, &[])?,
OutputFormat::Text => {
if skills.is_empty() {
println!("no skills bundled");
} else {
for s in &skills {
println!("{} (cli {}) {}", s.name, s.cli_version, s.description);
}
}
}
}
Ok(())
}
#[derive(Serialize)]
struct PrintPayload<'a> {
name: &'a str,
cli_version: &'a str,
schema_version_skill: u32,
content: String,
path_in_repo: &'a str,
}
fn print(args: &PrintArgs, format: OutputFormat) -> Result<(), CliError> {
let skill = lookup(&args.name)?;
let content = render(skill);
match format {
OutputFormat::Json => emit_json(
&PrintPayload {
name: skill.name,
cli_version: CLI_VERSION,
schema_version_skill: SKILL_SCHEMA_VERSION,
content,
path_in_repo: skill.path_in_repo,
},
&[],
)?,
OutputFormat::Text => print!("{content}"),
}
Ok(())
}
#[derive(Serialize)]
struct InstalledEntry {
name: String,
agent: &'static str,
dest_path: String,
cli_version: String,
schema_version: u32,
}
#[derive(Serialize)]
struct InstallPayload {
installed: Vec<InstalledEntry>,
}
struct PlanEntry<'a> {
skill: &'a BundledSkill,
runtime: Runtime,
path: PathBuf,
}
fn install(args: &InstallArgs, format: OutputFormat) -> Result<(), CliError> {
let targets: Vec<&BundledSkill> = match &args.name {
Some(name) => vec![lookup(name)?],
None => CATALOG.iter().collect(),
};
let home = home_dir();
let mut plan = Vec::new();
for &runtime in selected_runtimes(args.agent) {
let root = match &args.dest {
Some(dest) => dest.clone(),
None => runtime.default_root(home.as_deref().ok_or_else(home_error)?),
};
for skill in &targets {
plan.push(PlanEntry {
skill,
runtime,
path: runtime.path_under(&root, skill.name),
});
}
}
let mut warnings = Vec::new();
let mut checked = std::collections::HashSet::new();
for entry in &plan {
if checked.insert(entry.path.clone()) {
if let Some(w) = check_drift(&entry.path, args.force)? {
warnings.push(w);
}
}
}
let mut installed = Vec::new();
let mut written = std::collections::HashSet::new();
for (idx, entry) in plan.iter().enumerate() {
if written.insert(entry.path.clone()) {
let content = render(entry.skill);
write_atomic(&entry.path, &content, idx)?;
}
installed.push(InstalledEntry {
name: entry.skill.name.to_string(),
agent: entry.runtime.label(),
dest_path: entry.path.display().to_string(),
cli_version: CLI_VERSION.to_string(),
schema_version: SKILL_SCHEMA_VERSION,
});
}
match format {
OutputFormat::Json => emit_json(&InstallPayload { installed }, &warnings)?,
OutputFormat::Text => {
for w in &warnings {
eprintln!("warning: {w}");
}
for e in &installed {
println!("installed {} ({}) → {}", e.name, e.agent, e.dest_path);
}
}
}
Ok(())
}
fn write_atomic(path: &Path, content: &str, idx: usize) -> Result<(), CliError> {
let fail = |e: std::io::Error, what: &str| {
CliError::system(
"install_failed",
format!("could not {what} `{}`: {e}", path.display()),
)
};
let parent = path.parent().ok_or_else(|| {
CliError::system(
"install_failed",
format!("destination `{}` has no parent directory", path.display()),
)
})?;
std::fs::create_dir_all(parent).map_err(|e| fail(e, "create"))?;
let tmp = parent.join(format!(".ossctl-skill.{}.{idx}.tmp", std::process::id()));
std::fs::write(&tmp, content).map_err(|e| fail(e, "write"))?;
std::fs::rename(&tmp, path).map_err(|e| {
let _ = std::fs::remove_file(&tmp);
fail(e, "install")
})
}
fn home_dir() -> Option<PathBuf> {
std::env::var_os("HOME")
.map(PathBuf::from)
.filter(|p| !p.as_os_str().is_empty())
}
fn home_error() -> CliError {
CliError::system(
"no_home_dir",
"cannot resolve the skills directory: $HOME is unset (pass --dest <PATH>)",
)
}
fn check_drift(path: &Path, force: bool) -> Result<Option<String>, CliError> {
let existing = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => {
return Err(CliError::system(
"install_failed",
format!("could not inspect existing `{}`: {e}", path.display()),
));
}
};
let Some(on_disk) = frontmatter_cli_version(&existing) else {
if force {
return Ok(Some(format!(
"overwriting `{}`: existing file has no readable cli_version",
path.display()
)));
}
return Err(CliError::user(
"skill_install_conflict",
format!(
"`{}` exists but its cli_version is unreadable; pass --force to overwrite",
path.display()
),
));
};
match compare_versions(&on_disk, CLI_VERSION) {
Some(std::cmp::Ordering::Less) => Ok(Some(format!(
"upgrading `{}` from cli_version {on_disk} to {CLI_VERSION}",
path.display()
))),
Some(std::cmp::Ordering::Equal) => Ok(None),
Some(std::cmp::Ordering::Greater) => {
if force {
Ok(Some(format!(
"downgrading `{}` from cli_version {on_disk} to {CLI_VERSION} (--force)",
path.display()
)))
} else {
Err(CliError::user(
"skill_version_mismatch",
format!(
"`{}` has cli_version {on_disk}, newer than this binary ({CLI_VERSION}); \
pass --force to overwrite",
path.display()
),
))
}
}
None => {
if force {
Ok(Some(format!(
"overwriting `{}`: existing cli_version `{on_disk}` is unparseable",
path.display()
)))
} else {
Err(CliError::user(
"skill_install_conflict",
format!(
"`{}` has an unparseable cli_version `{on_disk}`; pass --force to overwrite",
path.display()
),
))
}
}
}
}
pub(crate) fn frontmatter_cli_version(text: &str) -> Option<String> {
frontmatter_field(text, "cli_version")
}
pub(crate) fn frontmatter_field(text: &str, key: &str) -> Option<String> {
let text = text.strip_prefix('\u{FEFF}').unwrap_or(text);
let body = text.strip_prefix("---")?;
let end = body.find("\n---")?;
for line in body[..end].lines() {
if line.starts_with(char::is_whitespace) {
continue;
}
let Some(rest) = line.trim_end().strip_prefix(&format!("{key}:")) else {
continue;
};
let v = rest.split(" #").next().unwrap_or(rest).trim();
let v = v.trim_matches(|c| c == '"' || c == '\'');
if !v.is_empty() {
return Some(v.to_string());
}
}
None
}
fn compare_versions(a: &str, b: &str) -> Option<std::cmp::Ordering> {
let a = semver::Version::parse(a).ok()?;
let b = semver::Version::parse(b).ok()?;
Some(a.cmp(&b))
}
#[cfg(test)]
mod tests {
use super::*;
use std::cmp::Ordering;
#[test]
fn the_binarys_own_version_parses() {
assert_eq!(
compare_versions(CLI_VERSION, CLI_VERSION),
Some(Ordering::Equal)
);
}
#[test]
fn semver_precedence_is_respected() {
assert_eq!(compare_versions("0.1.0", "0.2.0"), Some(Ordering::Less));
assert_eq!(
compare_versions("1.0.0-rc.1", "1.0.0"),
Some(Ordering::Less)
);
assert_eq!(
compare_versions("0.1.0-rc.1", "0.1.0-rc.1"),
Some(Ordering::Equal)
);
assert!(compare_versions("0.1.0+build.7", "0.1.0").is_some());
assert_eq!(compare_versions("not-a-version", "0.1.0"), None);
}
#[test]
fn frontmatter_reads_top_level_quoted_value() {
let text = "---\nname: x\ncli_version: \"0.3.1\"\n---\nbody\n";
assert_eq!(frontmatter_cli_version(text).as_deref(), Some("0.3.1"));
}
#[test]
fn frontmatter_tolerates_bom_and_unquoted() {
let text = "\u{FEFF}---\ncli_version: 0.3.1\n---\n";
assert_eq!(frontmatter_cli_version(text).as_deref(), Some("0.3.1"));
}
#[test]
fn frontmatter_strips_trailing_comment() {
let text = "---\ncli_version: \"0.3.1\" # pinned\n---\n";
assert_eq!(frontmatter_cli_version(text).as_deref(), Some("0.3.1"));
}
#[test]
fn frontmatter_ignores_nested_key() {
let text = "---\nmetadata:\n cli_version: \"9.9.9\"\n---\n";
assert_eq!(frontmatter_cli_version(text), None);
}
#[test]
fn frontmatter_requires_a_closing_delimiter() {
let text = "---\nname: x\nsome prose mentioning cli_version: fake\n";
assert_eq!(frontmatter_cli_version(text), None);
}
}