use std::path::PathBuf;
use std::process::Command;
use serde_json::Value;
use tempfile::TempDir;
fn bin(home: &TempDir) -> Command {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_orchestratectl"));
cmd.env("ORCHESTRATECTL_HOME", home.path());
cmd.env("HOME", home.path());
cmd
}
fn mk_home() -> TempDir {
tempfile::tempdir().expect("tempdir")
}
#[test]
fn skill_list_json_pins_catalog_shape() {
let home = mk_home();
let out = bin(&home)
.args(["skill", "list", "--output", "json"])
.output()
.expect("spawn");
assert!(out.status.success(), "exit: {:?}", out.status);
let v: Value = serde_json::from_slice(&out.stdout).expect("json");
assert_eq!(v["schema_version"], 1);
let skills = v["data"]["skills"].as_array().expect("skills array");
let mut names: Vec<&str> = skills.iter().map(|s| s["name"].as_str().unwrap()).collect();
names.sort_unstable();
assert_eq!(
names,
vec![
"fan-out",
"octl-run-overview",
"octl-spawn-spinoff",
"orchestrate",
"orchestratectl-overview",
"stint-handoff",
"stint-start",
"worktree",
"worktree-bug-analysis",
"worktree-bugfix",
"worktree-code",
"worktree-make-skill",
"worktree-merge",
"worktree-orchestrated",
"worktree-research",
"worktree-spinoff",
"worktree-status",
"worktree-technical-decision",
]
);
for s in skills {
assert!(
!s["description"].as_str().unwrap_or("").is_empty(),
"empty description for {}",
s["name"]
);
}
}
#[test]
fn skill_show_text_prints_skill_md_contents() {
let home = mk_home();
let out = bin(&home)
.args(["--output", "text", "skill", "show", "octl-run-overview"])
.output()
.expect("spawn");
assert!(out.status.success(), "exit: {:?}", out.status);
let stdout = String::from_utf8(out.stdout).expect("utf8");
assert!(
stdout.starts_with("---\nname: octl-run-overview"),
"show did not emit frontmatter: {stdout:?}"
);
}
#[test]
fn skill_show_json_wraps_content_under_data() {
let home = mk_home();
let out = bin(&home)
.args(["skill", "show", "octl-run-overview", "--output", "json"])
.output()
.expect("spawn");
assert!(out.status.success(), "exit: {:?}", out.status);
let v: Value = serde_json::from_slice(&out.stdout).expect("json");
assert_eq!(v["schema_version"], 1);
assert_eq!(v["data"]["name"], "octl-run-overview");
let content = v["data"]["content"].as_str().expect("content str");
assert!(content.starts_with("---\nname: octl-run-overview"));
}
#[test]
fn skill_install_refuses_overwrite_then_force_succeeds() {
let home = mk_home();
let dest = home.path().join("SKILL.md");
let out = bin(&home)
.args(["skill", "install", "octl-run-overview", "--dest"])
.arg(&dest)
.output()
.expect("spawn");
assert!(out.status.success(), "first install failed: {out:?}");
assert!(dest.exists(), "destination not created");
let out = bin(&home)
.args(["skill", "install", "octl-run-overview", "--dest"])
.arg(&dest)
.output()
.expect("spawn");
assert_eq!(out.status.code(), Some(2), "expected exit 2");
let err: Value = serde_json::from_slice(&out.stderr).expect("json err envelope");
assert_eq!(err["schema_version"], 1);
assert_eq!(err["error"]["code"], "refused_overwrite");
assert_eq!(err["error"]["invalid_value"], dest.display().to_string());
let out = bin(&home)
.args(["skill", "install", "octl-run-overview", "--dest"])
.arg(&dest)
.arg("--force")
.output()
.expect("spawn");
assert!(out.status.success(), "force install failed: {out:?}");
}
#[test]
fn skill_install_with_default_paths_writes_under_home() {
let home = mk_home();
let out = bin(&home)
.args(["skill", "install", "octl-run-overview", "--output", "json"])
.output()
.expect("spawn");
assert!(out.status.success(), "default install failed: {out:?}");
let v: Value = serde_json::from_slice(&out.stdout).expect("json");
let installed = v["data"]["installed"].as_array().expect("installed array");
assert_eq!(installed.len(), 1);
assert_eq!(installed[0]["agent"], "claude");
let expected: PathBuf = home
.path()
.join(".claude/skills/octl-run-overview/SKILL.md");
assert_eq!(installed[0]["path"], expected.display().to_string());
assert!(expected.exists(), "claude install not on disk");
}
#[test]
fn skill_install_agent_all_installs_to_both_default_paths() {
let home = mk_home();
let out = bin(&home)
.args([
"skill",
"install",
"octl-spawn-spinoff",
"--agent",
"all",
"--output",
"json",
])
.output()
.expect("spawn");
assert!(out.status.success(), "agent=all install failed: {out:?}");
let v: Value = serde_json::from_slice(&out.stdout).expect("json");
let installed = v["data"]["installed"].as_array().expect("installed");
let agents: Vec<&str> = installed
.iter()
.map(|f| f["agent"].as_str().unwrap())
.collect();
assert!(agents.contains(&"claude"));
assert!(agents.contains(&"codex"));
assert!(home
.path()
.join(".claude/skills/octl-spawn-spinoff/SKILL.md")
.exists());
assert!(home
.path()
.join(".codex/prompts/octl-spawn-spinoff.md")
.exists());
}
#[test]
fn skill_install_no_name_installs_every_skill() {
let home = mk_home();
let out = bin(&home)
.args(["skill", "install", "--output", "json"])
.output()
.expect("spawn");
assert!(out.status.success(), "install-all failed: {out:?}");
let v: Value = serde_json::from_slice(&out.stdout).expect("json");
let installed = v["data"]["installed"].as_array().expect("installed");
let names: Vec<&str> = installed
.iter()
.map(|f| f["name"].as_str().unwrap())
.collect();
assert!(names.contains(&"octl-run-overview"));
assert!(names.contains(&"octl-spawn-spinoff"));
}
#[test]
fn skill_install_agent_all_with_dest_is_rejected() {
let home = mk_home();
let dest = home.path().join("SKILL.md");
let out = bin(&home)
.args([
"skill",
"install",
"octl-run-overview",
"--agent",
"all",
"--dest",
])
.arg(&dest)
.output()
.expect("spawn");
assert_eq!(out.status.code(), Some(1), "expected user-error exit 1");
let err: Value = serde_json::from_slice(&out.stderr).expect("err json");
assert_eq!(err["error"]["code"], "invalid_arguments");
}
#[test]
fn skill_install_partial_failure_is_preflighted() {
let home = mk_home();
let codex = home.path().join(".codex/prompts");
std::fs::create_dir_all(&codex).unwrap();
std::fs::write(codex.join("octl-run-overview.md"), "pre-existing").unwrap();
let out = bin(&home)
.args(["skill", "install", "octl-run-overview", "--agent", "all"])
.output()
.expect("spawn");
assert_eq!(out.status.code(), Some(2));
let err: Value = serde_json::from_slice(&out.stderr).expect("err json");
assert_eq!(err["error"]["code"], "refused_overwrite");
assert!(!home
.path()
.join(".claude/skills/octl-run-overview/SKILL.md")
.exists());
}
#[test]
fn skill_install_accepts_bare_relative_dest() {
let home = mk_home();
let out = bin(&home)
.current_dir(home.path())
.args([
"skill",
"install",
"octl-run-overview",
"--dest",
"SKILL.md",
])
.output()
.expect("spawn");
assert!(out.status.success(), "bare-dest install failed: {out:?}");
assert!(home.path().join("SKILL.md").exists());
}
#[test]
fn skill_show_unknown_emits_skill_not_found() {
let home = mk_home();
let out = bin(&home)
.args(["skill", "show", "no-such-skill"])
.output()
.expect("spawn");
assert_eq!(out.status.code(), Some(1));
let err: Value = serde_json::from_slice(&out.stderr).expect("json err envelope");
assert_eq!(err["error"]["code"], "skill_not_found");
assert_eq!(err["error"]["invalid_value"], "no-such-skill");
}
#[test]
fn skill_print_default_streams_skill_md_byte_identically() {
let home = mk_home();
let print_out = bin(&home)
.args(["skill", "print", "orchestratectl-overview"])
.output()
.expect("spawn");
assert!(print_out.status.success(), "exit: {:?}", print_out.status);
let dest = home.path().join("printed.md");
let install_out = bin(&home)
.args(["skill", "install", "orchestratectl-overview", "--dest"])
.arg(&dest)
.output()
.expect("spawn");
assert!(install_out.status.success());
let on_disk = std::fs::read(&dest).expect("read installed");
assert_eq!(
print_out.stdout, on_disk,
"skill print stdout must equal skill install on-disk bytes"
);
}
#[test]
fn skill_print_json_payload_pins_schema() {
let home = mk_home();
let out = bin(&home)
.args([
"skill",
"print",
"orchestratectl-overview",
"--output",
"json",
])
.output()
.expect("spawn");
assert!(out.status.success(), "exit: {:?}", out.status);
let v: Value = serde_json::from_slice(&out.stdout).expect("json");
assert_eq!(v["schema_version"], 1);
let data = &v["data"];
assert_eq!(data["name"], "orchestratectl-overview");
assert_eq!(data["schema_version"], 1);
assert_eq!(data["schema_version_skill"], 1);
assert_eq!(
data["cli_version"].as_str().unwrap(),
env!("CARGO_PKG_VERSION")
);
assert!(data["content"]
.as_str()
.unwrap()
.starts_with("---\nname: orchestratectl-overview"));
assert!(data["path_in_repo"]
.as_str()
.unwrap()
.contains("SKILL.template.md"));
}
#[test]
fn skill_print_unknown_emits_skill_not_found() {
let home = mk_home();
let out = bin(&home)
.args(["skill", "print", "no-such-skill"])
.output()
.expect("spawn");
assert_eq!(out.status.code(), Some(1));
let err: Value = serde_json::from_slice(&out.stderr).expect("err envelope");
assert_eq!(err["error"]["code"], "skill_not_found");
}
#[test]
fn skill_install_over_older_version_warns_and_succeeds_without_force() {
let home = mk_home();
let dest = home.path().join("SKILL.md");
std::fs::write(
&dest,
"---\nname: orchestratectl-overview\ndescription: old\ncli_version: \"0.0.0\"\nschema_version: 1\n---\n",
)
.unwrap();
let out = bin(&home)
.args(["skill", "install", "orchestratectl-overview", "--dest"])
.arg(&dest)
.args(["--output", "json"])
.output()
.expect("spawn");
assert!(
out.status.success(),
"install over older must succeed: {out:?}"
);
let v: Value = serde_json::from_slice(&out.stdout).expect("json");
let warnings = v["warnings"].as_array().expect("warnings array");
assert!(
warnings
.iter()
.any(|w| w.as_str().unwrap().contains("skill_version_drift")
&& w.as_str().unwrap().contains("0.0.0")),
"expected skill_version_drift warning naming 0.0.0; got {warnings:?}"
);
let after = std::fs::read_to_string(&dest).unwrap();
assert!(after.contains(&format!("cli_version: \"{}\"", env!("CARGO_PKG_VERSION"))));
}
#[test]
fn skill_install_over_newer_version_refuses_with_skill_version_too_new() {
let home = mk_home();
let dest = home.path().join("SKILL.md");
std::fs::write(
&dest,
"---\nname: orchestratectl-overview\ndescription: future\ncli_version: \"99.0.0\"\nschema_version: 1\n---\n",
)
.unwrap();
let out = bin(&home)
.args(["skill", "install", "orchestratectl-overview", "--dest"])
.arg(&dest)
.output()
.expect("spawn");
assert_eq!(out.status.code(), Some(2), "expected exit 2");
let err: Value = serde_json::from_slice(&out.stderr).expect("err envelope");
assert_eq!(err["error"]["code"], "skill_version_too_new");
let out = bin(&home)
.args(["skill", "install", "orchestratectl-overview", "--dest"])
.arg(&dest)
.arg("--force")
.output()
.expect("spawn");
assert!(out.status.success(), "--force install must succeed");
}
const MARKER: &str = ".orchestratectl-managed";
fn write_marker(dir: &std::path::Path, skill_name: &str) {
std::fs::write(
dir.join(MARKER),
format!("managed-by: orchestratectl\ncli_version: 9.9.9\nskill_name: {skill_name}\n"),
)
.unwrap();
}
#[test]
fn skill_install_default_stamps_provenance_marker() {
let home = mk_home();
assert!(bin(&home)
.args(["skill", "install", "octl-run-overview"])
.output()
.expect("spawn")
.status
.success());
let marker = home
.path()
.join(".claude/skills/octl-run-overview")
.join(MARKER);
assert!(
marker.is_file(),
"provenance marker not written next to SKILL.md"
);
let body = std::fs::read_to_string(&marker).unwrap();
assert!(
body.contains("managed-by: orchestratectl"),
"marker: {body}"
);
assert!(
body.contains("skill_name: octl-run-overview"),
"marker: {body}"
);
}
#[test]
fn skill_install_all_prunes_managed_orphan() {
let home = mk_home();
let orphan = home.path().join(".claude/skills/gone-skill");
std::fs::create_dir_all(&orphan).unwrap();
std::fs::write(orphan.join("SKILL.md"), "---\nname: gone-skill\n---\n").unwrap();
write_marker(&orphan, "gone-skill");
let out = bin(&home)
.args(["skill", "install", "--force", "--output", "json"])
.output()
.expect("spawn");
assert!(out.status.success(), "install-all failed: {out:?}");
assert!(!orphan.exists(), "managed orphan was not pruned");
let v: Value = serde_json::from_slice(&out.stdout).expect("json");
let pruned: Vec<&str> = v["data"]["pruned"]
.as_array()
.expect("pruned array")
.iter()
.map(|p| p.as_str().unwrap())
.collect();
assert!(pruned.contains(&"gone-skill"), "pruned list: {pruned:?}");
let warnings = v["warnings"].as_array().expect("warnings");
assert!(
warnings
.iter()
.any(|w| w.as_str().unwrap().contains("skill_pruned")
&& w.as_str().unwrap().contains("gone-skill")),
"expected skill_pruned warning; got {warnings:?}"
);
}
#[test]
fn skill_install_all_spares_unmanaged_same_name_dir() {
let home = mk_home();
let user_skill = home.path().join(".claude/skills/my-own-skill");
std::fs::create_dir_all(&user_skill).unwrap();
std::fs::write(
user_skill.join("SKILL.md"),
"---\nname: my-own-skill\n---\nmine\n",
)
.unwrap();
let out = bin(&home)
.args(["skill", "install", "--force", "--output", "json"])
.output()
.expect("spawn");
assert!(out.status.success(), "install-all failed: {out:?}");
assert!(
user_skill.join("SKILL.md").exists(),
"unmanaged user skill was deleted — provenance guard failed"
);
let v: Value = serde_json::from_slice(&out.stdout).expect("json");
let pruned = v["data"]["pruned"].as_array().expect("pruned array");
assert!(
pruned.is_empty(),
"unmanaged dir must not appear in pruned: {pruned:?}"
);
}
#[test]
fn skill_install_all_keeps_registered_skills() {
let home = mk_home();
assert!(bin(&home)
.args(["skill", "install", "--force"])
.output()
.expect("spawn")
.status
.success());
let registered = home.path().join(".claude/skills/octl-run-overview");
assert!(registered.join("SKILL.md").exists());
assert!(registered.join(MARKER).is_file());
let out = bin(&home)
.args(["skill", "install", "--force", "--output", "json"])
.output()
.expect("spawn");
assert!(out.status.success(), "second install failed: {out:?}");
assert!(
registered.join("SKILL.md").exists(),
"still-registered skill was pruned"
);
let v: Value = serde_json::from_slice(&out.stdout).expect("json");
assert!(v["data"]["pruned"].as_array().expect("pruned").is_empty());
}
#[test]
fn skill_install_named_does_not_prune() {
let home = mk_home();
let orphan = home.path().join(".claude/skills/gone-skill");
std::fs::create_dir_all(&orphan).unwrap();
std::fs::write(orphan.join("SKILL.md"), "---\nname: gone-skill\n---\n").unwrap();
write_marker(&orphan, "gone-skill");
let out = bin(&home)
.args([
"skill",
"install",
"octl-run-overview",
"--force",
"--output",
"json",
])
.output()
.expect("spawn");
assert!(out.status.success(), "named install failed: {out:?}");
assert!(
orphan.exists(),
"targeted install pruned an orphan — must be scoped to install-all"
);
let v: Value = serde_json::from_slice(&out.stdout).expect("json");
assert!(v["data"]["pruned"].as_array().expect("pruned").is_empty());
}
#[test]
fn skill_install_all_without_force_does_not_prune() {
let home = mk_home();
let orphan = home.path().join(".claude/skills/gone-skill");
std::fs::create_dir_all(&orphan).unwrap();
std::fs::write(orphan.join("SKILL.md"), "---\nname: gone-skill\n---\n").unwrap();
write_marker(&orphan, "gone-skill");
let out = bin(&home)
.args(["skill", "install", "--output", "json"])
.output()
.expect("spawn");
assert!(out.status.success(), "install-all failed: {out:?}");
assert!(orphan.exists(), "prune ran without --force");
let v: Value = serde_json::from_slice(&out.stdout).expect("json");
assert!(v["data"]["pruned"].as_array().expect("pruned").is_empty());
}
#[test]
fn skill_install_all_spares_copied_and_renamed_managed_skill() {
let home = mk_home();
let copy = home.path().join(".claude/skills/my-worktree");
std::fs::create_dir_all(©).unwrap();
std::fs::write(copy.join("SKILL.md"), "---\nname: my-worktree\n---\nmine\n").unwrap();
write_marker(©, "worktree");
let out = bin(&home)
.args(["skill", "install", "--force", "--output", "json"])
.output()
.expect("spawn");
assert!(out.status.success(), "install-all failed: {out:?}");
assert!(
copy.join("SKILL.md").exists(),
"a copied-and-renamed managed skill was deleted — name-binding guard failed"
);
let v: Value = serde_json::from_slice(&out.stdout).expect("json");
assert!(v["data"]["pruned"].as_array().expect("pruned").is_empty());
}
#[cfg(unix)]
#[test]
fn skill_install_all_does_not_follow_symlinked_orphan() {
let home = mk_home();
let outside = home.path().join("precious");
std::fs::create_dir_all(&outside).unwrap();
std::fs::write(outside.join("SKILL.md"), "important user data\n").unwrap();
write_marker(&outside, "evil");
let skills = home.path().join(".claude/skills");
std::fs::create_dir_all(&skills).unwrap();
std::os::unix::fs::symlink(&outside, skills.join("evil")).unwrap();
let out = bin(&home)
.args(["skill", "install", "--force", "--output", "json"])
.output()
.expect("spawn");
assert!(out.status.success(), "install-all failed: {out:?}");
assert!(
outside.join("SKILL.md").exists(),
"remove_dir_all followed a symlink and deleted an outside directory"
);
let v: Value = serde_json::from_slice(&out.stdout).expect("json");
assert!(v["data"]["pruned"].as_array().expect("pruned").is_empty());
}
#[test]
fn skill_install_stint_start_writes_companion_resource_for_claude() {
let home = mk_home();
let out = bin(&home)
.args(["skill", "install", "stint-start", "--output", "json"])
.output()
.expect("spawn");
assert!(out.status.success(), "install failed: {out:?}");
let skill_md = home.path().join(".claude/skills/stint-start/SKILL.md");
let companion = home
.path()
.join(".claude/skills/stint-start/AGENTS-EXECUTION-DAG.md");
assert!(skill_md.exists(), "SKILL.md not installed");
assert!(
companion.exists(),
"companion AGENTS-EXECUTION-DAG.md not installed alongside SKILL.md"
);
let v: Value = serde_json::from_slice(&out.stdout).expect("json");
let installed = v["data"]["installed"].as_array().expect("installed array");
let paths: Vec<&str> = installed
.iter()
.map(|f| f["path"].as_str().unwrap())
.collect();
assert!(
paths.iter().any(|p| p.ends_with("AGENTS-EXECUTION-DAG.md")),
"companion not reported in install payload: {paths:?}"
);
}
#[test]
fn skill_install_stint_start_codex_skips_companion() {
let home = mk_home();
let out = bin(&home)
.args([
"skill",
"install",
"stint-start",
"--agent",
"codex",
"--output",
"json",
])
.output()
.expect("spawn");
assert!(out.status.success(), "codex install failed: {out:?}");
assert!(
home.path().join(".codex/prompts/stint-start.md").exists(),
"flat codex prompt not installed"
);
assert!(
!home
.path()
.join(".codex/prompts/AGENTS-EXECUTION-DAG.md")
.exists(),
"companion must not leak into the flat codex prompts dir"
);
}
#[test]
fn skill_install_over_older_companion_upgrades_without_force() {
let home = mk_home();
assert!(bin(&home)
.args(["skill", "install", "stint-start"])
.output()
.expect("spawn")
.status
.success());
let skill_md = home.path().join(".claude/skills/stint-start/SKILL.md");
let companion = home
.path()
.join(".claude/skills/stint-start/AGENTS-EXECUTION-DAG.md");
std::fs::write(
&skill_md,
"---\nname: stint-start\ndescription: old\ncli_version: \"0.0.0\"\nschema_version: 1\n---\n",
)
.unwrap();
std::fs::write(
&companion,
"---\ncli_version: \"0.0.0\"\nschema_version: 1\n---\nstale\n",
)
.unwrap();
let out = bin(&home)
.args(["skill", "install", "stint-start"])
.output()
.expect("spawn");
assert!(
out.status.success(),
"redeploy over an older companion must not require --force: {out:?}"
);
let after = std::fs::read_to_string(&companion).unwrap();
assert!(
after.contains(&format!("cli_version: \"{}\"", env!("CARGO_PKG_VERSION"))),
"companion was not upgraded to the binary version"
);
}