use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use project_canon_core::EnvConfigLayer;
use crate::error::{fail, json_requested, write_stdout, CliError};
use crate::json::Json;
const SCHEMA_VERSION: i64 = 1;
const SKILL_SCHEMA_VERSION: i64 = 1;
const CLI_VERSION: &str = env!("CARGO_PKG_VERSION");
use project_canon_core::CANON;
const MARKER_PREFIX: &str = "<!-- Installed by `project-canon skill install`";
const EXIT_OK: u8 = 0;
struct ShippedSkill {
name: &'static str,
description: &'static str,
}
const SHIPPED: &[ShippedSkill] = &[ShippedSkill {
name: "ai-first-cli-canon",
description: "The AI-first CLI canon (AGENTS-AI-FIRST-CLI.md, \u{a7}1\u{2013}\u{a7}22): the family's binding conventions for any CLI surface \u{2014} strict input validation, --json output, JSONL logs, non-interactive operation, informative errors, meaningful exit codes, composable commands. Reference this when designing or changing this repo's CLI surface.",
}];
pub(crate) fn bundled_skill_metadata() -> Vec<(&'static str, &'static str, i64)> {
SHIPPED
.iter()
.map(|skill| (skill.name, CLI_VERSION, SKILL_SCHEMA_VERSION))
.collect()
}
fn lookup_skill(name: &str) -> Option<&'static ShippedSkill> {
SHIPPED.iter().find(|s| s.name == name)
}
#[cfg_attr(not(test), allow(dead_code))]
fn is_valid_skill_name(name: &str) -> bool {
!name.is_empty()
&& name.len() <= 64
&& name.chars().next().is_some_and(|c| c.is_ascii_lowercase())
&& name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
}
pub fn run(args: &[String]) -> ExitCode {
match args.first().map(String::as_str) {
Some("install") => install::run(&args[1..]),
Some("list") => list::run(&args[1..]),
Some("print") | Some("show") => print::run(&args[1..]),
None | Some("--help") => {
print!("{HELP}");
ExitCode::from(EXIT_OK)
}
Some(other) => fail(
json_requested(args),
CliError::actionable(
"usage_error",
format!(
"skill: unknown subcommand or flag: {other:?}; known: install, list, print (alias: show)"
),
),
)
}
}
const HELP: &str = "\
project-canon skill — install / list / print the companion AI-skills (canon \u{a7}15/\u{a7}16/\u{a7}17)
USAGE:
project-canon skill install [<name>] [FLAGS]
project-canon skill list [--json]
project-canon skill print <name> [--agent claude|codex] [--json] (alias: show)
The one shipped skill, `ai-first-cli-canon`, is the AI-first CLI canon as a versioned,
installable reference skill (single-sourced from AGENTS-AI-FIRST-CLI.md).
INSTALL FLAGS:
--target <dir> Install base (default: $HOME \u{2192} ~/.claude/skills/). Pass a repo
root to install into that repo's agent dirs.
--agent <claude|codex|all> Which runtime layout(s) to write (default: all).
--force Overwrite a newer on-disk skill or a non-managed file at the path.
--dry-run Print the per-file plan; write nothing.
--json Emit the structured \u{a7}10 report on stdout.
SIDE EFFECTS:
install writes skill files under <target> and nothing else \u{2014} it never shells out or
touches the network. list/print are read-only. --dry-run writes nothing.
EXIT CODES:
0 success (installed/upgraded/unchanged, dry-run plan, list, or print)
2 usage/operational error (bad flag/--agent, unknown skill, a blocking clobber/version
conflict without --force, an I/O fault, or malformed PROJECT_CANON_* env)
";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Agent {
Claude,
Codex,
}
impl Agent {
fn slug(self) -> &'static str {
match self {
Agent::Claude => "claude",
Agent::Codex => "codex",
}
}
fn path(self, base: &Path, name: &str) -> PathBuf {
match self {
Agent::Claude => base.join(".claude/skills").join(name).join("SKILL.md"),
Agent::Codex => base.join(".codex/prompts").join(format!("{name}.md")),
}
}
fn render(self, skill: &ShippedSkill) -> String {
let provenance = provenance_line(skill.name);
match self {
Agent::Claude => format!(
"---\nname: {}\ndescription: {}\ncli_version: \"{CLI_VERSION}\"\nschema_version: {SKILL_SCHEMA_VERSION}\n---\n\n{provenance}\n\n{CANON}",
skill.name,
yaml_double_quote(skill.description),
),
Agent::Codex => format!("{provenance}\n\n{CANON}"),
}
}
}
fn yaml_double_quote(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
_ => out.push(c),
}
}
out.push('"');
out
}
fn provenance_line(name: &str) -> String {
format!(
"{MARKER_PREFIX} \u{2014} {name} cli_version={CLI_VERSION} schema_version={SKILL_SCHEMA_VERSION}. Generated from AGENTS-AI-FIRST-CLI.md; do not hand-edit, re-run to upgrade. -->"
)
}
fn parse_agent(s: &str, allow_all: bool) -> Result<Vec<Agent>, String> {
match s {
"claude" => Ok(vec![Agent::Claude]),
"codex" => Ok(vec![Agent::Codex]),
"all" if allow_all => Ok(vec![Agent::Claude, Agent::Codex]),
_ => {
let valid = if allow_all {
"claude/codex/all"
} else {
"claude/codex"
};
Err(format!("invalid --agent {s:?} (expected one of {valid})"))
}
}
}
fn reject_inline(flag: &str, inline: Option<&str>) -> Result<(), String> {
match inline {
Some(value) => Err(format!("flag {flag} does not take a value (got {value:?})")),
None => Ok(()),
}
}
fn set_flag(slot: &mut bool, name: &str) -> Result<(), String> {
if *slot {
return Err(format!("repeated flag: {name}"));
}
*slot = true;
Ok(())
}
fn take_value<'a>(
flag: &str,
inline: Option<&str>,
iter: &mut impl Iterator<Item = &'a String>,
) -> Result<String, String> {
let value = match inline {
Some(v) => v.to_string(),
None => {
let next = iter
.next()
.cloned()
.ok_or_else(|| format!("{flag} requires a value"))?;
if next.starts_with('-') {
return Err(format!(
"{flag} requires a value, got flag-like token {next:?} (use {flag}={next} for a literal dash value)"
));
}
next
}
};
if value.is_empty() {
return Err(format!("{flag} requires a non-empty value"));
}
Ok(value)
}
fn split_flag(arg: &str) -> (&str, Option<&str>) {
match arg.split_once('=') {
Some((f, v)) if f.starts_with("--") => (f, Some(v)),
_ => (arg, None),
}
}
fn abs(root: &Path) -> String {
let p = if root.is_absolute() {
root.to_path_buf()
} else {
std::env::current_dir()
.map(|c| c.join(root))
.unwrap_or_else(|_| root.to_path_buf())
};
p.display().to_string()
}
fn validate_env() -> Result<(), String> {
EnvConfigLayer::from_env_vars(&std::env::vars().collect())
.map(|_| ())
.map_err(|err| err.to_string())
}
mod install {
use super::*;
#[derive(Debug, PartialEq, Eq)]
struct Args {
skill: Option<String>,
target: Option<String>,
agents: Vec<Agent>,
force: bool,
dry_run: bool,
json: bool,
}
#[derive(Debug, PartialEq, Eq)]
enum Command {
Help,
Run(Args),
}
pub fn run(args: &[String]) -> ExitCode {
let parsed = match parse(args) {
Ok(Command::Help) => {
print!("{HELP}");
return ExitCode::from(EXIT_OK);
}
Ok(Command::Run(a)) => a,
Err(err) => {
return fail(
json_requested(args),
CliError::actionable("usage_error", format!("skill install: {err}")),
);
}
};
if let Err(err) = validate_env() {
return fail(
parsed.json,
CliError::actionable("validation_error", format!("skill install: {err}")),
);
}
let skills: Vec<&ShippedSkill> = match &parsed.skill {
Some(name) => match lookup_skill(name) {
Some(s) => vec![s],
None => {
return fail(
parsed.json,
CliError::actionable(
"not_found",
format!(
"skill install: unknown skill {name:?} (available: {})",
SHIPPED
.iter()
.map(|s| s.name)
.collect::<Vec<_>>()
.join(", ")
),
),
);
}
},
None => SHIPPED.iter().collect(),
};
let base = match resolve_base(&parsed.target) {
Ok(b) => b,
Err(err) => {
return fail(
parsed.json,
CliError::actionable("validation_error", format!("skill install: {err}")),
);
}
};
let rows = match resolve_rows(&skills, &parsed.agents, &base, parsed.force) {
Ok(rows) => rows,
Err(fault) => {
return fail(
parsed.json,
CliError::system(
"io_error",
format!(
"skill install: cannot inspect {}: {}",
fault.path, fault.source
),
),
);
}
};
let report = Report {
target: abs(&base),
agents: parsed.agents.clone(),
dry_run: parsed.dry_run,
force: parsed.force,
rows,
};
if report.rows.iter().any(|r| r.action.is_blocking()) {
let blocked = report
.rows
.iter()
.find(|r| r.action.is_blocking())
.expect("blocking row exists after any check");
return fail(
parsed.json,
CliError::actionable(
"already_exists",
format!(
"skill install: refusing to write {} ({})\npass --force to overwrite.",
blocked.path,
blocked.action.blocking_reason().unwrap_or("conflict")
),
),
);
}
if !parsed.dry_run {
for r in &report.rows {
if let (Some(content), true) = (&r.desired, r.action.writes()) {
if let Err(source) = write_file_atomic(Path::new(&r.path), content) {
return fail(
parsed.json,
CliError::system(
"io_error",
format!("skill install: writing {}: {source}", r.path),
),
);
}
}
}
for r in &report.rows {
if let Some(note) = &r.note {
eprintln!("project-canon skill install: {}: {note}", r.path);
}
}
}
let output = if parsed.json {
format!("{}\n", report.to_json(EXIT_OK, "ok"))
} else {
report.render_human()
};
write_stdout(&output, parsed.json)
}
fn resolve_base(target: &Option<String>) -> Result<PathBuf, String> {
if let Some(t) = target {
return Ok(PathBuf::from(t));
}
match std::env::var("HOME") {
Ok(h) if !h.is_empty() => Ok(PathBuf::from(h)),
_ => Err(
"no --target given and $HOME is not set (pass --target <dir> for the install base)"
.to_string(),
),
}
}
fn parse(args: &[String]) -> Result<Command, String> {
let mut skill: Option<String> = None;
let mut target: Option<String> = None;
let mut agents: Option<Vec<Agent>> = None;
let mut force = false;
let mut dry_run = false;
let mut json = false;
let mut positional_only = false;
let mut iter = args.iter();
while let Some(arg) = iter.next() {
if positional_only {
set_skill(&mut skill, arg)?;
continue;
}
if arg == "--" {
positional_only = true;
continue;
}
let (flag, inline) = split_flag(arg);
match flag {
"--help" => {
reject_inline("--help", inline)?;
return Ok(Command::Help);
}
"--force" => {
reject_inline("--force", inline)?;
set_flag(&mut force, "--force")?;
}
"--dry-run" => {
reject_inline("--dry-run", inline)?;
set_flag(&mut dry_run, "--dry-run")?;
}
"--json" => {
reject_inline("--json", inline)?;
set_flag(&mut json, "--json")?;
}
"--target" => {
if target.is_some() {
return Err("repeated flag: --target".to_string());
}
target = Some(take_value("--target", inline, &mut iter)?);
}
"--agent" => {
if agents.is_some() {
return Err("repeated flag: --agent".to_string());
}
agents = Some(parse_agent(
&take_value("--agent", inline, &mut iter)?,
true,
)?);
}
other if other.starts_with('-') => {
return Err(format!("unknown flag: {other}"));
}
_ => set_skill(&mut skill, arg)?,
}
}
Ok(Command::Run(Args {
skill,
target,
agents: agents.unwrap_or_else(|| vec![Agent::Claude, Agent::Codex]),
force,
dry_run,
json,
}))
}
fn set_skill(skill: &mut Option<String>, arg: &str) -> Result<(), String> {
if arg.is_empty() {
return Err("skill name must not be empty".to_string());
}
if skill.is_some() {
return Err(format!("unexpected extra argument: {arg:?}"));
}
*skill = Some(arg.to_string());
Ok(())
}
#[derive(Debug)]
pub(super) struct Row {
pub name: &'static str,
pub agent: Agent,
pub path: String,
pub desired: Option<String>,
pub action: Action,
pub note: Option<String>,
}
pub(super) struct Fault {
pub path: String,
pub source: std::io::Error,
}
enum Existing {
Absent,
NonRegular,
Regular(Vec<u8>),
}
fn resolve_rows(
skills: &[&ShippedSkill],
agents: &[Agent],
base: &Path,
force: bool,
) -> Result<Vec<Row>, Fault> {
let mut rows = Vec::new();
for skill in skills {
for &agent in agents {
let path = agent.path(base, skill.name);
let desired = agent.render(skill);
let existing = match std::fs::symlink_metadata(&path) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Existing::Absent,
Err(source) => {
return Err(Fault {
path: path.display().to_string(),
source,
})
}
Ok(md) if !md.file_type().is_file() => Existing::NonRegular,
Ok(_) => match std::fs::read(&path) {
Ok(bytes) => Existing::Regular(bytes),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Existing::Absent,
Err(source) => {
return Err(Fault {
path: path.display().to_string(),
source,
})
}
},
};
let (action, note) = decide(&existing, desired.as_bytes(), force);
rows.push(Row {
name: skill.name,
agent,
path: path.display().to_string(),
desired: action.writes().then_some(desired),
action,
note,
});
}
}
Ok(rows)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum Action {
Install,
Unchanged,
Upgrade,
Blocked(BlockReason),
Overwrite,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum BlockReason {
Foreign,
NewerOnDisk,
}
impl Action {
pub(super) fn writes(self) -> bool {
matches!(self, Action::Install | Action::Upgrade | Action::Overwrite)
}
pub(super) fn is_blocking(self) -> bool {
matches!(self, Action::Blocked(_))
}
pub(super) fn blocking_reason(self) -> Option<&'static str> {
match self {
Action::Blocked(BlockReason::Foreign) => {
Some("a non-managed or non-regular file already exists here")
}
Action::Blocked(BlockReason::NewerOnDisk) => {
Some("on-disk skill is newer than this binary")
}
_ => None,
}
}
pub(super) fn as_str(self) -> &'static str {
match self {
Action::Install => "install",
Action::Unchanged => "unchanged",
Action::Upgrade => "upgrade",
Action::Blocked(BlockReason::Foreign) => "blocked-foreign",
Action::Blocked(BlockReason::NewerOnDisk) => "blocked-newer",
Action::Overwrite => "overwrite",
}
}
}
fn decide(existing: &Existing, desired: &[u8], force: bool) -> (Action, Option<String>) {
let cur = match existing {
Existing::Absent => return (Action::Install, None),
Existing::NonRegular => {
return if force {
(
Action::Overwrite,
Some("overwrite a non-regular file (--force)".to_string()),
)
} else {
(Action::Blocked(BlockReason::Foreign), None)
}
}
Existing::Regular(bytes) => bytes.as_slice(),
};
if cur == desired {
return (Action::Unchanged, None);
}
if !is_ours(cur) {
return if force {
(
Action::Overwrite,
Some("overwrite a non-managed file (--force)".to_string()),
)
} else {
(Action::Blocked(BlockReason::Foreign), None)
};
}
match marker_cli_version(cur) {
Some(old) if cmp_versions(&old, CLI_VERSION) == std::cmp::Ordering::Greater => {
if force {
(
Action::Overwrite,
Some(format!(
"downgrade on-disk cli_version {old} \u{2192} {CLI_VERSION} (--force)"
)),
)
} else {
(Action::Blocked(BlockReason::NewerOnDisk), None)
}
}
Some(old) if cmp_versions(&old, CLI_VERSION) == std::cmp::Ordering::Less => (
Action::Upgrade,
Some(format!(
"upgrade from cli_version {old} \u{2192} {CLI_VERSION}"
)),
),
_ => (Action::Upgrade, None),
}
}
fn is_ours(cur: &[u8]) -> bool {
let text = String::from_utf8_lossy(cur);
if text.starts_with(MARKER_PREFIX) {
return true; }
if let Some(rest) = text.strip_prefix("---\n") {
if let Some(idx) = rest.find("\n---\n") {
let after = &rest[idx + "\n---\n".len()..];
let after = after.strip_prefix('\n').unwrap_or(after);
return after.starts_with(MARKER_PREFIX);
}
}
false
}
fn marker_cli_version(bytes: &[u8]) -> Option<String> {
let text = String::from_utf8_lossy(bytes);
let start = text.find(MARKER_PREFIX)?;
let marker = &text[start..];
let marker = marker.split_once("-->").map(|(m, _)| m).unwrap_or(marker);
let rest = marker.split_once("cli_version=")?.1;
let ver: String = rest.chars().take_while(|c| !c.is_whitespace()).collect();
(!ver.is_empty()).then_some(ver)
}
fn cmp_versions(a: &str, b: &str) -> std::cmp::Ordering {
let parse =
|s: &str| -> Option<Vec<u64>> { s.split('.').map(|p| p.parse::<u64>().ok()).collect() };
match (parse(a), parse(b)) {
(Some(a), Some(b)) => a.cmp(&b),
_ => a.cmp(b),
}
}
fn write_file_atomic(path: &Path, content: &str) -> std::io::Result<()> {
let parent = path.parent().ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::InvalidInput, "path has no parent")
})?;
std::fs::create_dir_all(parent)?;
let file_name = path.file_name().and_then(|n| n.to_str()).ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::InvalidInput, "path has no file name")
})?;
let tmp = parent.join(format!(".{file_name}.tmp-{}", std::process::id()));
let write_result = (|| -> std::io::Result<()> {
let mut f = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&tmp)?;
f.write_all(content.as_bytes())?;
f.sync_all()?;
Ok(())
})();
if let Err(e) = write_result {
let _ = std::fs::remove_file(&tmp);
return Err(e);
}
if let Err(e) = std::fs::rename(&tmp, path) {
let _ = std::fs::remove_file(&tmp);
return Err(e);
}
Ok(())
}
struct Report {
target: String,
agents: Vec<Agent>,
dry_run: bool,
force: bool,
rows: Vec<Row>,
}
impl Report {
fn count(&self, action: Action) -> usize {
self.rows.iter().filter(|r| r.action == action).count()
}
fn to_json(&self, exit_code: u8, status: &str) -> Json {
let files = self
.rows
.iter()
.map(|r| {
let mut obj = vec![
("name".into(), Json::str(r.name)),
("agent".into(), Json::str(r.agent.slug())),
("path".into(), Json::str(r.path.clone())),
("action".into(), Json::str(r.action.as_str())),
("blocked".into(), Json::Bool(r.action.is_blocking())),
];
if let Some(note) = &r.note {
obj.push(("note".into(), Json::str(note.clone())));
}
Json::Object(obj)
})
.collect();
let written = if self.dry_run {
0
} else {
self.rows.iter().filter(|r| r.action.writes()).count()
};
let summary = Json::Object(vec![
("files".into(), Json::Int(self.rows.len() as i64)),
(
"installed".into(),
Json::Int(self.count(Action::Install) as i64),
),
(
"upgraded".into(),
Json::Int(self.count(Action::Upgrade) as i64),
),
(
"overwritten".into(),
Json::Int(self.count(Action::Overwrite) as i64),
),
(
"unchanged".into(),
Json::Int(self.count(Action::Unchanged) as i64),
),
(
"blocked".into(),
Json::Int(self.rows.iter().filter(|r| r.action.is_blocking()).count() as i64),
),
("written".into(), Json::Int(written as i64)),
]);
Json::Object(vec![
("schema_version".into(), Json::Int(SCHEMA_VERSION)),
("tool".into(), Json::str("project-canon")),
("verb".into(), Json::str("skill install")),
("status".into(), Json::str(status)),
("cli_version".into(), Json::str(CLI_VERSION)),
("target".into(), Json::str(self.target.clone())),
(
"agents".into(),
Json::Array(self.agents.iter().map(|a| Json::str(a.slug())).collect()),
),
("dry_run".into(), Json::Bool(self.dry_run)),
("force".into(), Json::Bool(self.force)),
("files".into(), Json::Array(files)),
("summary".into(), summary),
("exit_code".into(), Json::Int(exit_code as i64)),
])
}
fn render_human(&self) -> String {
let mut out = String::new();
let mode = if self.dry_run { " (dry-run)" } else { "" };
out.push_str(&format!(
"project-canon skill install: base {}{mode}\n",
self.target
));
for r in &self.rows {
let verb = if self.dry_run && r.action.writes() {
"would-".to_string() + r.action.as_str()
} else {
r.action.as_str().to_string()
};
out.push_str(&format!(" {:<16} {} [{}]\n", verb, r.path, r.agent.slug()));
}
let written = if self.dry_run {
0
} else {
self.rows.iter().filter(|r| r.action.writes()).count()
};
let verb = if self.dry_run { "would write" } else { "wrote" };
out.push_str(&format!(
"summary: {verb} {written} file{}, {} unchanged\n",
if written == 1 { "" } else { "s" },
self.count(Action::Unchanged),
));
out
}
}
#[cfg(test)]
mod tests {
use super::*;
fn canon_bytes(agent: Agent) -> String {
agent.render(&SHIPPED[0])
}
fn reg(bytes: &[u8]) -> Existing {
Existing::Regular(bytes.to_vec())
}
#[test]
fn absent_installs() {
let (a, note) = decide(&Existing::Absent, b"x", false);
assert_eq!(a, Action::Install);
assert!(note.is_none());
}
#[test]
fn identical_is_unchanged() {
let desired = canon_bytes(Agent::Claude);
let (a, _) = decide(®(desired.as_bytes()), desired.as_bytes(), false);
assert_eq!(a, Action::Unchanged);
}
#[test]
fn foreign_file_is_blocked_without_force() {
let (a, _) = decide(®(b"hand-written notes"), b"desired", false);
assert_eq!(a, Action::Blocked(BlockReason::Foreign));
let (a, note) = decide(®(b"hand-written notes"), b"desired", true);
assert_eq!(a, Action::Overwrite);
assert!(note.unwrap().contains("non-managed"));
}
#[test]
fn non_regular_file_is_blocked_without_force() {
let (a, _) = decide(&Existing::NonRegular, b"desired", false);
assert_eq!(a, Action::Blocked(BlockReason::Foreign));
let (a, _) = decide(&Existing::NonRegular, b"desired", true);
assert_eq!(a, Action::Overwrite);
}
#[test]
fn a_marker_only_quoted_in_prose_is_not_ours() {
let prose = format!("# My notes\n\nWe use `{MARKER_PREFIX}`-style tools.\n");
let (a, _) = decide(®(prose.as_bytes()), b"desired", false);
assert_eq!(
a,
Action::Blocked(BlockReason::Foreign),
"an un-anchored marker must not make a foreign file 'ours'"
);
}
#[test]
fn our_stale_file_upgrades() {
let old = format!(
"{MARKER_PREFIX} \u{2014} ai-first-cli-canon cli_version={CLI_VERSION} schema_version=1. -->\n\nOLD BODY"
);
let (a, _) = decide(®(old.as_bytes()), b"new desired", false);
assert_eq!(a, Action::Upgrade);
}
#[test]
fn claude_form_marker_is_anchored_after_frontmatter() {
let claude = canon_bytes(Agent::Claude);
assert!(is_ours(claude.as_bytes()));
let tampered = format!("hello\n{claude}");
assert!(!is_ours(tampered.as_bytes()));
}
#[test]
fn newer_on_disk_is_blocked_without_force() {
let newer = format!(
"{MARKER_PREFIX} \u{2014} ai-first-cli-canon cli_version=99.0.0 schema_version=1. -->\n\nBODY"
);
let (a, _) = decide(®(newer.as_bytes()), b"desired", false);
assert_eq!(a, Action::Blocked(BlockReason::NewerOnDisk));
let (a, note) = decide(®(newer.as_bytes()), b"desired", true);
assert_eq!(a, Action::Overwrite);
assert!(note.unwrap().contains("downgrade"));
}
#[test]
fn older_on_disk_upgrades_without_force() {
let older = format!(
"{MARKER_PREFIX} \u{2014} ai-first-cli-canon cli_version=0.0.0 schema_version=1. -->\n\nBODY"
);
let (a, _) = decide(®(older.as_bytes()), b"desired", false);
assert!(a.writes() && !a.is_blocking());
}
#[test]
fn version_compare_orders_numerically() {
use std::cmp::Ordering::*;
assert_eq!(cmp_versions("0.0.0", "0.1.0"), Less);
assert_eq!(cmp_versions("1.2.3", "1.2.3"), Equal);
assert_eq!(cmp_versions("2.0.0", "1.9.9"), Greater);
assert_eq!(cmp_versions("0.10.0", "0.9.0"), Greater);
}
#[test]
fn marker_version_parses_only_from_within_the_marker() {
let s = format!("{MARKER_PREFIX} \u{2014} x cli_version=1.2.3 schema_version=1. -->");
assert_eq!(marker_cli_version(s.as_bytes()).as_deref(), Some("1.2.3"));
assert_eq!(marker_cli_version(b"no marker here"), None);
let spoof = format!("cli_version=9.9.9\n{MARKER_PREFIX} y cli_version=1.0.0 -->");
assert_eq!(
marker_cli_version(spoof.as_bytes()).as_deref(),
Some("1.0.0")
);
}
#[test]
fn rendered_forms_embed_the_canon_and_marker() {
let claude = canon_bytes(Agent::Claude);
let codex = canon_bytes(Agent::Codex);
assert!(claude.contains(CANON));
assert!(codex.contains(CANON));
assert!(claude.starts_with("---\nname: ai-first-cli-canon"));
assert!(!codex.starts_with("---"));
assert!(claude.contains(MARKER_PREFIX));
assert!(codex.contains(MARKER_PREFIX));
}
#[test]
fn claude_description_is_a_quoted_yaml_scalar() {
let claude = canon_bytes(Agent::Claude);
assert!(
claude.contains("description: \""),
"description must be a double-quoted YAML scalar"
);
}
#[test]
fn shipped_names_are_path_safe_slugs() {
for s in SHIPPED {
assert!(
is_valid_skill_name(s.name),
"shipped skill name {:?} is not a path-safe slug",
s.name
);
}
}
#[test]
fn canon_master_has_no_leading_frontmatter_delimiter() {
assert!(!CANON.starts_with("---"));
}
}
}
mod list {
use super::*;
pub fn run(args: &[String]) -> ExitCode {
let mut json = false;
for arg in args {
let (flag, inline) = split_flag(arg);
match flag {
"--help" => {
if let Err(err) = reject_inline("--help", inline) {
return fail(
json_requested(args),
CliError::actionable("usage_error", format!("skill list: {err}")),
);
}
print!("{HELP}");
return ExitCode::from(EXIT_OK);
}
"--json" => {
if let Err(err) =
reject_inline("--json", inline).and_then(|()| set_flag(&mut json, "--json"))
{
return fail(
json_requested(args),
CliError::actionable("usage_error", format!("skill list: {err}")),
);
}
}
other => {
return fail(
json_requested(args),
CliError::actionable(
"usage_error",
format!("skill list: unexpected argument: {other:?}"),
),
);
}
}
}
if let Err(err) = validate_env() {
return fail(
json,
CliError::actionable("validation_error", format!("skill list: {err}")),
);
}
let output = if json {
let skills = SHIPPED
.iter()
.map(|s| {
Json::Object(vec![
("name".into(), Json::str(s.name)),
("description".into(), Json::str(s.description)),
("cli_version".into(), Json::str(CLI_VERSION)),
(
"skill_schema_version".into(),
Json::Int(SKILL_SCHEMA_VERSION),
),
])
})
.collect();
format!(
"{}\n",
Json::Object(vec![
("schema_version".into(), Json::Int(SCHEMA_VERSION)),
("tool".into(), Json::str("project-canon")),
("verb".into(), Json::str("skill list")),
("cli_version".into(), Json::str(CLI_VERSION)),
("skills".into(), Json::Array(skills)),
("exit_code".into(), Json::Int(EXIT_OK as i64)),
])
)
} else {
SHIPPED
.iter()
.map(|s| {
format!(
"{} (cli_version {CLI_VERSION})\n {}\n",
s.name, s.description
)
})
.collect()
};
write_stdout(&output, json)
}
}
mod print {
use super::*;
pub fn run(args: &[String]) -> ExitCode {
let mut name: Option<String> = None;
let mut agent = Agent::Claude;
let mut agent_set = false;
let mut json = false;
let mut positional_only = false;
let mut iter = args.iter();
while let Some(arg) = iter.next() {
if positional_only {
if name.is_some() {
return usage(args, &format!("unexpected extra argument: {arg:?}"));
}
name = Some(arg.clone());
continue;
}
if arg == "--" {
positional_only = true;
continue;
}
let (flag, inline) = split_flag(arg);
match flag {
"--help" => {
if let Err(err) = reject_inline("--help", inline) {
return usage(args, &err);
}
print!("{HELP}");
return ExitCode::from(EXIT_OK);
}
"--json" => {
if let Err(err) =
reject_inline("--json", inline).and_then(|()| set_flag(&mut json, "--json"))
{
return usage(args, &err);
}
}
"--agent" => {
if agent_set {
return usage(args, "repeated flag: --agent");
}
let value = match take_value("--agent", inline, &mut iter) {
Ok(v) => v,
Err(err) => return usage(args, &err),
};
match parse_agent(&value, false) {
Ok(a) => agent = a[0],
Err(err) => return usage(args, &err),
}
agent_set = true;
}
other if other.starts_with('-') => {
return usage(args, &format!("unknown flag: {other}"));
}
_ => {
if name.is_some() {
return usage(args, &format!("unexpected extra argument: {arg:?}"));
}
name = Some(arg.clone());
}
}
}
if let Err(err) = validate_env() {
return fail(
json_requested(args),
CliError::actionable("validation_error", format!("skill print: {err}")),
);
}
let name = match name {
Some(n) => n,
None => return usage(args, "missing skill name (usage: skill print <name>)"),
};
let skill = match lookup_skill(&name) {
Some(s) => s,
None => {
return usage(
args,
&format!(
"unknown skill {name:?} (available: {})",
SHIPPED
.iter()
.map(|s| s.name)
.collect::<Vec<_>>()
.join(", ")
),
)
}
};
let content = agent.render(skill);
if json {
let payload = Json::Object(vec![
("schema_version".into(), Json::Int(SCHEMA_VERSION)),
("tool".into(), Json::str("project-canon")),
("verb".into(), Json::str("skill print")),
("name".into(), Json::str(skill.name)),
("cli_version".into(), Json::str(CLI_VERSION)),
(
"skill_schema_version".into(),
Json::Int(SKILL_SCHEMA_VERSION),
),
("agent".into(), Json::str(agent.slug())),
("content".into(), Json::str(content)),
("path_in_repo".into(), Json::str("AGENTS-AI-FIRST-CLI.md")),
("exit_code".into(), Json::Int(EXIT_OK as i64)),
]);
write_stdout(&format!("{payload}\n"), true)
} else {
write_stdout(&content, false)
}
}
fn usage(args: &[String], msg: &str) -> ExitCode {
fail(
json_requested(args),
CliError::actionable("usage_error", format!("skill print: {msg}")),
)
}
}