use std::fs;
use std::path::{Path, PathBuf};
use assert_cmd::Command;
use predicates::prelude::*;
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();
}
#[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_skillpack_toml_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()
.failure() .stderr(predicate::str::contains("no skillpack.toml found"));
assert!(!root.join(".claude-plugin").exists());
}
#[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.")));
}
#[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_warns_invocation_only_checked_first() {
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.multi_cli") || s.contains("only run against the first"),
"expected a multi-CLI invocation warning, 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::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; runs on CI via --include-ignored"]
fn jvm_cli_init_then_verify_round_trip() {
if !jvm_available() {
eprintln!("skipped: sh not on PATH");
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");
}
#[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 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 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"));
}
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}"
);
}
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_five_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",
"claude",
"--target",
"cursor",
"--target",
"codex",
"--target",
"opencode",
"--target",
"copilot",
"--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());
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",
];
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_five_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"),
];
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}"
);
}