use std::fs;
use std::path::{Path, PathBuf};
use assert_cmd::Command;
use predicates::prelude::*;
use skillpack::exit;
fn repo_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).to_path_buf()
}
fn copy_fixture(name: &str) -> PathBuf {
let src = repo_root().join("tests/fixtures/repos").join(name);
let dest = tempfile::tempdir().unwrap().keep();
copy_dir(&src, &dest);
dest
}
fn copy_dir(src: &Path, dest: &Path) {
fs::create_dir_all(dest).unwrap();
for entry in fs::read_dir(src).unwrap() {
let entry = entry.unwrap();
let from = entry.path();
let to = dest.join(entry.file_name());
if from.is_dir() {
copy_dir(&from, &to);
} else {
fs::copy(&from, &to).unwrap();
}
}
}
fn write_skillpack_toml(root: &Path, name: &str) {
let toml = format!(
"[skill]\n\
name = \"{name}\"\n\
one_line_description = \"Print a journal entry to stdout\"\n\
when_to_use_phrases = [\"log a journal entry\", \"record a quick note\"]\n\
invocation_command = \"{name} --new \\\"entry\\\"\"\n\
license = \"MIT\"\n"
);
fs::write(root.join("skillpack.toml"), toml).unwrap();
}
fn replace_first_line_starting_with(text: &str, prefix: &str, new_line: &str) -> String {
let mut out = String::with_capacity(text.len());
for line in text.split_inclusive('\n') {
if line.trim_end_matches('\n').starts_with(prefix) {
out.push_str(new_line);
out.push('\n');
} else {
out.push_str(line);
}
}
out
}
#[test]
fn rust_cli_init_then_verify_round_trip() {
let root = copy_fixture("rust-cli");
Command::new("cargo")
.args(["build", "--quiet"])
.current_dir(&root)
.assert()
.success();
write_skillpack_toml(&root, "sample-rust");
Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--root",
".",
"--non-interactive",
"--accept-warnings",
])
.current_dir(&root)
.assert()
.success();
assert!(root.join(".claude-plugin/marketplace.json").exists());
assert!(root.join(".claude-plugin/plugin.json").exists());
assert!(root.join("skills/sample-rust/SKILL.md").exists());
assert!(root.join("skillpack.toml").exists());
Command::cargo_bin("skillpack")
.unwrap()
.args(["verify", "--root", "."])
.current_dir(&root)
.assert()
.success()
.stdout(predicate::str::contains("verify OK").or(predicate::str::contains("0 failed")));
let mp = fs::read_to_string(root.join(".claude-plugin/marketplace.json")).unwrap();
let v: serde_json::Value = serde_json::from_str(&mp).unwrap();
assert_eq!(v["plugins"][0]["source"], "./");
assert_eq!(v["plugins"][0]["name"], "sample-rust");
assert_eq!(v["name"], "sample-rust");
let pj = fs::read_to_string(root.join(".claude-plugin/plugin.json")).unwrap();
let v: serde_json::Value = serde_json::from_str(&pj).unwrap();
assert_eq!(v["name"], "sample-rust");
assert_eq!(v["license"], "MIT");
}
#[test]
fn pure_library_init_skips_invocation_and_writes_import_pattern() {
let root = copy_fixture("node-lib");
let toml = "[skill]\n\
name = \"sample-lib\"\n\
one_line_description = \"Parse CSV files with a small library\"\n\
when_to_use_phrases = [\"ingest csv\", \"convert rows\"]\n\
import_pattern = \"import { parse } from 'sample-lib'\"\n\
license = \"MIT\"\n";
fs::write(root.join("skillpack.toml"), toml).unwrap();
Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--root",
".",
"--non-interactive",
"--accept-warnings",
])
.current_dir(&root)
.assert()
.success();
let skill = fs::read_to_string(root.join("skills/sample-lib/SKILL.md")).unwrap();
assert!(skill.contains("import { parse } from 'sample-lib'"));
assert!(!skill.contains("## Invocation"));
assert!(skill.contains("## Usage"));
Command::cargo_bin("skillpack")
.unwrap()
.args(["verify", "--root", "."])
.current_dir(&root)
.assert()
.success()
.stdout(predicate::str::contains("Skipped: pure-library project"));
}
#[test]
fn non_interactive_without_flags_refuses_to_write() {
let root = copy_fixture("rust-cli");
Command::new("cargo")
.args(["build", "--quiet"])
.current_dir(&root)
.assert()
.success();
Command::cargo_bin("skillpack")
.unwrap()
.args(["init", "--root", ".", "--non-interactive"])
.current_dir(&root)
.assert()
.code(exit::INIT_FATAL) .stderr(predicate::str::contains("--description"));
assert!(!root.join(".claude-plugin").exists());
}
#[test]
fn non_interactive_bootstrap_with_flags_generates_pack() {
let root = copy_fixture("rust-cli");
Command::new("cargo")
.args(["build", "--quiet"])
.current_dir(&root)
.assert()
.success();
Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--root",
".",
"--non-interactive",
"--accept-warnings",
"--license",
"MIT",
"--description",
"Print a journal entry to stdout",
"--trigger",
"log a journal entry, record a quick note",
"--author",
"CI Bot",
"--invocation",
"sample-rust --new \"entry\"",
])
.current_dir(&root)
.assert()
.success();
assert!(root.join(".claude-plugin/marketplace.json").exists());
assert!(root.join(".claude-plugin/plugin.json").exists());
assert!(root.join("skills/sample-rust/SKILL.md").exists());
let toml = fs::read_to_string(root.join("skillpack.toml")).unwrap();
assert!(toml.contains("Print a journal entry to stdout"));
assert!(toml.contains("record a quick note"));
assert!(toml.contains("CI Bot"));
Command::cargo_bin("skillpack")
.unwrap()
.args(["verify", "--root", "."])
.current_dir(&root)
.assert()
.success()
.stdout(predicate::str::contains("verify OK").or(predicate::str::contains("0 failed")));
}
#[test]
fn non_interactive_bootstrap_rejects_both_invocation_and_import() {
let root = copy_fixture("rust-cli");
Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--root",
".",
"--non-interactive",
"--description",
"d",
"--trigger",
"x",
"--invocation",
"cmd",
"--import",
"pat",
])
.current_dir(&root)
.assert()
.code(exit::INIT_FATAL)
.stderr(predicate::str::contains("only one of --invocation"));
assert!(!root.join(".claude-plugin").exists());
}
#[test]
fn non_interactive_bootstrap_pure_library_uses_import() {
let root = copy_fixture("node-lib");
Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--root",
".",
"--non-interactive",
"--accept-warnings",
"--description",
"Parse CSV files with a small library",
"--trigger",
"ingest csv",
"--import",
"import { parse } from 'sample-lib'",
])
.current_dir(&root)
.assert()
.success();
let skill = fs::read_to_string(root.join("skills/sample-lib/SKILL.md")).unwrap();
assert!(skill.contains("import { parse } from 'sample-lib'"));
assert!(!skill.contains("## Invocation"));
assert!(skill.contains("## Usage"));
}
#[test]
fn init_auto_derives_intent_from_repo() {
let root = copy_fixture("rust-cli"); Command::new("cargo")
.args(["build", "--quiet"])
.current_dir(&root)
.assert()
.success();
Command::cargo_bin("skillpack")
.unwrap()
.args(["init", "--root", ".", "--auto", "--accept-warnings"])
.current_dir(&root)
.assert()
.success();
assert!(root.join(".claude-plugin/plugin.json").exists());
assert!(root.join("skills/sample-rust/SKILL.md").exists());
let skill = fs::read_to_string(root.join("skills/sample-rust/SKILL.md")).unwrap();
assert!(
skill.contains("## Invocation"),
"auto-derived CLI project must emit an Invocation section, got:\n{skill}"
);
Command::cargo_bin("skillpack")
.unwrap()
.args(["verify", "--root", "."])
.current_dir(&root)
.assert()
.success()
.stdout(predicate::str::contains("verify OK").or(predicate::str::contains("0 failed")));
}
#[test]
fn init_auto_library_requires_import_pattern() {
let root = copy_fixture("node-lib");
Command::cargo_bin("skillpack")
.unwrap()
.args(["init", "--root", ".", "--auto"])
.current_dir(&root)
.assert()
.code(exit::INIT_FATAL)
.stderr(predicate::str::contains("--import"));
assert!(!root.join(".claude-plugin").exists());
}
#[test]
fn new_harness_targets_render_and_verify() {
let root = copy_fixture("rust-cli");
Command::new("cargo")
.args(["build", "--quiet"])
.current_dir(&root)
.assert()
.success();
write_skillpack_toml(&root, "sample-rust");
Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--root",
".",
"--non-interactive",
"--accept-warnings",
"--target",
"all",
])
.current_dir(&root)
.assert()
.success();
for rel in [
"CLAUDE.md",
"GEMINI.md",
"CONVENTIONS.md",
".windsurf/rules/sample-rust.md",
] {
assert!(root.join(rel).exists(), "missing {rel}");
}
let rule = fs::read_to_string(root.join(".windsurf/rules/sample-rust.md")).unwrap();
assert!(rule.starts_with("---\ndescription:"), "got:\n{rule}");
assert!(rule.contains("alwaysApply: false"));
let claude_md = fs::read_to_string(root.join("CLAUDE.md")).unwrap();
assert!(claude_md.starts_with("# sample-rust"));
Command::cargo_bin("skillpack")
.unwrap()
.args(["verify", "--root", "."])
.current_dir(&root)
.assert()
.success()
.stdout(predicate::str::contains("0 failed"));
}
#[test]
fn plain_md_targets_validate_and_reject_frontmatter() {
let root = copy_fixture("rust-cli");
Command::new("cargo")
.args(["build", "--quiet"])
.current_dir(&root)
.assert()
.success();
write_skillpack_toml(&root, "sample-rust");
Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--root",
".",
"--non-interactive",
"--accept-warnings",
"--target",
"all",
])
.current_dir(&root)
.assert()
.success();
Command::cargo_bin("skillpack")
.unwrap()
.args(["verify", "--root", "."])
.current_dir(&root)
.assert()
.success();
fs::write(
root.join("GEMINI.md"),
"---\ndescription: x\n---\n\n# gemini\n",
)
.unwrap();
Command::cargo_bin("skillpack")
.unwrap()
.args(["verify", "--root", "."])
.current_dir(&root)
.assert()
.failure()
.stdout(predicate::str::contains("plain markdown (no frontmatter)"));
}
#[test]
fn broken_cli_verify_flags_drift() {
let root = copy_fixture("broken-cli");
Command::new("cargo")
.args(["build", "--quiet"])
.current_dir(&root)
.assert()
.success();
let out = Command::cargo_bin("skillpack")
.unwrap()
.args(["verify", "--root", "."])
.current_dir(&root)
.assert()
.failure()
.get_output()
.stdout
.clone();
let s = String::from_utf8_lossy(&out);
assert!(
s.contains("flag_drift") || s.contains("missing from `--help`"),
"expected a flag-drift failure, got:\n{s}"
);
}
#[test]
fn init_with_empty_when_to_use_warns_on_verify() {
let root = copy_fixture("rust-cli");
Command::new("cargo")
.args(["build", "--quiet"])
.current_dir(&root)
.assert()
.success();
let toml = "[skill]\n\
name = \"sample-rust\"\n\
one_line_description = \"Print a journal entry to stdout\"\n\
when_to_use_phrases = []\n\
invocation_command = \"sample-rust --new \\\"entry\\\"\"\n\
license = \"MIT\"\n";
fs::write(root.join("skillpack.toml"), toml).unwrap();
Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--root",
".",
"--non-interactive",
"--accept-warnings",
])
.current_dir(&root)
.assert()
.success();
let skill = fs::read_to_string(root.join("skills/sample-rust/SKILL.md")).unwrap();
assert!(skill.contains("when_to_use: \"\""));
assert!(!skill.contains("(unspecified)"));
let out = Command::cargo_bin("skillpack")
.unwrap()
.args(["verify", "--root", "."])
.current_dir(&root)
.assert()
.success()
.get_output()
.stdout
.clone();
let s = String::from_utf8_lossy(&out);
assert!(
s.contains("when_to_use") && s.contains("warn"),
"expected a when_to_use warning, got:\n{s}"
);
}
#[test]
fn verify_format_json_is_machine_readable() {
let root = copy_fixture("rust-cli");
Command::new("cargo")
.args(["build", "--quiet"])
.current_dir(&root)
.assert()
.success();
write_skillpack_toml(&root, "sample-rust");
Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--root",
".",
"--non-interactive",
"--accept-warnings",
])
.current_dir(&root)
.assert()
.success();
let out = Command::cargo_bin("skillpack")
.unwrap()
.args(["verify", "--root", ".", "--format", "json"])
.current_dir(&root)
.assert()
.success()
.get_output()
.stdout
.clone();
let v: serde_json::Value = serde_json::from_str(&String::from_utf8_lossy(&out)).unwrap();
assert_eq!(v["ok"], serde_json::Value::Bool(true));
let results = v["results"].as_array().unwrap();
assert!(results.iter().all(|r| r["check_id"].is_string()));
assert!(results
.iter()
.any(|r| r["check_id"].as_str().unwrap().starts_with("invocation.")));
assert!(v["discoverability_score"].is_number());
}
#[test]
fn verify_warns_on_plugin_json_version_drift() {
let root = copy_fixture("rust-cli");
Command::new("cargo")
.args(["build", "--quiet"])
.current_dir(&root)
.assert()
.success();
write_skillpack_toml(&root, "sample-rust");
Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--root",
".",
"--non-interactive",
"--accept-warnings",
])
.current_dir(&root)
.assert()
.success();
let out = Command::cargo_bin("skillpack")
.unwrap()
.args(["verify", "--root", ".", "--format", "json"])
.current_dir(&root)
.output()
.unwrap();
let v: serde_json::Value = serde_json::from_str(&String::from_utf8_lossy(&out.stdout)).unwrap();
assert!(
!v["results"]
.as_array()
.unwrap()
.iter()
.any(|r| r["check_id"].as_str().unwrap() == "discovery.plugin.version_drift"),
"freshly-init'd pack must not report version drift, got:\n{}",
String::from_utf8_lossy(&out.stdout)
);
let pj = root.join(".claude-plugin/plugin.json");
let mut pjv: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&pj).unwrap()).unwrap();
pjv["version"] = serde_json::Value::String("9.9.9-fake".into());
fs::write(&pj, serde_json::to_string_pretty(&pjv).unwrap()).unwrap();
let out = Command::cargo_bin("skillpack")
.unwrap()
.args(["verify", "--root", ".", "--format", "json"])
.current_dir(&root)
.output()
.unwrap();
assert!(
out.status.success(),
"warnings must not fail verify; got status {} and stderr:\n{}",
out.status,
String::from_utf8_lossy(&out.stderr)
);
let v: serde_json::Value = serde_json::from_str(&String::from_utf8_lossy(&out.stdout)).unwrap();
let drift = v["results"]
.as_array()
.unwrap()
.iter()
.find(|r| r["check_id"].as_str().unwrap() == "discovery.plugin.version_drift")
.unwrap_or_else(|| {
panic!(
"version_drift warning missing in:\n{}",
String::from_utf8_lossy(&out.stdout)
)
});
assert_eq!(drift["severity"].as_str().unwrap(), "warn");
let msg = drift["message"].as_str().unwrap();
assert!(
msg.contains("9.9.9-fake"),
"message must name plugin version: {msg}"
);
assert!(
msg.contains("0.1.0"),
"message must name manifest version: {msg}"
);
}
#[test]
fn verify_warns_on_plugin_json_url_drift() {
let root = copy_fixture("rust-cli");
Command::new("cargo")
.args(["build", "--quiet"])
.current_dir(&root)
.assert()
.success();
Command::new("git")
.args(["init", "--quiet"])
.current_dir(&root)
.assert()
.success();
Command::new("git")
.args([
"remote",
"add",
"origin",
"https://github.com/nordicnode/sample-rust.git",
])
.current_dir(&root)
.assert()
.success();
write_skillpack_toml(&root, "sample-rust");
Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--root",
".",
"--non-interactive",
"--accept-warnings",
])
.current_dir(&root)
.assert()
.success();
let out = Command::cargo_bin("skillpack")
.unwrap()
.args(["verify", "--root", ".", "--format", "json"])
.current_dir(&root)
.output()
.unwrap();
let v: serde_json::Value = serde_json::from_str(&String::from_utf8_lossy(&out.stdout)).unwrap();
assert!(
!v["results"]
.as_array()
.unwrap()
.iter()
.any(|r| r["check_id"].as_str().unwrap() == "discovery.plugin.url_drift"),
"freshly-init'd pack with matching homepage/repository must not report url_drift, got:\n{}",
String::from_utf8_lossy(&out.stdout)
);
let pj = root.join(".claude-plugin/plugin.json");
let mut pjv: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&pj).unwrap()).unwrap();
pjv["homepage"] = serde_json::Value::String("https://example.com/STALE-url".into());
fs::write(&pj, serde_json::to_string_pretty(&pjv).unwrap()).unwrap();
let out = Command::cargo_bin("skillpack")
.unwrap()
.args(["verify", "--root", ".", "--format", "json"])
.current_dir(&root)
.output()
.unwrap();
assert!(
out.status.success(),
"url_drift warning must not fail verify; got {} and stderr:\n{}",
out.status,
String::from_utf8_lossy(&out.stderr)
);
let v: serde_json::Value = serde_json::from_str(&String::from_utf8_lossy(&out.stdout)).unwrap();
let drift = v["results"]
.as_array()
.unwrap()
.iter()
.find(|r| r["check_id"].as_str().unwrap() == "discovery.plugin.url_drift")
.unwrap_or_else(|| {
panic!(
"url_drift warning missing in:\n{}",
String::from_utf8_lossy(&out.stdout)
)
});
assert_eq!(drift["severity"].as_str().unwrap(), "warn");
let msg = drift["message"].as_str().unwrap();
assert!(
msg.contains("homepage"),
"message must name the drifted field (homepage): {msg}"
);
assert!(
msg.contains("STALE-url"),
"message must name the stale URL value: {msg}"
);
assert!(
msg.contains("github.com/nordicnode/sample-rust.git"),
"message must name the canonical git origin URL: {msg}"
);
assert!(
!msg.contains("repository"),
"only homepage drifted; message must not mention repository: {msg}"
);
}
#[test]
fn verify_skips_url_drift_when_no_git_origin() {
let root = copy_fixture("rust-cli");
Command::new("cargo")
.args(["build", "--quiet"])
.current_dir(&root)
.assert()
.success();
write_skillpack_toml(&root, "sample-rust");
Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--root",
".",
"--non-interactive",
"--accept-warnings",
])
.current_dir(&root)
.assert()
.success();
let out = Command::cargo_bin("skillpack")
.unwrap()
.args(["verify", "--root", ".", "--format", "json"])
.current_dir(&root)
.output()
.unwrap();
assert!(
out.status.success(),
"no-git-origin repo must still pass verify; got {} and stderr:\n{}",
out.status,
String::from_utf8_lossy(&out.stderr)
);
let v: serde_json::Value = serde_json::from_str(&String::from_utf8_lossy(&out.stdout)).unwrap();
assert!(
!v["results"]
.as_array()
.unwrap()
.iter()
.any(|r| r["check_id"].as_str().unwrap() == "discovery.plugin.url_drift"),
"url_drift must not fire on a repo with no git origin (no canonical URL), got:\n{}",
String::from_utf8_lossy(&out.stdout)
);
}
#[test]
fn verify_warns_on_skill_md_name_drift() {
let root = copy_fixture("rust-cli");
Command::new("cargo")
.args(["build", "--quiet"])
.current_dir(&root)
.assert()
.success();
write_skillpack_toml(&root, "sample-rust");
Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--root",
".",
"--non-interactive",
"--accept-warnings",
])
.current_dir(&root)
.assert()
.success();
let out = Command::cargo_bin("skillpack")
.unwrap()
.args(["verify", "--root", ".", "--format", "json"])
.current_dir(&root)
.output()
.unwrap();
let v: serde_json::Value = serde_json::from_str(&String::from_utf8_lossy(&out.stdout)).unwrap();
assert!(
!v["results"]
.as_array()
.unwrap()
.iter()
.any(|r| r["check_id"].as_str().unwrap() == "discovery.skill.name_drift"),
"freshly-init'd pack must not report name_drift, got:\n{}",
String::from_utf8_lossy(&out.stdout)
);
let skill = root.join("skills/sample-rust/SKILL.md");
let raw = fs::read_to_string(&skill).unwrap();
let mutated = raw.replacen("name: sample-rust", "name: STALE-name", 1);
assert_ne!(
mutated, raw,
"fixture must have a `name: sample-rust` to mutate"
);
fs::write(&skill, mutated).unwrap();
let out = Command::cargo_bin("skillpack")
.unwrap()
.args(["verify", "--root", ".", "--format", "json"])
.current_dir(&root)
.output()
.unwrap();
assert!(
out.status.success(),
"name_drift warning must not fail verify; got {} and stderr:\n{}",
out.status,
String::from_utf8_lossy(&out.stderr)
);
let v: serde_json::Value = serde_json::from_str(&String::from_utf8_lossy(&out.stdout)).unwrap();
let drift = v["results"]
.as_array()
.unwrap()
.iter()
.find(|r| r["check_id"].as_str().unwrap() == "discovery.skill.name_drift")
.unwrap_or_else(|| {
panic!(
"name_drift warning missing in:\n{}",
String::from_utf8_lossy(&out.stdout)
)
});
assert_eq!(drift["severity"].as_str().unwrap(), "warn");
let msg = drift["message"].as_str().unwrap();
assert!(
msg.contains("STALE-name"),
"message must name the drifted frontmatter value: {msg}"
);
assert!(
msg.contains("sample-rust"),
"message must name the canonical project name: {msg}"
);
}
#[test]
fn verify_fix_repairs_name_drift_preserving_body() {
let root = copy_fixture("rust-cli");
Command::new("cargo")
.args(["build", "--quiet"])
.current_dir(&root)
.assert()
.success();
write_skillpack_toml(&root, "sample-rust");
Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--root",
".",
"--non-interactive",
"--accept-warnings",
])
.current_dir(&root)
.assert()
.success();
let skill = root.join("skills/sample-rust/SKILL.md");
let raw = fs::read_to_string(&skill).unwrap();
let body_sentinel = "BODY_SENTINEL_KEEP_ME_UNIQUE_PROSE";
let with_sentinel = format!("{raw}\n{body_sentinel}\n");
let mutated_name = with_sentinel.replacen("name: sample-rust", "name: STALE-name", 1);
assert_ne!(&mutated_name, &with_sentinel);
fs::write(&skill, mutated_name).unwrap();
let out = Command::cargo_bin("skillpack")
.unwrap()
.args(["verify", "--root", ".", "--format", "json"])
.current_dir(&root)
.output()
.unwrap();
let v: serde_json::Value = serde_json::from_str(&String::from_utf8_lossy(&out.stdout)).unwrap();
assert!(
v["results"]
.as_array()
.unwrap()
.iter()
.any(|r| { r["check_id"].as_str().unwrap() == "discovery.skill.name_drift" }),
"pre-fix verify must surface name_drift, got:\n{}",
String::from_utf8_lossy(&out.stdout)
);
let out = Command::cargo_bin("skillpack")
.unwrap()
.args(["verify", "--root", ".", "--fix", "--format", "json"])
.current_dir(&root)
.output()
.unwrap();
assert!(
out.status.success(),
"post-fix verify must exit 0; got {} and stderr:\n{}",
out.status,
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
let v: serde_json::Value = serde_json::from_str(&stdout).unwrap();
let has_drift = v["results"]
.as_array()
.unwrap()
.iter()
.any(|r| r["check_id"].as_str().unwrap() == "discovery.skill.name_drift");
assert!(
!has_drift,
"post-fix report must not still emit name_drift, got:\n{stdout}"
);
let after = fs::read_to_string(&skill).unwrap();
assert!(
after.contains("name: sample-rust"),
"post-fix frontmatter must show the canonical name; got:\n{after}"
);
assert!(
!after.contains("STALE-name"),
"post-fix must not retain the stale name; got:\n{after}"
);
assert!(
after.contains(body_sentinel),
"post-fix must preserve the body sentinel byte-for-byte; got:\n{after}"
);
}
#[test]
fn name_drift_warn_does_not_shadow_description_fail() {
let root = copy_fixture("rust-cli");
Command::new("cargo")
.args(["build", "--quiet"])
.current_dir(&root)
.assert()
.success();
write_skillpack_toml(&root, "sample-rust");
Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--root",
".",
"--non-interactive",
"--accept-warnings",
])
.current_dir(&root)
.assert()
.success();
let skill = root.join("skills/sample-rust/SKILL.md");
let raw = fs::read_to_string(&skill).unwrap();
let desc_line = raw
.lines()
.find(|l| l.starts_with("description:"))
.expect("fixture SKILL.md must have a `description:` frontmatter line");
let drifted = raw
.replacen("name: sample-rust", "name: STALE-name", 1)
.replacen(desc_line, "description: \"\"", 1);
assert_ne!(
drifted, raw,
"name + description mutation must change the file"
);
assert!(
drifted.contains("name: STALE-name"),
"name drift must be present before verify"
);
assert!(
drifted.contains("description: \"\""),
"blank description must be present before verify"
);
fs::write(&skill, drifted).unwrap();
let out = Command::cargo_bin("skillpack")
.unwrap()
.args(["verify", "--root", ".", "--format", "json"])
.current_dir(&root)
.output()
.unwrap();
assert_eq!(
out.status.code(),
Some(1),
"description fail must drive exit 1, not name_drift warn; got {:?} and stderr:\n{}",
out.status.code(),
String::from_utf8_lossy(&out.stderr)
);
let v: serde_json::Value = serde_json::from_str(&String::from_utf8_lossy(&out.stdout)).unwrap();
let has_desc_fail = v["results"].as_array().unwrap().iter().any(|r| {
r["check_id"].as_str().unwrap() == "discovery.skill.description"
&& r["severity"].as_str().unwrap() == "fail"
});
assert!(
has_desc_fail,
"report must surface the description FAIL, got:\n{}",
String::from_utf8_lossy(&out.stdout)
);
let has_name_drift = v["results"]
.as_array()
.unwrap()
.iter()
.any(|r| r["check_id"].as_str().unwrap() == "discovery.skill.name_drift");
let _ = has_name_drift;
}
#[test]
fn name_drift_warn_does_not_shadow_name_length_fail() {
let root = copy_fixture("rust-cli");
Command::new("cargo")
.args(["build", "--quiet"])
.current_dir(&root)
.assert()
.success();
write_skillpack_toml(&root, "sample-rust");
Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--root",
".",
"--non-interactive",
"--accept-warnings",
])
.current_dir(&root)
.assert()
.success();
let skill = root.join("skills/sample-rust/SKILL.md");
let raw = fs::read_to_string(&skill).unwrap();
let long_drifted = "01234567890123456789012345678901234567890123456789012345678901234";
assert_eq!(
long_drifted.chars().count(),
65,
"fixture name must be >64 chars to trigger name_length FAIL"
);
assert_ne!(
long_drifted, "sample-rust",
"name must differ from canonical to trigger name_drift WARN"
);
let mutated = raw.replacen("name: sample-rust", &format!("name: {long_drifted}"), 1);
assert_ne!(mutated, raw, "name-length mutation must change the file");
assert!(
mutated.contains(&format!("name: {long_drifted}")),
"long drifted name must be present before verify"
);
fs::write(&skill, mutated).unwrap();
let out = Command::cargo_bin("skillpack")
.unwrap()
.args(["verify", "--root", ".", "--format", "json"])
.current_dir(&root)
.output()
.unwrap();
assert_eq!(
out.status.code(),
Some(1),
"name_length fail must drive exit 1, not name_drift warn; got {:?} and stderr:\n{}",
out.status.code(),
String::from_utf8_lossy(&out.stderr)
);
let v: serde_json::Value = serde_json::from_str(&String::from_utf8_lossy(&out.stdout)).unwrap();
let has_name_length_fail = v["results"].as_array().unwrap().iter().any(|r| {
r["check_id"].as_str().unwrap() == "discovery.skill.name_length"
&& r["severity"].as_str().unwrap() == "fail"
});
assert!(
has_name_length_fail,
"report must surface the name_length FAIL, got:\n{}",
String::from_utf8_lossy(&out.stdout)
);
}
#[test]
fn bom_prefixed_skill_md_validates_clean() {
let root = copy_fixture("rust-cli");
Command::new("cargo")
.args(["build", "--quiet"])
.current_dir(&root)
.assert()
.success();
write_skillpack_toml(&root, "sample-rust");
Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--root",
".",
"--non-interactive",
"--accept-warnings",
])
.current_dir(&root)
.assert()
.success();
let skill = root.join("skills/sample-rust/SKILL.md");
let raw = fs::read_to_string(&skill).unwrap();
let bom = "\u{feff}";
assert!(!raw.starts_with(bom), "fixture must not already have a BOM");
fs::write(&skill, format!("{bom}{raw}")).unwrap();
let bytes = fs::read(&skill).unwrap();
assert_eq!(bytes[0], 0xEF, "BOM byte 0 must be EF");
assert_eq!(bytes[1], 0xBB, "BOM byte 1 must be BB");
assert_eq!(bytes[2], 0xBF, "BOM byte 2 must be BF");
let out = Command::cargo_bin("skillpack")
.unwrap()
.args(["verify", "--root", ".", "--format", "json"])
.current_dir(&root)
.output()
.unwrap();
assert_eq!(
out.status.code(),
Some(0),
"BOM-prefixed SKILL.md with valid frontmatter must NOT trigger a description FAIL; got {:?} and stderr:\n{}",
out.status.code(),
String::from_utf8_lossy(&out.stderr)
);
let v: serde_json::Value = serde_json::from_str(&String::from_utf8_lossy(&out.stdout)).unwrap();
let has_desc_fail = v["results"].as_array().unwrap().iter().any(|r| {
r["check_id"].as_str().unwrap() == "discovery.skill.description"
&& r["severity"].as_str().unwrap() == "fail"
});
assert!(
!has_desc_fail,
"BOM must not produce a false 'missing description' FAIL; report was:\n{}",
String::from_utf8_lossy(&out.stdout)
);
}
#[test]
fn verify_min_score_passes_when_threshold_met() {
let root = copy_fixture("rust-cli");
Command::new("cargo")
.args(["build", "--quiet"])
.current_dir(&root)
.assert()
.success();
write_skillpack_toml(&root, "sample-rust");
Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--root",
".",
"--non-interactive",
"--accept-warnings",
])
.current_dir(&root)
.assert()
.success();
let out = Command::cargo_bin("skillpack")
.unwrap()
.args([
"verify",
"--root",
".",
"--min-score",
"80",
"--format",
"json",
])
.current_dir(&root)
.output()
.unwrap();
assert!(
out.status.success(),
"score meeting threshold must exit 0; got {} and stderr:\n{}",
out.status,
String::from_utf8_lossy(&out.stderr)
);
let v: serde_json::Value = serde_json::from_str(&String::from_utf8_lossy(&out.stdout)).unwrap();
assert!(v["discoverability_score"].as_u64().unwrap() >= 80);
assert!(
!String::from_utf8_lossy(&out.stderr).contains("--min-score"),
"no gate message when threshold met; stderr:\n{}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn verify_min_score_fails_below_threshold() {
let root = copy_fixture("rust-cli");
Command::new("cargo")
.args(["build", "--quiet"])
.current_dir(&root)
.assert()
.success();
write_skillpack_toml(&root, "sample-rust");
Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--root",
".",
"--non-interactive",
"--accept-warnings",
])
.current_dir(&root)
.assert()
.success();
let out = Command::cargo_bin("skillpack")
.unwrap()
.args([
"verify",
"--root",
".",
"--min-score",
"100",
"--format",
"json",
])
.current_dir(&root)
.output()
.unwrap();
assert_eq!(
out.status.code(),
Some(2),
"score below --min-score must exit 2 (not 1); got {:?} and stderr:\n{}",
out.status.code(),
String::from_utf8_lossy(&out.stderr)
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("--min-score"),
"stderr must mention the --min-score flag; got:\n{stderr}"
);
assert!(
stderr.contains("100"),
"stderr must name the threshold (100); got:\n{stderr}"
);
let v: serde_json::Value = serde_json::from_str(&String::from_utf8_lossy(&out.stdout)).unwrap();
let score = v["discoverability_score"].as_u64().unwrap();
assert!(
(80..100).contains(&score),
"fixture baseline must sit below 100 and above 0; got {score}"
);
}
#[test]
fn verify_watch_starts_and_reports_initial_verify() {
use std::thread;
use std::time::Duration;
let root = copy_fixture("rust-cli");
Command::new("cargo")
.args(["build", "--quiet"])
.current_dir(&root)
.assert()
.success();
write_skillpack_toml(&root, "sample-rust");
Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--root",
".",
"--non-interactive",
"--accept-warnings",
])
.current_dir(&root)
.assert()
.success();
let mut child = std::process::Command::new(env!("CARGO_BIN_EXE_skillpack"))
.args(["verify", "--watch", "--root", "."])
.current_dir(&root)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("spawn verify --watch");
thread::sleep(Duration::from_secs(2));
child.kill().expect("kill watch process");
let output = child.wait_with_output().expect("wait");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("watching"),
"stderr must print the watcher banner; got:\n{stderr}"
);
assert!(
stderr.contains("verify") || !String::from_utf8_lossy(&output.stdout).is_empty(),
"must produce at least one verify report"
);
}
#[test]
fn verify_watch_rejects_json_format() {
let root = copy_fixture("rust-cli");
Command::new("cargo")
.args(["build", "--quiet"])
.current_dir(&root)
.assert()
.success();
write_skillpack_toml(&root, "sample-rust");
Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--root",
".",
"--non-interactive",
"--accept-warnings",
])
.current_dir(&root)
.assert()
.success();
let out = Command::cargo_bin("skillpack")
.unwrap()
.args(["verify", "--watch", "--format", "json", "--root", "."])
.current_dir(&root)
.output()
.unwrap();
assert_eq!(
out.status.code(),
Some(skillpack::exit::VERIFY_USAGE),
"--watch --format json must exit VERIFY_USAGE (4); got {:?}\nstderr: {}",
out.status.code(),
String::from_utf8_lossy(&out.stderr)
);
assert!(
String::from_utf8_lossy(&out.stderr).contains("--watch is only valid with --format human"),
"stderr must explain the constraint"
);
}
#[test]
fn verify_fix_repairs_version_drift_surgically() {
let root = copy_fixture("rust-cli");
Command::new("cargo")
.args(["build", "--quiet"])
.current_dir(&root)
.assert()
.success();
write_skillpack_toml(&root, "sample-rust");
Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--root",
".",
"--non-interactive",
"--accept-warnings",
])
.current_dir(&root)
.assert()
.success();
let skill_path = root.join("skills/sample-rust/SKILL.md");
let skill_before = fs::read_to_string(&skill_path).unwrap();
let pj = root.join(".claude-plugin/plugin.json");
let mut pjv: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&pj).unwrap()).unwrap();
pjv["version"] = serde_json::Value::String("9.9.9-fake".into());
fs::write(&pj, serde_json::to_string_pretty(&pjv).unwrap()).unwrap();
let out = Command::cargo_bin("skillpack")
.unwrap()
.args(["verify", "--root", ".", "--fix", "--format", "json"])
.current_dir(&root)
.output()
.unwrap();
assert!(
out.status.success(),
"verify --fix must exit 0 after repair, got:\n{}",
String::from_utf8_lossy(&out.stdout)
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("✓ applied 1 fix(es), wrote: .claude-plugin/plugin.json"),
"must report the surgical fix in stderr: {stderr}"
);
let stdout = String::from_utf8_lossy(&out.stdout);
let v: serde_json::Value = serde_json::from_str(&stdout)
.expect("verify --fix --format json stdout must be pure JSON, got:\n{stdout}");
let has_drift = v["results"]
.as_array()
.unwrap()
.iter()
.any(|r| r["check_id"].as_str().unwrap() == "discovery.plugin.version_drift");
assert!(
!has_drift,
"post-fix report must not still emit version_drift, got:\n{stdout}"
);
let pjv2: serde_json::Value = serde_json::from_str(&fs::read_to_string(&pj).unwrap()).unwrap();
assert_ne!(
pjv2["version"].as_str().unwrap(),
"9.9.9-fake",
"plugin.json version must have been rewritten, got:\n{}",
fs::read_to_string(&pj).unwrap()
);
assert_eq!(pjv2["version"].as_str().unwrap(), "0.1.0");
let skill_after = fs::read_to_string(&skill_path).unwrap();
assert_eq!(
skill_before, skill_after,
"verify --fix must NOT touch SKILL.md (surgical to plugin.json only)"
);
}
#[test]
fn verify_fix_json_stdout_is_pure_json_no_summary_prefix() {
let root = copy_fixture("rust-cli");
Command::new("cargo")
.args(["build", "--quiet"])
.current_dir(&root)
.assert()
.success();
write_skillpack_toml(&root, "sample-rust");
Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--root",
".",
"--non-interactive",
"--accept-warnings",
])
.current_dir(&root)
.assert()
.success();
let pj = root.join(".claude-plugin/plugin.json");
let mut pjv: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&pj).unwrap()).unwrap();
pjv["version"] = serde_json::Value::String("9.9.9-fake".into());
fs::write(&pj, serde_json::to_string_pretty(&pjv).unwrap()).unwrap();
let out = Command::cargo_bin("skillpack")
.unwrap()
.args(["verify", "--root", ".", "--fix", "--format", "json"])
.current_dir(&root)
.output()
.unwrap();
assert!(out.status.success(), "verify --fix must exit 0");
let stdout = String::from_utf8_lossy(&out.stdout);
let v: serde_json::Value = serde_json::from_str(&stdout)
.expect("verify --fix --format json stdout must be pure JSON (no summary prefix)");
assert_eq!(v["ok"], serde_json::Value::Bool(true));
assert!(
!stdout.contains("✓ applied"),
"summary must not leak to stdout"
);
assert!(
String::from_utf8_lossy(&out.stderr).contains("✓ applied 1 fix(es)"),
"summary must go to stderr"
);
}
#[test]
fn verify_verbose_json_stdout_is_pure_json_introspection_on_stderr() {
let root = copy_fixture("rust-cli");
Command::new("cargo")
.args(["build", "--quiet"])
.current_dir(&root)
.assert()
.success();
write_skillpack_toml(&root, "sample-rust");
Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--root",
".",
"--non-interactive",
"--accept-warnings",
])
.current_dir(&root)
.assert()
.success();
let out_json = Command::cargo_bin("skillpack")
.unwrap()
.args(["verify", "--root", ".", "--verbose", "--format", "json"])
.current_dir(&root)
.output()
.unwrap();
assert!(out_json.status.success(), "verify must exit 0");
let stdout = String::from_utf8_lossy(&out_json.stdout);
let v: serde_json::Value = serde_json::from_str(&stdout).expect(
"verify --verbose --format json stdout must be pure JSON (no introspection prefix)",
);
assert_eq!(v["ok"], serde_json::Value::Bool(true));
assert!(
!stdout.contains("introspection"),
"introspection block must not leak to stdout in JSON mode: {stdout}"
);
let stderr = String::from_utf8_lossy(&out_json.stderr);
assert!(
stderr.contains("introspection"),
"introspection block must be visible on stderr in JSON mode: {stderr}"
);
let out_human = Command::cargo_bin("skillpack")
.unwrap()
.args(["verify", "--root", ".", "--verbose", "--format", "human"])
.current_dir(&root)
.output()
.unwrap();
assert!(out_human.status.success(), "verify must exit 0");
let human_stdout = String::from_utf8_lossy(&out_human.stdout);
assert!(
human_stdout.contains("introspection"),
"introspection block must stay on stdout in human mode: {human_stdout}"
);
}
#[test]
fn verify_fix_is_noop_when_no_fixable_drift() {
let root = copy_fixture("rust-cli");
Command::new("cargo")
.args(["build", "--quiet"])
.current_dir(&root)
.assert()
.success();
write_skillpack_toml(&root, "sample-rust");
Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--root",
".",
"--non-interactive",
"--accept-warnings",
])
.current_dir(&root)
.assert()
.success();
let out = Command::cargo_bin("skillpack")
.unwrap()
.args(["verify", "--root", ".", "--fix", "--format", "json"])
.current_dir(&root)
.output()
.unwrap();
assert!(
out.status.success(),
"no-op --fix must exit 0, got:\n{}",
String::from_utf8_lossy(&out.stdout)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
!stdout.contains("✓ applied"),
"no-op --fix must NOT emit fix summary, got: {stdout}"
);
let v: serde_json::Value = serde_json::from_str(&stdout).unwrap();
assert_eq!(v["ok"], serde_json::Value::Bool(true));
}
#[test]
fn verify_checks_all_skills_in_a_multi_skill_plugin() {
let root = copy_fixture("rust-cli");
Command::new("cargo")
.args(["build", "--quiet"])
.current_dir(&root)
.assert()
.success();
write_skillpack_toml(&root, "sample-rust");
Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--root",
".",
"--non-interactive",
"--accept-warnings",
])
.current_dir(&root)
.assert()
.success();
fs::create_dir_all(root.join("skills/second-tool")).unwrap();
fs::write(
root.join("skills/second-tool/SKILL.md"),
"---\nname: second-tool\ndescription: \"\"\nwhen_to_use: \"x\"\n---\nbody\n",
)
.unwrap();
let out = Command::cargo_bin("skillpack")
.unwrap()
.args(["verify", "--root", "."])
.current_dir(&root)
.assert()
.failure()
.get_output()
.stdout
.clone();
let s = String::from_utf8_lossy(&out);
assert!(
s.contains("skills/second-tool/SKILL.md"),
"expected the second skill's path in the report, got:\n{s}"
);
}
#[test]
fn reverse_drift_warns_on_success_path_via_verify_run() {
let root = copy_fixture("rust-cli");
Command::new("cargo")
.args(["build", "--quiet"])
.current_dir(&root)
.assert()
.success();
fs::create_dir_all(root.join(".claude-plugin")).unwrap();
fs::create_dir_all(root.join("skills/sample-rust")).unwrap();
fs::write(
root.join(".claude-plugin/marketplace.json"),
"{\"name\":\"mp\",\"owner\":{\"name\":\"x\"},\"plugins\":[{\"name\":\"sample-rust\",\"source\":\"./\"}]}",
)
.unwrap();
fs::write(
root.join(".claude-plugin/plugin.json"),
"{\"name\":\"sample-rust\",\"description\":\"Do thing\"}",
)
.unwrap();
fs::write(
root.join("skills/sample-rust/SKILL.md"),
"---\nname: sample-rust\ndescription: \"Run the sample rust thing\"\nwhen_to_use: \"run sample rust\"\n---\n\n## Invocation\n\n```\nsample-rust --new entry\n```\n",
)
.unwrap();
let out = Command::cargo_bin("skillpack")
.unwrap()
.args(["verify", "--root", "."])
.current_dir(&root)
.assert()
.success() .get_output()
.stdout
.clone();
let s = String::from_utf8_lossy(&out);
assert!(
s.contains("undocumented_flags") || s.contains("--verbose"),
"expected reverse-drift warning for --verbose via verify, got:\n{s}"
);
}
#[test]
fn multi_cli_plugin_checks_every_skill_cli() {
let root = copy_fixture("rust-cli");
Command::new("cargo")
.args(["build", "--quiet"])
.current_dir(&root)
.assert()
.success();
fs::create_dir_all(root.join(".claude-plugin")).unwrap();
fs::create_dir_all(root.join("skills/sample-rust")).unwrap();
fs::write(
root.join(".claude-plugin/marketplace.json"),
"{\"name\":\"mp\",\"owner\":{\"name\":\"x\"},\"plugins\":[{\"name\":\"sample-rust\",\"source\":\"./\"}]}",
)
.unwrap();
fs::write(
root.join(".claude-plugin/plugin.json"),
"{\"name\":\"sample-rust\",\"description\":\"Do thing\"}",
)
.unwrap();
fs::write(
root.join("skills/sample-rust/SKILL.md"),
"---\nname: sample-rust\ndescription: \"Run the sample rust thing\"\nwhen_to_use: \"run sample rust\"\n---\n\n## Invocation\n\n```\nsample-rust --new entry\n```\n",
)
.unwrap();
fs::create_dir_all(root.join("skills/zzz-other-tool")).unwrap();
fs::write(
root.join("skills/zzz-other-tool/SKILL.md"),
"---\nname: zzz-other-tool\ndescription: \"Run the other thing\"\nwhen_to_use: \"run other\"\n---\n\n## Invocation\n\n```\nzzz-other-tool --flag\n```\n",
)
.unwrap();
let out = Command::cargo_bin("skillpack")
.unwrap()
.args(["verify", "--root", "."])
.current_dir(&root)
.assert()
.success() .get_output()
.stdout
.clone();
let s = String::from_utf8_lossy(&out);
assert!(
s.contains("invocation.secondary_not_runnable")
|| s.contains("secondary skill documents CLI"),
"expected a secondary-CLI not-runnable warning, got:\n{s}"
);
assert!(
s.contains("documented `--help` runs and produces output"),
"primary skill's --help check must run, got:\n{s}"
);
}
#[test]
fn hand_written_pack_documenting_unrunnable_cli_warns() {
let dest = tempfile::tempdir().unwrap().keep();
fs::create_dir_all(dest.join(".claude-plugin")).unwrap();
fs::create_dir_all(dest.join("skills/foo")).unwrap();
fs::write(
dest.join(".claude-plugin/marketplace.json"),
"{\"name\":\"foo-marketplace\",\"owner\":{\"name\":\"x\"},\"plugins\":[{\"name\":\"foo\",\"source\":\"./\"}]}",
)
.unwrap();
fs::write(
dest.join(".claude-plugin/plugin.json"),
"{\"name\":\"foo\",\"description\":\"Do thing\"}",
)
.unwrap();
fs::write(
dest.join("skills/foo/SKILL.md"),
"---\nname: foo\ndescription: \"Run the foo thing\"\nwhen_to_use: \"run foo\"\n---\n\n## Invocation\n\n```\nfoo --new entry\n```\n",
)
.unwrap();
let out = Command::cargo_bin("skillpack")
.unwrap()
.args(["verify", "--root", "."])
.current_dir(&dest)
.assert()
.success() .get_output()
.stdout
.clone();
let s = String::from_utf8_lossy(&out);
assert!(
s.contains("not_runnable_here") || s.contains("no runnable command"),
"expected a not-runnable warning, got:\n{s}"
);
assert!(
!s.contains("pure-library project"),
"a documented CLI must not read as a pure library, got:\n{s}"
);
}
#[test]
fn init_critical_decline_exits_fixable_not_aborted() {
let root = copy_fixture("bad-help");
Command::new("cargo")
.args(["build", "--quiet"])
.current_dir(&root)
.assert()
.success();
write_skillpack_toml(&root, "bad-help");
let out = Command::cargo_bin("skillpack")
.unwrap()
.args(["init", "--root", "."])
.arg("--accept-warnings") .current_dir(&root)
.write_stdin("n\n")
.assert()
.get_output()
.clone();
let code = out.status.code().unwrap_or(-1);
assert_eq!(
code, 2,
"declining a fixable critical must exit INIT_FIXABLE (2); got {code}"
);
assert!(
!root.join(".claude-plugin/marketplace.json").exists(),
"declined init must not write the marketplace manifest"
);
}
fn node_available() -> bool {
std::process::Command::new("node")
.arg("--version")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
#[test]
fn node_cli_init_then_verify_round_trip() {
if !node_available() {
eprintln!("skipped: node not on PATH");
return;
}
let root = copy_fixture("node-cli");
let toml = "[skill]\n\
name = \"sample-node\"\n\
one_line_description = \"Build and run a sample Node CLI\"\n\
when_to_use_phrases = [\"build a node sample\", \"run the node demo\"]\n\
invocation_command = \"sample-node --build\"\n\
license = \"MIT\"\n";
fs::write(root.join("skillpack.toml"), toml).unwrap();
Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--root",
".",
"--non-interactive",
"--accept-warnings",
])
.current_dir(&root)
.assert()
.success();
assert!(root.join(".claude-plugin/marketplace.json").exists());
assert!(root.join(".claude-plugin/plugin.json").exists());
assert!(root.join("skills/sample-node/SKILL.md").exists());
assert!(root.join("skillpack.toml").exists());
let skill = fs::read_to_string(root.join("skills/sample-node/SKILL.md")).unwrap();
assert!(skill.contains("## Invocation"));
assert!(!skill.contains("## Usage"));
Command::cargo_bin("skillpack")
.unwrap()
.args(["verify", "--root", "."])
.current_dir(&root)
.assert()
.success()
.stdout(predicate::str::contains("verify OK"))
.stdout(predicate::str::contains(
"documented `--help` runs and produces output",
))
.stdout(predicate::str::contains(
"every documented flag exists in `--help`",
));
let mp = fs::read_to_string(root.join(".claude-plugin/marketplace.json")).unwrap();
let v: serde_json::Value = serde_json::from_str(&mp).unwrap();
assert_eq!(v["plugins"][0]["source"], "./");
assert_eq!(v["plugins"][0]["name"], "sample-node");
}
fn python_available() -> bool {
std::process::Command::new("python3")
.arg("--version")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
#[test]
fn python_cli_init_then_verify_round_trip() {
if !python_available() {
eprintln!("skipped: python not on PATH");
return;
}
let root = copy_fixture("python-cli");
let toml = "[skill]\n\
name = \"sample-python\"\n\
one_line_description = \"Lint and fix a sample Python project\"\n\
when_to_use_phrases = [\"lint python code\", \"apply auto-fixes\"]\n\
invocation_command = \"sample-python --lint\"\n\
license = \"MIT\"\n";
fs::write(root.join("skillpack.toml"), toml).unwrap();
Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--root",
".",
"--non-interactive",
"--accept-warnings",
])
.current_dir(&root)
.assert()
.success();
assert!(root.join(".claude-plugin/marketplace.json").exists());
assert!(root.join(".claude-plugin/plugin.json").exists());
assert!(root.join("skills/sample-python/SKILL.md").exists());
assert!(root.join("skillpack.toml").exists());
let skill = fs::read_to_string(root.join("skills/sample-python/SKILL.md")).unwrap();
assert!(skill.contains("## Invocation"));
assert!(!skill.contains("## Usage"));
Command::cargo_bin("skillpack")
.unwrap()
.args(["verify", "--root", "."])
.current_dir(&root)
.assert()
.success()
.stdout(predicate::str::contains("verify OK"))
.stdout(predicate::str::contains(
"documented `--help` runs and produces output",
))
.stdout(predicate::str::contains(
"every documented flag exists in `--help`",
));
let mp = fs::read_to_string(root.join(".claude-plugin/marketplace.json")).unwrap();
let v: serde_json::Value = serde_json::from_str(&mp).unwrap();
assert_eq!(v["plugins"][0]["source"], "./");
assert_eq!(v["plugins"][0]["name"], "sample-python");
}
fn go_available() -> bool {
std::process::Command::new("go")
.arg("version")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
#[test]
#[ignore = "requires `go` on PATH; runs on CI via --include-ignored"]
fn go_cli_init_then_verify_round_trip() {
if !go_available() {
eprintln!("skipped: go not on PATH");
return;
}
let root = copy_fixture("go-cli");
let toml = "[skill]\n\
name = \"sample-go\"\n\
one_line_description = \"Lint and fix a sample Go project\"\n\
when_to_use_phrases = [\"lint go code\", \"run the go demo\"]\n\
invocation_command = \"sample-go --lint\"\n\
license = \"MIT\"\n";
fs::write(root.join("skillpack.toml"), toml).unwrap();
Command::new("go")
.args(["build", "./"])
.current_dir(&root)
.assert()
.success();
Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--root",
".",
"--non-interactive",
"--accept-warnings",
])
.current_dir(&root)
.assert()
.success();
assert!(root.join(".claude-plugin/marketplace.json").exists());
assert!(root.join(".claude-plugin/plugin.json").exists());
assert!(root.join("skills/sample-go/SKILL.md").exists());
assert!(root.join("skillpack.toml").exists());
let skill = fs::read_to_string(root.join("skills/sample-go/SKILL.md")).unwrap();
assert!(skill.contains("## Invocation"));
assert!(!skill.contains("## Usage"));
Command::cargo_bin("skillpack")
.unwrap()
.args(["verify", "--root", "."])
.current_dir(&root)
.assert()
.success()
.stdout(predicate::str::contains("verify OK"))
.stdout(predicate::str::contains(
"documented `--help` runs and produces output",
))
.stdout(predicate::str::contains(
"every documented flag exists in `--help`",
));
let mp = fs::read_to_string(root.join(".claude-plugin/marketplace.json")).unwrap();
let v: serde_json::Value = serde_json::from_str(&mp).unwrap();
assert_eq!(v["plugins"][0]["source"], "./");
assert_eq!(v["plugins"][0]["name"], "sample-go");
}
fn ruby_available() -> bool {
std::process::Command::new("ruby")
.arg("--version")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
#[test]
#[ignore = "requires `ruby` on PATH; runs on CI via --include-ignored"]
fn ruby_cli_init_then_verify_round_trip() {
if !ruby_available() {
eprintln!("skipped: ruby not on PATH");
return;
}
let root = copy_fixture("ruby-cli");
let toml = "[skill]\n\
name = \"sample-ruby\"\n\
one_line_description = \"Lint and fix a sample Ruby project\"\n\
when_to_use_phrases = [\"lint ruby code\", \"run the ruby demo\"]\n\
invocation_command = \"sample-ruby --lint\"\n\
license = \"MIT\"\n";
fs::write(root.join("skillpack.toml"), toml).unwrap();
Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--root",
".",
"--non-interactive",
"--accept-warnings",
])
.current_dir(&root)
.assert()
.success();
assert!(root.join(".claude-plugin/marketplace.json").exists());
assert!(root.join(".claude-plugin/plugin.json").exists());
assert!(root.join("skills/sample-ruby/SKILL.md").exists());
assert!(root.join("skillpack.toml").exists());
let skill = fs::read_to_string(root.join("skills/sample-ruby/SKILL.md")).unwrap();
assert!(skill.contains("## Invocation"));
assert!(!skill.contains("## Usage"));
Command::cargo_bin("skillpack")
.unwrap()
.args(["verify", "--root", "."])
.current_dir(&root)
.assert()
.success()
.stdout(predicate::str::contains("verify OK"))
.stdout(predicate::str::contains(
"documented `--help` runs and produces output",
))
.stdout(predicate::str::contains(
"every documented flag exists in `--help`",
));
let mp = fs::read_to_string(root.join(".claude-plugin/marketplace.json")).unwrap();
let v: serde_json::Value = serde_json::from_str(&mp).unwrap();
assert_eq!(v["plugins"][0]["source"], "./");
assert_eq!(v["plugins"][0]["name"], "sample-ruby");
}
fn php_available() -> bool {
std::process::Command::new("php")
.arg("--version")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
#[test]
#[ignore = "requires `php` on PATH; runs on CI via --include-ignored"]
fn php_cli_init_then_verify_round_trip() {
if !php_available() {
eprintln!("skipped: php not on PATH");
return;
}
let root = copy_fixture("php-cli");
let toml = "[skill]\n\
name = \"sample-php\"\n\
one_line_description = \"Print a journal entry from PHP\"\n\
when_to_use_phrases = [\"log a php entry\", \"record a quick note\"]\n\
invocation_command = \"sample-php --new \\\"entry\\\"\"\n\
license = \"MIT\"\n";
fs::write(root.join("skillpack.toml"), toml).unwrap();
Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--root",
".",
"--non-interactive",
"--accept-warnings",
])
.current_dir(&root)
.assert()
.success();
assert!(root.join(".claude-plugin/marketplace.json").exists());
assert!(root.join(".claude-plugin/plugin.json").exists());
assert!(root.join("skills/sample-php/SKILL.md").exists());
let skill = fs::read_to_string(root.join("skills/sample-php/SKILL.md")).unwrap();
assert!(skill.contains("## Invocation"));
assert!(!skill.contains("## Usage"));
Command::cargo_bin("skillpack")
.unwrap()
.args(["verify", "--root", "."])
.current_dir(&root)
.assert()
.success()
.stdout(predicate::str::contains("verify OK"))
.stdout(predicate::str::contains(
"documented `--help` runs and produces output",
))
.stdout(predicate::str::contains(
"every documented flag exists in `--help`",
));
let mp = fs::read_to_string(root.join(".claude-plugin/marketplace.json")).unwrap();
let v: serde_json::Value = serde_json::from_str(&mp).unwrap();
assert_eq!(v["plugins"][0]["source"], "./");
assert_eq!(v["plugins"][0]["name"], "sample-php");
}
#[cfg(unix)]
fn jvm_available() -> bool {
std::process::Command::new("sh")
.arg("--version")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
#[cfg(unix)]
#[test]
#[ignore = "requires `sh` on PATH (JVM fixture ships an installDist launcher script); runs on CI via --include-ignored"]
fn jvm_cli_init_then_verify_round_trip() {
if !jvm_available() {
eprintln!("skipped: sh not on PATH (needed for the JVM installDist launcher)");
return;
}
let root = copy_fixture("jvm-cli");
let toml = "[skill]\n\
name = \"sample-jvm\"\n\
one_line_description = \"Print a journal entry from the JVM\"\n\
when_to_use_phrases = [\"log a jvm entry\", \"record a quick note\"]\n\
invocation_command = \"sample-jvm --new \\\"entry\\\"\"\n\
license = \"MIT\"\n";
fs::write(root.join("skillpack.toml"), toml).unwrap();
Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--root",
".",
"--non-interactive",
"--accept-warnings",
])
.current_dir(&root)
.assert()
.success();
assert!(root.join(".claude-plugin/marketplace.json").exists());
assert!(root.join(".claude-plugin/plugin.json").exists());
assert!(root.join("skills/sample-jvm/SKILL.md").exists());
let skill = fs::read_to_string(root.join("skills/sample-jvm/SKILL.md")).unwrap();
assert!(skill.contains("## Invocation"));
assert!(!skill.contains("## Usage"));
Command::cargo_bin("skillpack")
.unwrap()
.args(["verify", "--root", "."])
.current_dir(&root)
.assert()
.success()
.stdout(predicate::str::contains("verify OK"))
.stdout(predicate::str::contains(
"documented `--help` runs and produces output",
))
.stdout(predicate::str::contains(
"every documented flag exists in `--help`",
));
let mp = fs::read_to_string(root.join(".claude-plugin/marketplace.json")).unwrap();
let v: serde_json::Value = serde_json::from_str(&mp).unwrap();
assert_eq!(v["plugins"][0]["source"], "./");
assert_eq!(v["plugins"][0]["name"], "sample-jvm");
}
fn csharp_available() -> bool {
std::process::Command::new("dotnet")
.arg("--version")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
#[test]
#[ignore = "requires `dotnet` on PATH; runs on CI via --include-ignored"]
fn csharp_cli_init_then_verify_round_trip() {
if !csharp_available() {
eprintln!("skipped: dotnet not on PATH");
return;
}
let root = copy_fixture("csharp-cli");
let toml = "[skill]\n\
name = \"sample-csharp\"\n\
one_line_description = \"Print a journal entry from C#\"\n\
when_to_use_phrases = [\"log a csharp entry\", \"record a quick note\"]\n\
invocation_command = \"sample-csharp --new \\\"entry\\\"\"\n\
license = \"MIT\"\n";
fs::write(root.join("skillpack.toml"), toml).unwrap();
Command::new("dotnet")
.args(["build", "-v", "q"])
.current_dir(&root)
.assert()
.success();
Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--root",
".",
"--non-interactive",
"--accept-warnings",
])
.current_dir(&root)
.assert()
.success();
assert!(root.join(".claude-plugin/marketplace.json").exists());
assert!(root.join(".claude-plugin/plugin.json").exists());
assert!(root.join("skills/sample-csharp/SKILL.md").exists());
let skill = fs::read_to_string(root.join("skills/sample-csharp/SKILL.md")).unwrap();
assert!(skill.contains("## Invocation"));
assert!(!skill.contains("## Usage"));
Command::cargo_bin("skillpack")
.unwrap()
.args(["verify", "--root", "."])
.current_dir(&root)
.assert()
.success()
.stdout(predicate::str::contains("verify OK"))
.stdout(predicate::str::contains(
"documented `--help` runs and produces output",
))
.stdout(predicate::str::contains(
"every documented flag exists in `--help`",
));
let mp = fs::read_to_string(root.join(".claude-plugin/marketplace.json")).unwrap();
let v: serde_json::Value = serde_json::from_str(&mp).unwrap();
assert_eq!(v["plugins"][0]["source"], "./");
assert_eq!(v["plugins"][0]["name"], "sample-csharp");
}
#[cfg(unix)]
fn sh_available() -> bool {
std::process::Command::new("sh")
.arg("-c")
.arg("exit 0")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
#[cfg(unix)]
#[test]
#[ignore = "requires `sh` on PATH (fixture ships a pre-built launcher script); runs on CI via --include-ignored"]
fn zig_cli_init_then_verify_round_trip() {
if !sh_available() {
eprintln!("skipped: sh not on PATH (needed for the Zig launcher script)");
return;
}
let root = copy_fixture("zig-cli");
let toml = "[skill]\n\
name = \"sample-zig\"\n\
one_line_description = \"Print a journal entry from Zig\"\n\
when_to_use_phrases = [\"log a zig entry\", \"record a quick note\"]\n\
invocation_command = \"sample-zig --new\"\n\
license = \"MIT\"\n";
fs::write(root.join("skillpack.toml"), toml).unwrap();
Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--root",
".",
"--non-interactive",
"--accept-warnings",
])
.current_dir(&root)
.assert()
.success();
assert!(root.join(".claude-plugin/marketplace.json").exists());
assert!(root.join(".claude-plugin/plugin.json").exists());
assert!(root.join("skills/sample-zig/SKILL.md").exists());
let skill = fs::read_to_string(root.join("skills/sample-zig/SKILL.md")).unwrap();
assert!(skill.contains("## Invocation"));
assert!(!skill.contains("## Usage"));
Command::cargo_bin("skillpack")
.unwrap()
.args(["verify", "--root", "."])
.current_dir(&root)
.assert()
.success()
.stdout(predicate::str::contains("verify OK"))
.stdout(predicate::str::contains(
"documented `--help` runs and produces output",
))
.stdout(predicate::str::contains(
"every documented flag exists in `--help`",
));
let mp = fs::read_to_string(root.join(".claude-plugin/marketplace.json")).unwrap();
let v: serde_json::Value = serde_json::from_str(&mp).unwrap();
assert_eq!(v["plugins"][0]["source"], "./");
assert_eq!(v["plugins"][0]["name"], "sample-zig");
}
#[cfg(unix)]
#[test]
#[ignore = "requires `sh` on PATH (fixture ships a pre-built launcher script); runs on CI via --include-ignored"]
fn swift_cli_init_then_verify_round_trip() {
if !sh_available() {
eprintln!("skipped: sh not on PATH (needed for the Swift launcher script)");
return;
}
let root = copy_fixture("swift-cli");
let toml = "[skill]\n\
name = \"sample-swift\"\n\
one_line_description = \"Print a journal entry from Swift\"\n\
when_to_use_phrases = [\"log a swift entry\", \"record a quick note\"]\n\
invocation_command = \"sample-swift --new\"\n\
license = \"MIT\"\n";
fs::write(root.join("skillpack.toml"), toml).unwrap();
Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--root",
".",
"--non-interactive",
"--accept-warnings",
])
.current_dir(&root)
.assert()
.success();
assert!(root.join(".claude-plugin/marketplace.json").exists());
assert!(root.join(".claude-plugin/plugin.json").exists());
assert!(root.join("skills/sample-swift/SKILL.md").exists());
let skill = fs::read_to_string(root.join("skills/sample-swift/SKILL.md")).unwrap();
assert!(skill.contains("## Invocation"));
assert!(!skill.contains("## Usage"));
Command::cargo_bin("skillpack")
.unwrap()
.args(["verify", "--root", "."])
.current_dir(&root)
.assert()
.success()
.stdout(predicate::str::contains("verify OK"))
.stdout(predicate::str::contains(
"documented `--help` runs and produces output",
))
.stdout(predicate::str::contains(
"every documented flag exists in `--help`",
));
let mp = fs::read_to_string(root.join(".claude-plugin/marketplace.json")).unwrap();
let v: serde_json::Value = serde_json::from_str(&mp).unwrap();
assert_eq!(v["plugins"][0]["source"], "./");
assert_eq!(v["plugins"][0]["name"], "sample-swift");
}
#[cfg(unix)]
#[test]
#[ignore = "requires `sh` on PATH (fixture ships a pre-built launcher script); runs on CI via --include-ignored"]
fn c_cpp_cli_init_then_verify_round_trip() {
if !sh_available() {
eprintln!("skipped: sh not on PATH (needed for the C/C++ launcher script)");
return;
}
let root = copy_fixture("c-cpp-cli");
let toml = "[skill]\n\
name = \"sample-cpp\"\n\
one_line_description = \"Print a journal entry from C++\"\n\
when_to_use_phrases = [\"log a cpp entry\", \"record a quick note\"]\n\
invocation_command = \"sample-cpp --new\"\n\
license = \"MIT\"\n";
fs::write(root.join("skillpack.toml"), toml).unwrap();
Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--root",
".",
"--non-interactive",
"--accept-warnings",
])
.current_dir(&root)
.assert()
.success();
assert!(root.join(".claude-plugin/marketplace.json").exists());
assert!(root.join(".claude-plugin/plugin.json").exists());
assert!(root.join("skills/sample-cpp/SKILL.md").exists());
let skill = fs::read_to_string(root.join("skills/sample-cpp/SKILL.md")).unwrap();
assert!(skill.contains("## Invocation"));
assert!(!skill.contains("## Usage"));
Command::cargo_bin("skillpack")
.unwrap()
.args(["verify", "--root", "."])
.current_dir(&root)
.assert()
.success()
.stdout(predicate::str::contains("verify OK"))
.stdout(predicate::str::contains(
"documented `--help` runs and produces output",
))
.stdout(predicate::str::contains(
"every documented flag exists in `--help`",
));
let mp = fs::read_to_string(root.join(".claude-plugin/marketplace.json")).unwrap();
let v: serde_json::Value = serde_json::from_str(&mp).unwrap();
assert_eq!(v["plugins"][0]["source"], "./");
assert_eq!(v["plugins"][0]["name"], "sample-cpp");
}
#[cfg(unix)]
#[test]
#[ignore = "requires `sh` on PATH (fixture ships a pre-built launcher script); runs on CI via --include-ignored"]
fn elixir_cli_init_then_verify_round_trip() {
if !sh_available() {
eprintln!("skipped: sh not on PATH (needed for the Elixir launcher script)");
return;
}
let root = copy_fixture("elixir-cli");
let toml = "[skill]\n\
name = \"sample-elixir\"\n\
one_line_description = \"Print a journal entry from Elixir\"\n\
when_to_use_phrases = [\"log an elixir entry\", \"record a quick note\"]\n\
invocation_command = \"sample_elixir --new\"\n\
license = \"MIT\"\n";
fs::write(root.join("skillpack.toml"), toml).unwrap();
Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--root",
".",
"--non-interactive",
"--accept-warnings",
])
.current_dir(&root)
.assert()
.success();
assert!(root.join(".claude-plugin/marketplace.json").exists());
assert!(root.join(".claude-plugin/plugin.json").exists());
assert!(root.join("skills/sample-elixir/SKILL.md").exists());
let skill = fs::read_to_string(root.join("skills/sample-elixir/SKILL.md")).unwrap();
assert!(skill.contains("## Invocation"));
assert!(!skill.contains("## Usage"));
Command::cargo_bin("skillpack")
.unwrap()
.args(["verify", "--root", "."])
.current_dir(&root)
.assert()
.success()
.stdout(predicate::str::contains("verify OK"))
.stdout(predicate::str::contains(
"documented `--help` runs and produces output",
))
.stdout(predicate::str::contains(
"every documented flag exists in `--help`",
));
let mp = fs::read_to_string(root.join(".claude-plugin/marketplace.json")).unwrap();
let v: serde_json::Value = serde_json::from_str(&mp).unwrap();
assert_eq!(v["plugins"][0]["source"], "./");
assert_eq!(v["plugins"][0]["name"], "sample-elixir");
}
fn deno_available() -> bool {
std::process::Command::new("deno")
.arg("--version")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
#[test]
#[ignore = "requires `deno` on PATH; runs on CI via --include-ignored"]
fn deno_cli_init_then_verify_round_trip() {
if !deno_available() {
eprintln!("skipped: deno not on PATH");
return;
}
let root = copy_fixture("deno-cli");
let toml = "[skill]\n\
name = \"sample-deno\"\n\
one_line_description = \"Print a journal entry from Deno\"\n\
when_to_use_phrases = [\"log a deno entry\", \"record a quick note\"]\n\
invocation_command = \"sample-deno --new\"\n\
license = \"MIT\"\n";
fs::write(root.join("skillpack.toml"), toml).unwrap();
Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--root",
".",
"--non-interactive",
"--accept-warnings",
])
.current_dir(&root)
.assert()
.success();
assert!(root.join(".claude-plugin/marketplace.json").exists());
assert!(root.join(".claude-plugin/plugin.json").exists());
assert!(root.join("skills/sample-deno/SKILL.md").exists());
let skill = fs::read_to_string(root.join("skills/sample-deno/SKILL.md")).unwrap();
assert!(skill.contains("## Invocation"));
assert!(!skill.contains("## Usage"));
Command::cargo_bin("skillpack")
.unwrap()
.args(["verify", "--root", "."])
.current_dir(&root)
.assert()
.success()
.stdout(predicate::str::contains("verify OK"))
.stdout(predicate::str::contains(
"documented `--help` runs and produces output",
))
.stdout(predicate::str::contains(
"every documented flag exists in `--help`",
));
let mp = fs::read_to_string(root.join(".claude-plugin/marketplace.json")).unwrap();
let v: serde_json::Value = serde_json::from_str(&mp).unwrap();
assert_eq!(v["plugins"][0]["source"], "./");
assert_eq!(v["plugins"][0]["name"], "sample-deno");
}
#[test]
fn subcommand_cli_init_then_verify_round_trip() {
let root = copy_fixture("subcommand-cli");
Command::new("cargo")
.args(["build", "--quiet"])
.current_dir(&root)
.assert()
.success();
let toml = "[skill]\n\
name = \"sample-sub\"\n\
one_line_description = \"Scaffold and verify a skill pack\"\n\
when_to_use_phrases = [\"scaffold a skill pack\", \"verify a skill pack\"]\n\
invocation_command = \"sample-sub init --root\"\n\
license = \"MIT\"\n";
fs::write(root.join("skillpack.toml"), toml).unwrap();
Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--root",
".",
"--non-interactive",
"--accept-warnings",
])
.current_dir(&root)
.assert()
.success();
let skill = fs::read_to_string(root.join("skills/sample-sub/SKILL.md")).unwrap();
assert!(
skill.contains("### Subcommands"),
"SKILL.md must contain ### Subcommands block, got:\n{skill}"
);
assert!(
skill.contains("`init`"),
"SKILL.md must document the `init` subcommand, got:\n{skill}"
);
assert!(
skill.contains("`verify`"),
"SKILL.md must document the `verify` subcommand, got:\n{skill}"
);
assert!(
skill.contains("--non-interactive"),
"SKILL.md must list init's --non-interactive flag, got:\n{skill}"
);
assert!(
skill.contains("--format"),
"SKILL.md must list verify's --format flag, got:\n{skill}"
);
let json_out = Command::cargo_bin("skillpack")
.unwrap()
.args(["verify", "--root", ".", "--format", "json"])
.current_dir(&root)
.output()
.unwrap();
assert!(
json_out.status.success(),
"verify must exit 0, got:\n{}",
String::from_utf8_lossy(&json_out.stdout)
);
let json: serde_json::Value = serde_json::from_slice(&json_out.stdout).unwrap();
assert_eq!(
json["ok"],
serde_json::Value::Bool(true),
"verify ok must be true, got:\n{json}"
);
let sub_results: Vec<&serde_json::Value> = json["results"]
.as_array()
.unwrap()
.iter()
.filter(|r| r["check_id"] == "invocation.subcommand_drift")
.collect();
assert!(
!sub_results.is_empty(),
"verify must emit invocation.subcommand_drift results, got:\n{json}"
);
for r in &sub_results {
assert_eq!(
r["severity"], "pass",
"subcommand_drift must pass for all documented subs, got:\n{json}"
);
}
}
#[test]
fn nested_subcommand_cli_init_then_verify_round_trip() {
let root = copy_fixture("nested-subcommand-cli");
Command::new("cargo")
.args(["build", "--quiet"])
.current_dir(&root)
.assert()
.success();
let toml = "[skill]\n\
name = \"sample-nested\"\n\
one_line_description = \"Manage remotes in a nested-CLI tool\"\n\
when_to_use_phrases = [\"add a git remote\", \"remove a git remote\"]\n\
invocation_command = \"sample-nested remote add <name> <url>\"\n\
license = \"MIT\"\n";
fs::write(root.join("skillpack.toml"), toml).unwrap();
Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--root",
".",
"--non-interactive",
"--accept-warnings",
])
.current_dir(&root)
.assert()
.success();
let skill = fs::read_to_string(root.join("skills/sample-nested/SKILL.md")).unwrap();
assert!(
skill.contains("### Subcommands"),
"SKILL.md must contain ### Subcommands block, got:\n{skill}"
);
assert!(
skill.contains("- `remote`"),
"SKILL.md must document the top-level `remote` subcommand, got:\n{skill}"
);
assert!(
skill.contains(" - `add`"),
"SKILL.md must nest `add` under `remote`, got:\n{skill}"
);
assert!(
skill.contains(" - `remove`"),
"SKILL.md must nest `remove` under `remote`, got:\n{skill}"
);
assert!(
skill.contains("`--url`"),
"SKILL.md must list `remote add`'s --url flag, got:\n{skill}"
);
assert!(
skill.contains("`--name`"),
"SKILL.md must list `remote add`'s --name flag, got:\n{skill}"
);
let json_out = Command::cargo_bin("skillpack")
.unwrap()
.args(["verify", "--root", ".", "--format", "json"])
.current_dir(&root)
.output()
.unwrap();
assert!(
json_out.status.success(),
"verify must exit 0, got:\n{}",
String::from_utf8_lossy(&json_out.stdout)
);
let json: serde_json::Value = serde_json::from_slice(&json_out.stdout).unwrap();
assert_eq!(
json["ok"],
serde_json::Value::Bool(true),
"verify ok must be true, got:\n{json}"
);
let sub_results: Vec<&serde_json::Value> = json["results"]
.as_array()
.unwrap()
.iter()
.filter(|r| r["check_id"] == "invocation.subcommand_drift")
.collect();
assert!(
!sub_results.is_empty(),
"verify must emit invocation.subcommand_drift results, got:\n{json}"
);
for r in &sub_results {
assert_eq!(
r["severity"], "pass",
"subcommand_drift must pass for all documented paths, got:\n{json}"
);
}
assert!(
json_out
.stdout
.windows(b"remote add".len())
.any(|w| w == b"remote add"),
"a drift result must reference the nested `remote add` path, got:\n{}",
String::from_utf8_lossy(&json_out.stdout)
);
}
#[test]
fn log_format_json_emits_structured_events_on_stderr() {
let root = copy_fixture("rust-cli");
let out = Command::cargo_bin("skillpack")
.unwrap()
.args([
"doctor",
"--root",
".",
"--format",
"json",
"--log-level",
"debug",
"--log-format",
"json",
])
.current_dir(&root)
.output()
.unwrap();
assert!(out.status.success());
serde_json::from_slice::<serde_json::Value>(&out.stdout)
.expect("doctor --format json stdout must stay pure JSON");
let stderr = String::from_utf8_lossy(&out.stderr);
let lines: Vec<&str> = stderr.lines().filter(|l| !l.trim().is_empty()).collect();
assert!(
!lines.is_empty(),
"expected structured log events on stderr"
);
for line in &lines {
let ev: serde_json::Value = serde_json::from_str(line)
.unwrap_or_else(|e| panic!("stderr line is not JSON ({e}):\n{line}"));
assert!(
ev.get("level").is_some(),
"log event must carry a level, got:\n{line}"
);
}
assert!(
lines.iter().any(|l| l.contains("\"level\":\"DEBUG\"")),
"expected at least one DEBUG event, got:\n{stderr}"
);
}
#[test]
fn multi_target_init_then_verify_round_trip() {
let root = copy_fixture("rust-cli");
Command::new("cargo")
.args(["build", "--quiet"])
.current_dir(&root)
.assert()
.success();
write_skillpack_toml(&root, "sample-rust");
Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--target",
"claude",
"--target",
"cursor",
"--target",
"codex",
"--root",
".",
"--non-interactive",
"--accept-warnings",
])
.current_dir(&root)
.assert()
.success();
assert!(root.join("skills/sample-rust/SKILL.md").exists());
assert!(root.join(".cursor/rules/sample-rust.mdc").exists());
assert!(root.join(".codex/skills/sample-rust/SKILL.md").exists());
assert!(root.join(".claude-plugin/marketplace.json").exists());
assert!(root.join(".claude-plugin/plugin.json").exists());
let json_out = Command::cargo_bin("skillpack")
.unwrap()
.args(["verify", "--root", ".", "--format", "json"])
.current_dir(&root)
.output()
.unwrap();
assert!(
json_out.status.success(),
"verify must exit 0, got:\n{}",
String::from_utf8_lossy(&json_out.stdout)
);
let json: serde_json::Value = serde_json::from_slice(&json_out.stdout).unwrap();
assert_eq!(
json["ok"],
serde_json::Value::Bool(true),
"verify ok must be true, got:\n{json}"
);
let results = json["results"].as_array().unwrap();
for (check_id, label) in [
("discovery.skill", "claude"),
("discovery.codex.skill", "codex"),
("discovery.cursor.mdc", "cursor"),
] {
let matches: Vec<&serde_json::Value> = results
.iter()
.filter(|r| r["check_id"] == check_id)
.collect();
assert!(
!matches.is_empty(),
"verify must emit {check_id} result, got:\n{json}"
);
for r in &matches {
assert_eq!(
r["severity"], "pass",
"{label} check {check_id} must pass, got:\n{json}"
);
}
}
}
#[test]
fn cursor_only_init_does_not_fail_on_missing_claude_plugin_dir() {
let root = copy_fixture("rust-cli");
Command::new("cargo")
.args(["build", "--quiet"])
.current_dir(&root)
.assert()
.success();
write_skillpack_toml(&root, "sample-rust");
Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--target",
"cursor",
"--root",
".",
"--non-interactive",
"--accept-warnings",
])
.current_dir(&root)
.assert()
.success();
assert!(root.join(".cursor/rules/sample-rust.mdc").exists());
assert!(!root.join(".claude-plugin").exists());
Command::cargo_bin("skillpack")
.unwrap()
.args(["verify", "--root", "."])
.current_dir(&root)
.assert()
.success()
.stdout(predicate::str::contains("verify OK"));
}
#[test]
fn broken_mdc_missing_description_fails_verify() {
let root = copy_fixture("rust-cli");
Command::new("cargo")
.args(["build", "--quiet"])
.current_dir(&root)
.assert()
.success();
write_skillpack_toml(&root, "sample-rust");
Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--target",
"cursor",
"--root",
".",
"--non-interactive",
"--accept-warnings",
])
.current_dir(&root)
.assert()
.success();
fs::write(
root.join(".cursor/rules/sample-rust.mdc"),
"---\nalwaysApply: false\n---\n\n# sample-rust\n",
)
.unwrap();
let out = Command::cargo_bin("skillpack")
.unwrap()
.args(["verify", "--root", ".", "--format", "json"])
.current_dir(&root)
.output()
.unwrap();
assert!(
!out.status.success(),
"verify must exit non-zero on missing description, got:\n{}",
String::from_utf8_lossy(&out.stdout)
);
let json: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
let results = json["results"].as_array().unwrap();
let name = "discovery.cursor.mdc.description";
let matches: Vec<&serde_json::Value> =
results.iter().filter(|r| r["check_id"] == name).collect();
assert!(
!matches.is_empty(),
"verify must emit {name} result, got:\n{json}"
);
for r in &matches {
assert_eq!(
r["severity"], "fail",
"{name} must be fail severity, got:\n{json}"
);
}
}
#[test]
fn verify_warns_on_malformed_allowed_tools_grammar() {
let root = copy_fixture("rust-cli");
Command::new("cargo")
.args(["build", "--quiet"])
.current_dir(&root)
.assert()
.success();
write_skillpack_toml(&root, "sample-rust");
Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--root",
".",
"--non-interactive",
"--accept-warnings",
])
.current_dir(&root)
.assert()
.success();
let skill_path = root.join("skills/sample-rust/SKILL.md");
let raw = fs::read_to_string(&skill_path).unwrap();
let new_raw = replace_first_line_starting_with(
&raw,
"allowed-tools:",
"allowed-tools: Read, Bash(npm test:*), Bash(, 4R3ad",
);
assert_ne!(
new_raw, raw,
"test setup failed: emitted SKILL.md had no `allowed-tools:` line to replace"
);
fs::write(&skill_path, new_raw).unwrap();
let out = Command::cargo_bin("skillpack")
.unwrap()
.args(["verify", "--root", ".", "--format", "json"])
.current_dir(&root)
.output()
.unwrap();
assert!(
out.status.success(),
"verify must exit 0 on a warn (grammar only), got:\n{}",
String::from_utf8_lossy(&out.stdout)
);
let json: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
let results = json["results"].as_array().unwrap();
let matches: Vec<&serde_json::Value> = results
.iter()
.filter(|r| r["check_id"] == "discovery.skill.allowed_tools")
.collect();
assert!(
!matches.is_empty(),
"verify must emit discovery.skill.allowed_tools, got:\n{json}"
);
for r in &matches {
assert_eq!(
r["severity"], "warn",
"allowed_tools grammar must be warn, got:\n{json}"
);
let msg = r["message"].as_str().unwrap_or("");
assert!(msg.contains("`Bash(`"), "message must name `Bash(`: {msg}");
assert!(msg.contains("`4R3ad`"), "message must name `4R3ad`: {msg}");
assert!(
!msg.contains("`Read`"),
"valid `Read` must not be flagged bad: {msg}"
);
}
}
#[test]
fn verify_passes_on_valid_allowed_tools_grammar() {
let root = copy_fixture("rust-cli");
Command::new("cargo")
.args(["build", "--quiet"])
.current_dir(&root)
.assert()
.success();
write_skillpack_toml(&root, "sample-rust");
Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--root",
".",
"--non-interactive",
"--accept-warnings",
])
.current_dir(&root)
.assert()
.success();
let skill_path = root.join("skills/sample-rust/SKILL.md");
let raw = fs::read_to_string(&skill_path).unwrap();
let new_raw = replace_first_line_starting_with(
&raw,
"allowed-tools:",
"allowed-tools: Read, Edit, Bash(npm test:*), Grep(*)",
);
assert_ne!(
new_raw, raw,
"test setup failed: emitted SKILL.md had no `allowed-tools:` line to replace"
);
fs::write(&skill_path, new_raw).unwrap();
let out = Command::cargo_bin("skillpack")
.unwrap()
.args(["verify", "--root", ".", "--format", "json"])
.current_dir(&root)
.output()
.unwrap();
assert!(
out.status.success(),
"verify must exit 0, got:\n{}",
String::from_utf8_lossy(&out.stdout)
);
let json: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
let results = json["results"].as_array().unwrap();
let matches: Vec<&serde_json::Value> = results
.iter()
.filter(|r| r["check_id"] == "discovery.skill.allowed_tools")
.collect();
assert!(
matches.is_empty(),
"valid allowed-tools grammar must NOT emit a warn, got:\n{json}"
);
}
#[test]
fn self_dogfood_verify_on_repos_committed_files() {
Command::new("cargo")
.args(["build", "--release", "--quiet"])
.current_dir(repo_root())
.assert()
.success();
let out = Command::cargo_bin("skillpack")
.unwrap()
.args(["verify", "--root", "."])
.current_dir(repo_root())
.output()
.unwrap();
assert!(
out.status.success(),
"self-dogfood verify must pass, got:\n{}",
String::from_utf8_lossy(&out.stdout)
);
let s = String::from_utf8_lossy(&out.stdout);
assert!(s.contains("verify OK"), "expected verify OK, got:\n{s}");
assert!(s.contains(".claude-plugin/marketplace.json validates"));
assert!(s.contains("skills/skillpack/SKILL.md validates"));
assert!(s.contains(".codex/skills/skillpack/SKILL.md validates"));
assert!(s.contains(".cursor/rules/skillpack.mdc validates"));
}
#[test]
fn self_dogfood_regenerated_artifacts_match_committed_byte_identical() {
Command::new("cargo")
.args(["build", "--release", "--quiet"])
.current_dir(repo_root())
.assert()
.success();
let dest = tempfile::tempdir().unwrap().keep();
for entry in &[
"Cargo.toml",
"skillpack.toml",
"README.md",
"LICENSE",
"rust-toolchain.toml",
] {
fs::copy(repo_root().join(entry), dest.join(entry)).unwrap();
}
fs::create_dir_all(dest.join("docs")).unwrap();
fs::copy(
repo_root().join("docs/logo.png"),
dest.join("docs/logo.png"),
)
.unwrap();
for dir in &["src", "templates"] {
copy_dir(&repo_root().join(dir), &dest.join(dir));
}
Command::new("cargo")
.args(["build", "--release", "--quiet"])
.current_dir(&dest)
.assert()
.success();
let out = Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--non-interactive",
"--accept-warnings",
"--target",
"all",
"--force",
])
.current_dir(&dest)
.output()
.unwrap();
assert!(
out.status.success(),
"init must exit 0, got:\n{}",
String::from_utf8_lossy(&out.stdout)
);
for rel in &[
"skills/skillpack/SKILL.md",
".claude/skills/skillpack/SKILL.md",
".codex/skills/skillpack/SKILL.md",
".cursor/rules/skillpack.mdc",
".windsurf/rules/skillpack.md",
".opencode/agents/skillpack.md",
".github/copilot-instructions.md",
"AGENTS.md",
"CLAUDE.md",
"GEMINI.md",
"CONVENTIONS.md",
".clinerules/skillpack.md",
".roo/rules/skillpack.md",
".kilocode/rules/skillpack.md",
".goose/instructions.md",
] {
let regen = fs::read_to_string(dest.join(rel)).unwrap_or_default();
let committed = fs::read_to_string(repo_root().join(rel)).unwrap_or_default();
assert_eq!(
regen, committed,
"regenerated `{rel}` drifted from committed:\n--- committed ---\n{committed}\
\n--- regenerated ---\n{regen}"
);
}
let _ = fs::remove_dir_all(&dest);
}
#[test]
fn doctor_format_json_is_machine_readable() {
let root = copy_fixture("rust-cli");
write_skillpack_toml(&root, "sample-rust");
let out = Command::cargo_bin("skillpack")
.unwrap()
.args(["doctor", "--root", ".", "--format", "json"])
.current_dir(&root)
.assert()
.success()
.get_output()
.stdout
.clone();
let v: serde_json::Value = serde_json::from_str(&String::from_utf8_lossy(&out)).unwrap();
assert!(v["name"].is_string(), "name must be a string: {v}");
assert!(
matches!(
v["language"].as_str(),
Some("rust" | "node" | "python" | "go" | "ruby" | "unknown")
),
"language must be a known value: {v}"
);
assert!(v["has_cli"].is_boolean(), "has_cli must be bool: {v}");
let diag = v["diag"]
.as_array()
.expect("diag must always serialize as an array, even when empty");
assert!(
!diag.is_empty(),
"expected diag notes on unbuilt fixture: {v}"
);
for note in diag {
assert!(
note["stage"].is_string(),
"diag entry stage must be string: {note}"
);
assert!(
note["note"].is_string(),
"diag entry note must be string: {note}"
);
}
assert!(
diag.iter()
.any(|n| n["stage"].as_str() == Some("detect_cli")),
"expected at least one detect_cli-stage note: {v}"
);
Command::new("cargo")
.args(["build", "--release", "--quiet"])
.current_dir(repo_root())
.assert()
.success();
let clean_out = Command::cargo_bin("skillpack")
.unwrap()
.args(["doctor", "--root", ".", "--format", "json"])
.current_dir(repo_root())
.assert()
.success()
.get_output()
.stdout
.clone();
let cv: serde_json::Value = serde_json::from_str(&String::from_utf8_lossy(&clean_out)).unwrap();
assert_eq!(cv["has_cli"], serde_json::Value::Bool(true));
assert_eq!(
cv["diag"].as_array().map(std::vec::Vec::len),
Some(0),
"diag must be present as [] on clean runs, not omitted: {cv}"
);
}
fn cargo_workspace_scratch() -> PathBuf {
let root = tempfile::tempdir().unwrap().keep();
fs::write(
root.join("Cargo.toml"),
"[workspace]\nmembers = [\"cli-crate\"]\n",
)
.unwrap();
fs::create_dir_all(root.join("cli-crate")).unwrap();
fs::create_dir_all(root.join("cli-crate/src")).unwrap();
fs::write(
root.join("cli-crate/Cargo.toml"),
"[package]\nname = \"cli-crate\"\nversion = \"0.1.0\"\n[[bin]]\nname = \"cli-crate\"\npath = \"src/main.rs\"\n",
)
.unwrap();
fs::write(root.join("cli-crate/src/main.rs"), "fn main() {}\n").unwrap();
root
}
#[test]
fn doctor_cargo_workspace_finds_member_cli() {
let root = cargo_workspace_scratch();
Command::new("cargo")
.args(["build", "--release", "--quiet"])
.current_dir(root.join("cli-crate"))
.assert()
.success();
let out = Command::cargo_bin("skillpack")
.unwrap()
.args(["doctor", "--root", "."])
.current_dir(&root)
.output()
.unwrap();
assert!(
out.status.success(),
"doctor must exit 0 (read-only), got:\n{}",
String::from_utf8_lossy(&out.stdout)
);
let s = String::from_utf8_lossy(&out.stdout);
assert!(
s.contains("has_cli: true"),
"workspace with a member bin must report has_cli=true, got:\n{s}"
);
assert!(
s.contains("workspace"),
"doctor trace must reference the workspace detection, got:\n{s}"
);
}
#[test]
fn doctor_cargo_workspace_no_member_cli_reports_false() {
let root = cargo_workspace_scratch();
let out = Command::cargo_bin("skillpack")
.unwrap()
.args(["doctor", "--root", "."])
.current_dir(&root)
.output()
.unwrap();
assert!(out.status.success(), "doctor is read-only (exit 0)");
let s = String::from_utf8_lossy(&out.stdout);
assert!(
s.contains("has_cli: false"),
"workspace with unbuilt member must report has_cli=false, got:\n{s}"
);
assert!(
s.contains("no workspace member yielded a runnable CLI"),
"trace must explain the false, got:\n{s}"
);
assert!(
s.contains("💡"),
"diag note with embedded 'run skillpack init' suggestion must be prefixed with 💡, got:\n{s}"
);
}
fn npm_workspace_scratch() -> PathBuf {
let root = tempfile::tempdir().unwrap().keep();
fs::write(
root.join("package.json"),
"{ \"name\": \"ws-root\", \"workspaces\": [\"cli-pkg\"] }\n",
)
.unwrap();
fs::create_dir_all(root.join("cli-pkg/bin")).unwrap();
fs::write(
root.join("cli-pkg/package.json"),
"{ \"name\": \"cli-pkg\", \"bin\": { \"cli-pkg\": \"./bin/cli.js\" } }\n",
)
.unwrap();
fs::write(
root.join("cli-pkg/bin/cli.js"),
"#!/usr/bin/env node\nconsole.log('cli-pkg help');\n",
)
.unwrap();
root
}
#[test]
fn doctor_npm_workspace_finds_member_cli() {
if !node_available() {
return;
}
let root = npm_workspace_scratch();
let out = Command::cargo_bin("skillpack")
.unwrap()
.args(["doctor", "--root", "."])
.current_dir(&root)
.output()
.unwrap();
assert!(
out.status.success(),
"doctor must exit 0, got:\n{}",
String::from_utf8_lossy(&out.stdout)
);
let s = String::from_utf8_lossy(&out.stdout);
assert!(
s.contains("has_cli: true"),
"npm workspace with a member bin must report has_cli=true, got:\n{s}"
);
assert!(
s.contains("detect_cli.node.workspace"),
"trace must mention the npm workspace walk, got:\n{s}"
);
}
#[test]
fn doctor_npm_workspace_no_member_bin_reports_false() {
if !node_available() {
return;
}
let root = tempfile::tempdir().unwrap().keep();
fs::write(
root.join("package.json"),
"{ \"name\": \"ws-root\", \"workspaces\": [\"lib-pkg\"] }\n",
)
.unwrap();
fs::create_dir_all(root.join("lib-pkg")).unwrap();
fs::write(
root.join("lib-pkg/package.json"),
"{ \"name\": \"lib-pkg\" }\n",
)
.unwrap();
let out = Command::cargo_bin("skillpack")
.unwrap()
.args(["doctor", "--root", "."])
.current_dir(&root)
.output()
.unwrap();
assert!(out.status.success());
let s = String::from_utf8_lossy(&out.stdout);
assert!(
s.contains("has_cli: false"),
"npm workspace with no member bin must report has_cli=false, got:\n{s}"
);
}
#[test]
fn opencode_copilot_init_then_verify_round_trip() {
let root = copy_fixture("rust-cli");
Command::new("cargo")
.args(["build", "--quiet"])
.current_dir(&root)
.assert()
.success();
write_skillpack_toml(&root, "sample-rust");
Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--target",
"opencode",
"--target",
"copilot",
"--root",
".",
"--non-interactive",
"--accept-warnings",
])
.current_dir(&root)
.assert()
.success();
let opencode_path = root.join(".opencode/agents/sample-rust.md");
let copilot_path = root.join(".github/copilot-instructions.md");
assert!(opencode_path.exists(), "OpenCode agent file must exist");
assert!(
copilot_path.exists(),
"Copilot instructions file must exist"
);
let opencode_raw = fs::read_to_string(&opencode_path).unwrap();
assert!(
opencode_raw.starts_with("---\n"),
"OpenCode agent file must start with frontmatter, got:\n{opencode_raw}"
);
assert!(
opencode_raw.contains("description:"),
"OpenCode frontmatter must have description, got:\n{opencode_raw}"
);
let copilot_raw = fs::read_to_string(&copilot_path).unwrap();
assert!(
copilot_raw.starts_with("# "),
"Copilot instructions must start with a `#` heading, got:\n{copilot_raw}"
);
let json_out = Command::cargo_bin("skillpack")
.unwrap()
.args(["verify", "--root", ".", "--format", "json"])
.current_dir(&root)
.output()
.unwrap();
assert!(
json_out.status.success(),
"verify must exit 0, got:\n{}",
String::from_utf8_lossy(&json_out.stdout)
);
let json: serde_json::Value = serde_json::from_slice(&json_out.stdout).unwrap();
assert_eq!(json["ok"], serde_json::Value::Bool(true));
let results = json["results"].as_array().unwrap();
for check_id in ["discovery.opencode.agent", "discovery.copilot.instructions"] {
let matches: Vec<&serde_json::Value> = results
.iter()
.filter(|r| r["check_id"] == check_id)
.collect();
assert!(
!matches.is_empty(),
"verify must emit {check_id} result, got:\n{json}"
);
for r in &matches {
assert_eq!(r["severity"], "pass", "{check_id} must pass, got:\n{json}");
}
}
}
#[test]
fn opencode_missing_frontmatter_fails_verify() {
let root = copy_fixture("rust-cli");
Command::new("cargo")
.args(["build", "--quiet"])
.current_dir(&root)
.assert()
.success();
write_skillpack_toml(&root, "sample-rust");
Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--target",
"opencode",
"--root",
".",
"--non-interactive",
"--accept-warnings",
])
.current_dir(&root)
.assert()
.success();
fs::write(
root.join(".opencode/agents/sample-rust.md"),
"# sample-rust\n\nNo frontmatter here, just markdown.\n",
)
.unwrap();
let out = Command::cargo_bin("skillpack")
.unwrap()
.args(["verify", "--root", ".", "--format", "json"])
.current_dir(&root)
.output()
.unwrap();
assert!(
!out.status.success(),
"verify must exit non-zero on missing frontmatter, got:\n{}",
String::from_utf8_lossy(&out.stdout)
);
let json: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
let results = json["results"].as_array().unwrap();
let name = "discovery.opencode.agent.frontmatter";
let matches: Vec<&serde_json::Value> =
results.iter().filter(|r| r["check_id"] == name).collect();
assert!(
!matches.is_empty(),
"verify must emit {name} result, got:\n{json}"
);
for r in &matches {
assert_eq!(
r["severity"], "fail",
"{name} must be fail severity, got:\n{json}"
);
}
}
#[test]
fn copilot_frontmatter_fails_verify() {
let root = copy_fixture("rust-cli");
Command::new("cargo")
.args(["build", "--quiet"])
.current_dir(&root)
.assert()
.success();
write_skillpack_toml(&root, "sample-rust");
Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--target",
"copilot",
"--root",
".",
"--non-interactive",
"--accept-warnings",
])
.current_dir(&root)
.assert()
.success();
fs::write(
root.join(".github/copilot-instructions.md"),
"---\ndescription: x\n---\n# Foo\n",
)
.unwrap();
let out = Command::cargo_bin("skillpack")
.unwrap()
.args(["verify", "--root", ".", "--format", "json"])
.current_dir(&root)
.output()
.unwrap();
assert!(
!out.status.success(),
"verify must exit non-zero on frontmatter present, got:\n{}",
String::from_utf8_lossy(&out.stdout)
);
let json: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
let results = json["results"].as_array().unwrap();
let name = "discovery.copilot.instructions";
let matches: Vec<&serde_json::Value> =
results.iter().filter(|r| r["check_id"] == name).collect();
assert!(
!matches.is_empty(),
"verify must emit {name} result, got:\n{json}"
);
for r in &matches {
assert_eq!(
r["severity"], "fail",
"{name} must be fail severity, got:\n{json}"
);
}
}
#[test]
fn codex_empty_skills_dir_fails_verify() {
let root = copy_fixture("rust-cli");
Command::new("cargo")
.args(["build", "--quiet"])
.current_dir(&root)
.assert()
.success();
write_skillpack_toml(&root, "sample-rust");
Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--target",
"codex",
"--root",
".",
"--non-interactive",
"--accept-warnings",
])
.current_dir(&root)
.assert()
.success();
assert!(root.join(".codex/skills").is_dir());
let skill_dir = root.join(".codex/skills/sample-rust");
fs::remove_file(skill_dir.join("SKILL.md")).unwrap();
fs::remove_dir(&skill_dir).unwrap();
assert!(root.join(".codex/skills").exists());
let out = Command::cargo_bin("skillpack")
.unwrap()
.args(["verify", "--root", ".", "--format", "json"])
.current_dir(&root)
.output()
.unwrap();
assert!(
!out.status.success(),
"verify must exit non-zero on empty .codex/skills/, got:\n{}",
String::from_utf8_lossy(&out.stdout)
);
let json: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
let results = json["results"].as_array().unwrap();
let name = "discovery.codex.skill.missing";
let matches: Vec<&serde_json::Value> =
results.iter().filter(|r| r["check_id"] == name).collect();
assert!(
!matches.is_empty(),
"verify must emit {name} result, got:\n{json}"
);
for r in &matches {
assert_eq!(
r["severity"], "fail",
"{name} must be fail severity, got:\n{json}"
);
}
}
#[test]
fn all_six_targets_init_then_verify_round_trip() {
let root = copy_fixture("rust-cli");
Command::new("cargo")
.args(["build", "--quiet"])
.current_dir(&root)
.assert()
.success();
write_skillpack_toml(&root, "sample-rust");
Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--target",
"all",
"--root",
".",
"--non-interactive",
"--accept-warnings",
])
.current_dir(&root)
.assert()
.success();
assert!(root.join(".claude-plugin/marketplace.json").exists());
assert!(root.join(".cursor/rules/sample-rust.mdc").exists());
assert!(root.join(".codex/skills/sample-rust/SKILL.md").exists());
assert!(root.join(".opencode/agents/sample-rust.md").exists());
assert!(root.join(".github/copilot-instructions.md").exists());
assert!(root.join("AGENTS.md").exists());
let out = Command::cargo_bin("skillpack")
.unwrap()
.args(["verify", "--root", ".", "--format", "json"])
.current_dir(&root)
.output()
.unwrap();
assert!(
out.status.success(),
"verify must pass, got:\n{}",
String::from_utf8_lossy(&out.stdout)
);
let json: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
let results = json["results"].as_array().unwrap();
let expected_ids = [
"discovery.skill",
"discovery.codex.skill",
"discovery.cursor.mdc",
"discovery.opencode.agent",
"discovery.copilot.instructions",
"discovery.agentsmd",
];
for id in &expected_ids {
let matches: Vec<&serde_json::Value> =
results.iter().filter(|r| r["check_id"] == *id).collect();
assert!(!matches.is_empty(), "verify must emit {id}, got:\n{json}");
for r in &matches {
assert_eq!(r["severity"], "pass", "{id} must be pass, got:\n{json}");
}
}
}
#[test]
fn verify_on_empty_repo_fails_with_discovery_empty() {
let root = copy_fixture("rust-cli");
Command::new("cargo")
.args(["build", "--quiet"])
.current_dir(&root)
.assert()
.success();
let out = Command::cargo_bin("skillpack")
.unwrap()
.args(["verify", "--root", ".", "--format", "json"])
.current_dir(&root)
.output()
.unwrap();
assert!(!out.status.success(), "verify on empty repo must fail");
let json: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
let results = json["results"].as_array().unwrap();
let matches: Vec<&serde_json::Value> = results
.iter()
.filter(|r| r["check_id"] == "discovery.empty")
.collect();
assert!(
!matches.is_empty(),
"must emit discovery.empty, got:\n{json}"
);
assert_eq!(matches[0]["severity"], "fail");
}
#[test]
fn self_dogfood_verify_asserts_all_ecosystems() {
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let out = Command::cargo_bin("skillpack")
.unwrap()
.args(["verify", "--root", ".", "--format", "json"])
.current_dir(&root)
.output()
.unwrap();
assert!(
out.status.success(),
"self-dogfood verify must pass, got:\n{}",
String::from_utf8_lossy(&out.stdout)
);
let json: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
let results = json["results"].as_array().unwrap();
let check_expectations = [
("discovery.marketplace", "pass"),
("discovery.skill", "pass"),
("discovery.cursor.mdc", "pass"),
("discovery.codex.skill", "pass"),
("discovery.opencode.agent", "pass"),
("discovery.copilot.instructions", "pass"),
("discovery.agentsmd", "pass"),
];
for (id, severity) in &check_expectations {
let matches: Vec<&serde_json::Value> =
results.iter().filter(|r| r["check_id"] == *id).collect();
assert!(
!matches.is_empty(),
"self-dogfood must emit {id}, got:\n{json}"
);
assert_eq!(
matches[0]["severity"], *severity,
"{id} must be {severity}, got:\n{json}"
);
}
}
#[test]
fn doctor_on_plain_rust_cli_reports_has_cli_true() {
let root = copy_fixture("rust-cli");
Command::new("cargo")
.args(["build", "--quiet"])
.current_dir(&root)
.assert()
.success();
let out = Command::cargo_bin("skillpack")
.unwrap()
.args(["doctor", "--root", "."])
.current_dir(&root)
.output()
.unwrap();
assert!(out.status.success(), "doctor must exit 0");
let s = String::from_utf8_lossy(&out.stdout);
assert!(
s.contains("has_cli: true"),
"doctor should report has_cli: true, got:\n{s}"
);
}
#[test]
fn target_all_with_dup_does_not_double_write() {
let root = copy_fixture("rust-cli");
Command::new("cargo")
.args(["build", "--quiet"])
.current_dir(&root)
.assert()
.success();
write_skillpack_toml(&root, "sample-rust");
let out = Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--target",
"all",
"--target",
"claude",
"--root",
".",
"--non-interactive",
"--accept-warnings",
])
.current_dir(&root)
.output()
.unwrap();
assert!(
out.status.success(),
"init must exit 0, got:\n{}",
String::from_utf8_lossy(&out.stdout)
);
let s = String::from_utf8_lossy(&out.stdout);
assert!(
s.contains("wrote 17 file(s)"),
"dedup must reduce all+claude to 14 targets (17 files), got:\n{s}"
);
Command::cargo_bin("skillpack")
.unwrap()
.args(["verify", "--root", ".", "--format", "json"])
.current_dir(&root)
.assert()
.success();
}
#[test]
fn agents_md_collision_guard_skips_without_force() {
let root = copy_fixture("rust-cli");
Command::new("cargo")
.args(["build", "--quiet"])
.current_dir(&root)
.assert()
.success();
write_skillpack_toml(&root, "sample-rust");
let existing = "# My custom agent instructions\n\nDo not touch.\n";
std::fs::write(root.join("AGENTS.md"), existing).unwrap();
let out = Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--target",
"agentsmd",
"--root",
".",
"--non-interactive",
"--accept-warnings",
])
.current_dir(&root)
.output()
.unwrap();
assert!(
out.status.success(),
"init must exit 0 even when skipping collision, got:\n{}",
String::from_utf8_lossy(&out.stdout)
);
let after = std::fs::read_to_string(root.join("AGENTS.md")).unwrap();
assert_eq!(
after, existing,
"collision guard must not overwrite without --force"
);
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains("AGENTS.md already exists") && err.contains("skipping"),
"must warn about skipped AGENTS.md, got:\n{err}"
);
}
#[test]
fn agents_md_collision_guard_overwrites_with_force() {
let root = copy_fixture("rust-cli");
Command::new("cargo")
.args(["build", "--quiet"])
.current_dir(&root)
.assert()
.success();
write_skillpack_toml(&root, "sample-rust");
std::fs::write(root.join("AGENTS.md"), "# Old instructions\n\nStale.\n").unwrap();
Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--target",
"agentsmd",
"--force",
"--root",
".",
"--non-interactive",
"--accept-warnings",
])
.current_dir(&root)
.assert()
.success();
let after = std::fs::read_to_string(root.join("AGENTS.md")).unwrap();
assert_ne!(
after, "# Old instructions\n\nStale.\n",
"--force must overwrite the pre-existing AGENTS.md"
);
assert!(
after.contains("skillpack"),
"generated AGENTS.md must contain the tool name, got:\n{after}"
);
Command::cargo_bin("skillpack")
.unwrap()
.args(["verify", "--root", ".", "--format", "json"])
.current_dir(&root)
.assert()
.success();
}
#[test]
fn update_idempotent_second_run_writes_zero_files() {
let root = copy_fixture("rust-cli");
Command::new("cargo")
.args(["build", "--quiet"])
.current_dir(&root)
.assert()
.success();
write_skillpack_toml(&root, "sample-rust");
Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--non-interactive",
"--accept-warnings",
"--root",
".",
])
.current_dir(&root)
.assert()
.success();
let first = Command::cargo_bin("skillpack")
.unwrap()
.args(["update", "--root", "."])
.current_dir(&root)
.output()
.unwrap();
assert!(first.status.success(), "first update must succeed");
let stdout = String::from_utf8_lossy(&first.stdout);
assert!(
stdout.contains("updated 0 file(s)"),
"first update with no drift must write 0 files, got:\n{stdout}"
);
let second = Command::cargo_bin("skillpack")
.unwrap()
.args(["update", "--root", "."])
.current_dir(&root)
.output()
.unwrap();
assert!(second.status.success());
let stdout2 = String::from_utf8_lossy(&second.stdout);
assert!(
stdout2.contains("updated 0 file(s)"),
"second update must also write 0 files, got:\n{stdout2}"
);
}
#[test]
fn update_preserves_skill_md_body_and_skips_when_only_body_changed() {
let root = copy_fixture("rust-cli");
Command::new("cargo")
.args(["build", "--quiet"])
.current_dir(&root)
.assert()
.success();
write_skillpack_toml(&root, "sample-rust");
Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--non-interactive",
"--accept-warnings",
"--root",
".",
])
.current_dir(&root)
.assert()
.success();
let skill_path = root.join("skills/sample-rust/SKILL.md");
let original = fs::read_to_string(&skill_path).unwrap();
let sentinel = "UPDATE_BODY_PRESERVATION_SENTINEL_42";
fs::write(
&skill_path,
format!("{original}\n## Custom\n\n{sentinel}\n"),
)
.unwrap();
let out = Command::cargo_bin("skillpack")
.unwrap()
.args(["update", "--root", "."])
.current_dir(&root)
.output()
.unwrap();
assert!(out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("updated 0 file(s)"),
"body-only edit must not trigger an update write, got:\n{stdout}"
);
assert!(
!stdout.contains("SKILL.md"),
"SKILL.md must not appear in updated list"
);
let after = fs::read_to_string(&skill_path).unwrap();
assert!(
after.contains(sentinel),
"body sentinel must survive update, got:\n{after}"
);
}
#[test]
fn update_version_bump_changes_plugin_json_preserves_skill_md_body() {
let root = copy_fixture("rust-cli");
Command::new("cargo")
.args(["build", "--quiet"])
.current_dir(&root)
.assert()
.success();
write_skillpack_toml(&root, "sample-rust");
Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--non-interactive",
"--accept-warnings",
"--root",
".",
])
.current_dir(&root)
.assert()
.success();
let skill_path = root.join("skills/sample-rust/SKILL.md");
let original = fs::read_to_string(&skill_path).unwrap();
let sentinel = "VERSION_BUMP_SENTINEL_99";
fs::write(&skill_path, format!("{original}\n\n{sentinel}\n")).unwrap();
let plugin_path = root.join(".claude-plugin/plugin.json");
let plugin_before = fs::read_to_string(&plugin_path).unwrap();
let cargo_toml_path = root.join("Cargo.toml");
let cargo_toml = fs::read_to_string(&cargo_toml_path).unwrap();
let bumped = cargo_toml.replace("0.1.0", "9.9.9");
fs::write(&cargo_toml_path, bumped).unwrap();
Command::new("cargo")
.args(["build", "--quiet"])
.current_dir(&root)
.assert()
.success();
let out = Command::cargo_bin("skillpack")
.unwrap()
.args(["update", "--root", "."])
.current_dir(&root)
.output()
.unwrap();
assert!(out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("plugin.json"),
"plugin.json must be in updated list, got:\n{stdout}"
);
assert!(
!stdout.contains("SKILL.md"),
"SKILL.md must NOT be in updated list (version not in frontmatter), got:\n{stdout}"
);
let plugin_after = fs::read_to_string(&plugin_path).unwrap();
assert_ne!(
plugin_before, plugin_after,
"plugin.json must change after version bump"
);
assert!(
plugin_after.contains("9.9.9"),
"plugin.json must carry new version, got:\n{plugin_after}"
);
let skill_after = fs::read_to_string(&skill_path).unwrap();
assert!(
skill_after.contains(sentinel),
"body sentinel must survive update after version bump"
);
}
#[test]
fn multi_skill_pack_renders_verifies_and_fixes_secondary_skill() {
let root = copy_fixture("rust-cli");
Command::new("cargo")
.args(["build", "--quiet"])
.current_dir(&root)
.assert()
.success();
Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--root",
".",
"--non-interactive",
"--accept-warnings",
"--description",
"Main skill",
"--trigger",
"main task",
"--author",
"Me",
"--invocation",
"sample-rust --new \"entry\"",
])
.current_dir(&root)
.assert()
.success();
fs::write(
root.join("skillpack.toml"),
format!(
"{}
[[skills]]
name = \"sidekick\"
one_line_description = \"Side skill for aux tasks\"
when_to_use_phrases = [\"aux task\", \"side errand\"]
invocation_command = \"sample-rust side\"
license = \"MIT\"
",
fs::read_to_string(root.join("skillpack.toml")).unwrap()
),
)
.unwrap();
Command::cargo_bin("skillpack")
.unwrap()
.args(["update", "--root", ".", "--target", "all", "--force"])
.current_dir(&root)
.assert()
.success();
assert!(root.join("skills/sidekick/SKILL.md").exists());
assert!(root.join(".codex/skills/sidekick/SKILL.md").exists());
assert!(root.join(".cursor/rules/sidekick.mdc").exists());
assert!(root.join(".opencode/agents/sidekick.md").exists());
let toml = fs::read_to_string(root.join("skillpack.toml")).unwrap();
assert!(
toml.matches("[[skills]]").count() == 2,
"config must normalize to two [[skills]] entries, got:\n{toml}"
);
let side = fs::read_to_string(root.join("skills/sidekick/SKILL.md")).unwrap();
assert!(side.contains("name: sidekick"));
assert!(side.contains("Side skill for aux tasks"));
Command::cargo_bin("skillpack")
.unwrap()
.args(["verify", "--root", "."])
.current_dir(&root)
.assert()
.success()
.stdout(predicate::str::contains("0 failed"));
let side_path = root.join("skills/sidekick/SKILL.md");
let content = fs::read_to_string(&side_path).unwrap();
let stripped: String = content
.lines()
.filter(|l| !l.starts_with("when_to_use:"))
.collect::<Vec<_>>()
.join("\n")
+ "\n# hand body\n";
fs::write(&side_path, stripped).unwrap();
let out = Command::cargo_bin("skillpack")
.unwrap()
.args(["verify", "--root", ".", "--fix"])
.current_dir(&root)
.assert()
.success()
.get_output()
.stdout
.clone();
let s = String::from_utf8_lossy(&out);
assert!(
s.contains("skills/sidekick/SKILL.md"),
"--fix must rewrite the SECONDARY skill, got:\n{s}"
);
let fixed = fs::read_to_string(&side_path).unwrap();
assert!(
fixed.contains("when_to_use: \"aux task, side errand\""),
"sidekick's own triggers must be restored, got:\n{fixed}"
);
assert!(
fixed.contains("# hand body"),
"hand body must survive --fix"
);
assert!(
!fs::read_to_string(root.join("skills/sample-rust/SKILL.md"))
.unwrap()
.contains("# hand body")
);
}
#[test]
fn init_preserves_multi_skill_pack() {
let root = copy_fixture("rust-cli");
Command::new("cargo")
.args(["build", "--quiet"])
.current_dir(&root)
.assert()
.success();
Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--root",
".",
"--non-interactive",
"--accept-warnings",
"--description",
"Main skill",
"--trigger",
"main task",
"--author",
"Me",
"--invocation",
"sample-rust --new \"entry\"",
])
.current_dir(&root)
.assert()
.success();
fs::write(
root.join("skillpack.toml"),
format!(
"{}\n[[skills]]\nname = \"sidekick\"\none_line_description = \"Side skill for aux tasks\"\nwhen_to_use_phrases = [\"aux task\"]\ninvocation_command = \"sample-rust side\"\nlicense = \"MIT\"\n",
fs::read_to_string(root.join("skillpack.toml")).unwrap()
),
)
.unwrap();
Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--root",
".",
"--non-interactive",
"--accept-warnings",
"--target",
"all",
"--force",
])
.current_dir(&root)
.assert()
.success();
let toml = fs::read_to_string(root.join("skillpack.toml")).unwrap();
assert!(
toml.matches("[[skills]]").count() == 2,
"re-init must preserve both skills, got:\n{toml}"
);
assert!(
root.join("skills/sidekick/SKILL.md").exists(),
"sidekick SKILL.md must be rendered by re-init"
);
let side = fs::read_to_string(root.join("skills/sidekick/SKILL.md")).unwrap();
assert!(side.contains("name: sidekick"));
assert!(side.contains("Side skill for aux tasks"));
}
#[test]
fn init_dry_run_writes_nothing() {
let root = copy_fixture("rust-cli");
Command::new("cargo")
.args(["build", "--quiet"])
.current_dir(&root)
.assert()
.success();
let out = Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--root",
".",
"--non-interactive",
"--accept-warnings",
"--dry-run",
"--description",
"Main skill",
"--trigger",
"main task",
"--author",
"Me",
"--invocation",
"sample-rust --new \"entry\"",
"--target",
"all",
])
.current_dir(&root)
.assert()
.success();
let stdout = String::from_utf8_lossy(&out.get_output().stdout);
assert!(
stdout.contains("dry run"),
"dry-run must announce itself, got:\n{stdout}"
);
assert!(!root.join(".claude-plugin/plugin.json").exists());
assert!(!root.join("skills/sample-rust/SKILL.md").exists());
assert!(!root.join("AGENTS.md").exists());
assert!(!root.join("skillpack.toml").exists());
}
#[test]
fn diff_exits_zero_on_clean_repo() {
let root = copy_fixture("rust-cli");
Command::new("cargo")
.args(["build", "--quiet"])
.current_dir(&root)
.assert()
.success();
write_skillpack_toml(&root, "sample-rust");
Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--non-interactive",
"--accept-warnings",
"--root",
".",
])
.current_dir(&root)
.assert()
.success();
let out = Command::cargo_bin("skillpack")
.unwrap()
.args(["diff", "--root", "."])
.current_dir(&root)
.output()
.unwrap();
assert!(out.status.success(), "diff must exit 0 on clean repo");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("up-to-date"),
"clean diff must say up-to-date, got:\n{stdout}"
);
}
#[test]
fn diff_exits_one_after_version_bump_reports_drifted_plugin_json() {
let root = copy_fixture("rust-cli");
Command::new("cargo")
.args(["build", "--quiet"])
.current_dir(&root)
.assert()
.success();
write_skillpack_toml(&root, "sample-rust");
Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--non-interactive",
"--accept-warnings",
"--root",
".",
])
.current_dir(&root)
.assert()
.success();
let cargo_toml = root.join("Cargo.toml");
let raw = fs::read_to_string(&cargo_toml).unwrap();
fs::write(&cargo_toml, raw.replace("0.1.0", "8.8.8")).unwrap();
Command::new("cargo")
.args(["build", "--quiet"])
.current_dir(&root)
.assert()
.success();
let out = Command::cargo_bin("skillpack")
.unwrap()
.args(["diff", "--root", "."])
.current_dir(&root)
.output()
.unwrap();
assert!(
!out.status.success(),
"diff must exit non-zero after version bump"
);
assert_eq!(out.status.code(), Some(1), "diff drift must exit 1");
let combined = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(
combined.contains("drifted") && combined.contains("plugin.json"),
"diff must report plugin.json drift, got:\n{combined}"
);
assert!(
!combined.contains("SKILL.md"),
"SKILL.md must not drift (version not in frontmatter), got:\n{combined}"
);
}
#[test]
fn diff_exits_zero_after_body_only_edit() {
let root = copy_fixture("rust-cli");
Command::new("cargo")
.args(["build", "--quiet"])
.current_dir(&root)
.assert()
.success();
write_skillpack_toml(&root, "sample-rust");
Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--non-interactive",
"--accept-warnings",
"--root",
".",
])
.current_dir(&root)
.assert()
.success();
let skill_path = root.join("skills/sample-rust/SKILL.md");
let original = fs::read_to_string(&skill_path).unwrap();
fs::write(
&skill_path,
format!("{original}\n## Custom\n\nDIFF_BODY_SENTINEL\n"),
)
.unwrap();
let out = Command::cargo_bin("skillpack")
.unwrap()
.args(["diff", "--root", "."])
.current_dir(&root)
.output()
.unwrap();
assert!(
out.status.success(),
"body-only edit must not trigger diff drift (spliced == committed), got:\n{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
}
#[test]
fn verify_format_sarif_is_valid_sarif_2_1_0() {
let root = copy_fixture("rust-cli");
Command::new("cargo")
.args(["build", "--quiet"])
.current_dir(&root)
.assert()
.success();
write_skillpack_toml(&root, "sample-rust");
Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--non-interactive",
"--accept-warnings",
"--root",
".",
])
.current_dir(&root)
.assert()
.success();
let cargo_toml = root.join("Cargo.toml");
let raw = fs::read_to_string(&cargo_toml).unwrap();
fs::write(&cargo_toml, raw.replace("0.1.0", "9.9.9")).unwrap();
Command::new("cargo")
.args(["build", "--quiet"])
.current_dir(&root)
.assert()
.success();
let out = Command::cargo_bin("skillpack")
.unwrap()
.args(["verify", "--format", "sarif", "--root", "."])
.current_dir(&root)
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
let v: serde_json::Value = serde_json::from_str(&stdout).expect("SARIF must be valid JSON");
assert_eq!(
v["$schema"],
"https://json.schemastore.org/sarif-2.1.0.json"
);
assert_eq!(v["version"], "2.1.0");
assert_eq!(v["runs"][0]["tool"]["driver"]["name"], "skillpack");
let results = v["runs"][0]["results"].as_array().unwrap();
assert!(
!results.is_empty(),
"SARIF must contain at least one result"
);
for r in results {
assert!(r["ruleId"].is_string(), "every result needs ruleId");
assert!(
r["level"] == "warning" || r["level"] == "error",
"level must be warning or error"
);
assert!(
r["message"]["text"].is_string(),
"every result needs message.text"
);
}
for r in results {
assert_ne!(r["level"], "none", "SARIF must not emit pass/skip results");
}
assert!(
results
.iter()
.any(|r| r["ruleId"] == "discovery.plugin.version_drift"),
"SARIF must include version_drift result"
);
}
#[test]
fn init_template_dir_overrides_skill_md_body() {
let root = copy_fixture("rust-cli");
Command::new("cargo")
.args(["build", "--quiet"])
.current_dir(&root)
.assert()
.success();
write_skillpack_toml(&root, "sample-rust");
let tmpl_dir = root.join(".custom-templates");
fs::create_dir_all(&tmpl_dir).unwrap();
fs::write(
tmpl_dir.join("SKILL.md.tera"),
"---\nname: {{ name }}\ndescription: \"{{ one_line_description }}\"\nwhen_to_use: \"{{ when_concat }}\"\n---\n\nCUSTOM_TEMPLATE_SENTINEL\n",
)
.unwrap();
Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--non-interactive",
"--accept-warnings",
"--root",
".",
"--template-dir",
".custom-templates",
])
.current_dir(&root)
.assert()
.success();
let skill = fs::read_to_string(root.join("skills/sample-rust/SKILL.md")).unwrap();
assert!(
skill.contains("CUSTOM_TEMPLATE_SENTINEL"),
"custom template must override embedded SKILL.md, got:\n{skill}"
);
}
#[test]
fn init_template_dir_missing_files_fall_back_to_embedded() {
let root = copy_fixture("rust-cli");
Command::new("cargo")
.args(["build", "--quiet"])
.current_dir(&root)
.assert()
.success();
write_skillpack_toml(&root, "sample-rust");
let tmpl_dir = root.join(".custom-templates");
fs::create_dir_all(&tmpl_dir).unwrap();
fs::write(
tmpl_dir.join("plugin.json.tera"),
"{\n \"name\": \"{{ name }}\",\n \"version\": \"{{ version }}\",\n \"license\": \"{{ license }}\"\n}\n",
)
.unwrap();
Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--non-interactive",
"--accept-warnings",
"--root",
".",
"--template-dir",
".custom-templates",
])
.current_dir(&root)
.assert()
.success();
let plugin = fs::read_to_string(root.join(".claude-plugin/plugin.json")).unwrap();
assert!(
plugin.contains("\"name\": \"sample-rust\""),
"custom plugin.json must render, got: {plugin}"
);
let skill = fs::read_to_string(root.join("skills/sample-rust/SKILL.md")).unwrap();
assert!(
!skill.is_empty(),
"embedded template fallback must produce non-empty SKILL.md"
);
}
#[test]
fn target_list_prints_canonical_values() {
let root = copy_fixture("rust-cli");
let out = Command::cargo_bin("skillpack")
.unwrap()
.args(["init", "--target", "list", "--root", "."])
.current_dir(&root)
.output()
.unwrap();
assert!(
out.status.success(),
"--target list must exit 0, got:\n{}",
String::from_utf8_lossy(&out.stdout)
);
let s = String::from_utf8_lossy(&out.stdout);
for name in [
"claude", "cursor", "codex", "cline", "roo", "kilo", "goose", "all",
] {
assert!(
s.contains(name),
"--target list must name `{name}`, got:\n{s}"
);
}
assert!(!root.join("skillpack.toml").exists());
}
#[test]
fn init_format_json_emits_machine_readable_summary() {
let root = copy_fixture("rust-cli");
write_skillpack_toml(&root, "sample-rust");
let out = Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--root",
".",
"--non-interactive",
"--accept-warnings",
"--format",
"json",
])
.current_dir(&root)
.output()
.unwrap();
assert!(
out.status.success(),
"init --format json must exit 0, got:\n{}",
String::from_utf8_lossy(&out.stdout)
);
let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(v["command"], "init");
assert_eq!(v["dry_run"], false);
assert!(
v["written"].as_array().is_some_and(|a| !a.is_empty()),
"written must be a non-empty array, got: {v}"
);
}
#[test]
fn add_skill_appends_second_skill_to_existing_pack() {
let root = copy_fixture("rust-cli");
Command::new("cargo")
.args(["build", "--quiet"])
.current_dir(&root)
.assert()
.success();
write_skillpack_toml(&root, "sample-rust");
Command::cargo_bin("skillpack")
.unwrap()
.args([
"init",
"--root",
".",
"--non-interactive",
"--accept-warnings",
])
.current_dir(&root)
.assert()
.success();
Command::cargo_bin("skillpack")
.unwrap()
.args([
"add",
"sidekick",
"--root",
".",
"--non-interactive",
"--description",
"Handle auxiliary chores",
"--trigger",
"aux task",
"--invocation",
"sample-rust sidekick",
])
.current_dir(&root)
.assert()
.success();
assert!(root.join("skills/sample-rust/SKILL.md").exists());
assert!(root.join("skills/sidekick/SKILL.md").exists());
assert!(root.join(".claude/skills/sidekick/SKILL.md").exists());
let cfg = fs::read_to_string(root.join("skillpack.toml")).unwrap();
assert!(
cfg.contains("sidekick"),
"skillpack.toml must list the new skill, got:\n{cfg}"
);
assert!(
cfg.contains("[[skills]]"),
"multi-skill pack must serialize as [[skills]], got:\n{cfg}"
);
Command::cargo_bin("skillpack")
.unwrap()
.args(["verify", "--root", ".", "--format", "json"])
.current_dir(&root)
.assert()
.success();
}