shepherd-cli 6.7.8

Small installed-plugin diagnostics for the Shepherd workflow
use std::path::{Path, PathBuf};
use std::process::Command;

fn checkout() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("../..")
        .canonicalize()
        .unwrap()
}

fn run(host: &str, root: &Path) -> std::process::Output {
    Command::new(env!("CARGO_BIN_EXE_shepherd"))
        .args(["doctor", "--host", host, "--plugin-root"])
        .arg(root)
        .arg("--json")
        .output()
        .unwrap()
}

#[test]
fn actual_source_carriers_are_diagnosable_without_runtime() {
    let root = checkout();
    for (host, path) in [
        ("claude", root.clone()),
        ("codex", root.join("plugins/shepherd")),
        ("claude", root.join("packages/harness-claude")),
        ("codex", root.join("packages/harness-codex")),
        ("pi", root.join("packages/harness-pi")),
    ] {
        let output = run(host, &path);
        assert!(
            output.status.success(),
            "{host}: {}",
            String::from_utf8_lossy(&output.stdout)
        );
        let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
        assert_eq!(report["ok"], true, "{host}: {report}");
        assert_eq!(report["helper_version"], env!("CARGO_PKG_VERSION"));
        assert_eq!(report["plugin_version"], env!("CARGO_PKG_VERSION"));
        if host == "pi" {
            assert!(
                report["warnings"]
                    .as_array()
                    .unwrap()
                    .iter()
                    .any(|warning| {
                        warning
                            .as_str()
                            .unwrap()
                            .contains("separately verified dispatch extension")
                    })
            );
            assert!(!report["findings"].to_string().contains("pi.subagents"));
        }
    }
}

#[test]
fn corrupt_manifest_and_escaping_declared_path_are_rejected() {
    let temp = tempfile::tempdir().unwrap();
    let claude = temp.path().join("claude");
    std::fs::create_dir_all(claude.join(".claude-plugin")).unwrap();
    std::fs::write(claude.join(".claude-plugin/plugin.json"), "{").unwrap();
    let corrupt = run("claude", &claude);
    assert!(!corrupt.status.success());
    assert!(String::from_utf8_lossy(&corrupt.stdout).contains("not valid JSON"));

    let codex = temp.path().join("codex");
    std::fs::create_dir_all(codex.join(".codex-plugin")).unwrap();
    std::fs::write(codex.join(".codex-plugin/plugin.json"),
        serde_json::json!({"name":"shepherd","version":env!("CARGO_PKG_VERSION"),"skills":"../outside","hooks":"../outside/hooks.json"}).to_string()).unwrap();
    let escaped = run("codex", &codex);
    assert!(!escaped.status.success());
    assert!(String::from_utf8_lossy(&escaped.stdout).contains("must stay inside plugin root"));
}

#[test]
fn corrupt_installed_skill_is_not_silently_skipped() {
    let temp = tempfile::tempdir().unwrap();
    std::fs::write(
        temp.path().join("package.json"),
        serde_json::json!({"name":"@pzzld/claude-shepherd","version":env!("CARGO_PKG_VERSION")})
            .to_string(),
    )
    .unwrap();
    let skill = temp.path().join("skills/shepherd/SKILL.md");
    std::fs::create_dir_all(skill.parent().unwrap()).unwrap();
    std::fs::write(&skill, [0xff, 0xfe]).unwrap();
    let result = run("claude", temp.path());
    assert!(!result.status.success());
    assert!(String::from_utf8_lossy(&result.stdout).contains("installed skill is not UTF-8"));
}

#[test]
#[cfg(unix)]
fn installed_hook_must_be_registered_and_executable() {
    use std::os::unix::fs::PermissionsExt;
    let temp = tempfile::tempdir().unwrap();
    std::fs::create_dir_all(temp.path().join(".claude-plugin")).unwrap();
    std::fs::create_dir_all(temp.path().join("hooks/scripts")).unwrap();
    std::fs::write(
        temp.path().join(".claude-plugin/plugin.json"),
        serde_json::json!({"name":"shepherd","version":env!("CARGO_PKG_VERSION")}).to_string(),
    )
    .unwrap();
    std::fs::write(
        temp.path().join("hooks/hooks.json"),
        r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"bash hooks/scripts/seed_preflight_check.sh","timeout":5}]}]}}"#,
    )
    .unwrap();
    let script = temp.path().join("hooks/scripts/seed_preflight_check.sh");
    std::fs::write(&script, "#!/bin/sh\nexit 0\n").unwrap();
    std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o644)).unwrap();
    let result = run("claude", temp.path());
    assert!(!result.status.success());
    let text = String::from_utf8_lossy(&result.stdout);
    assert!(text.contains("does not register the bounded Write seed check"));
    assert!(text.contains("installed hook is not executable"));
}

#[test]
fn extra_event_duplicate_hook_and_echo_only_command_are_not_healthy() {
    let temp = tempfile::tempdir().unwrap();
    std::fs::create_dir_all(temp.path().join(".claude-plugin")).unwrap();
    std::fs::create_dir_all(temp.path().join("hooks/scripts")).unwrap();
    std::fs::write(
        temp.path().join(".claude-plugin/plugin.json"),
        serde_json::json!({"name":"shepherd","version":env!("CARGO_PKG_VERSION")}).to_string(),
    )
    .unwrap();
    std::fs::write(
        temp.path().join("hooks/scripts/seed_preflight_check.sh"),
        "#!/bin/sh\nexit 0\n",
    )
    .unwrap();
    let expected = serde_json::json!({
        "description": "focused seed check",
        "hooks": {"PreToolUse": [{"matcher": "Write", "hooks": [{
            "type": "command",
            "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scripts/seed_preflight_check.sh\"",
            "timeout": 5
        }]}]}
    });
    let mut extra_event = expected.clone();
    extra_event["hooks"]["SessionStart"] = serde_json::json!([]);
    let mut duplicate = expected.clone();
    duplicate["hooks"]["PreToolUse"][0]["hooks"]
        .as_array_mut()
        .unwrap()
        .push(expected["hooks"]["PreToolUse"][0]["hooks"][0].clone());
    let mut echo_only = expected;
    echo_only["hooks"]["PreToolUse"][0]["hooks"][0]["command"] =
        serde_json::json!("echo hooks/scripts/seed_preflight_check.sh");
    for candidate in [extra_event, duplicate, echo_only] {
        std::fs::write(
            temp.path().join("hooks/hooks.json"),
            serde_json::to_vec(&candidate).unwrap(),
        )
        .unwrap();
        let output = run("claude", temp.path());
        let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
        assert!(
            report["findings"]
                .as_array()
                .unwrap()
                .iter()
                .any(|finding| {
                    finding
                        .as_str()
                        .unwrap()
                        .contains("does not register the bounded Write seed check")
                }),
            "hook false positive for {candidate}"
        );
    }
}

#[test]
fn root_only_roles_are_not_registered_as_children() {
    let temp = tempfile::tempdir().unwrap();
    let claude = temp.path().join("claude");
    std::fs::create_dir_all(claude.join(".claude-plugin")).unwrap();
    std::fs::create_dir_all(claude.join("agents")).unwrap();
    std::fs::write(
        claude.join(".claude-plugin/plugin.json"),
        serde_json::json!({"name":"shepherd","version":env!("CARGO_PKG_VERSION")}).to_string(),
    )
    .unwrap();
    std::fs::write(claude.join("agents/shepherd.md"), "root only").unwrap();
    let claude_output = run("claude", &claude);
    assert!(
        String::from_utf8_lossy(&claude_output.stdout)
            .contains("unexpected child agent: shepherd.md")
    );

    let pi = temp.path().join("pi");
    std::fs::create_dir_all(pi.join("agents")).unwrap();
    std::fs::write(pi.join("package.json"), serde_json::json!({"name":"@pzzld/pi-shepherd","version":env!("CARGO_PKG_VERSION"),"pi":{"skills":["./skills"],"prompts":["./prompts"]}}).to_string()).unwrap();
    std::fs::write(pi.join("agents/planter.md"), "root-local only").unwrap();
    let pi_output = run("pi", &pi);
    assert!(
        String::from_utf8_lossy(&pi_output.stdout).contains("unexpected child agent: planter.md")
    );
}

#[test]
fn missing_optional_jq_is_warning_not_blanket_failure() {
    let temp = tempfile::tempdir().unwrap();
    std::fs::write(temp.path().join("jq"), "not executable").unwrap();
    let output = Command::new(env!("CARGO_BIN_EXE_shepherd"))
        .args(["doctor", "--host", "claude", "--plugin-root"])
        .arg(checkout())
        .arg("--json")
        .env("PATH", temp.path())
        .output()
        .unwrap();
    assert!(
        output.status.success(),
        "{}",
        String::from_utf8_lossy(&output.stdout)
    );
    let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
    assert_eq!(report["ok"], true);
    assert!(
        report["warnings"]
            .as_array()
            .unwrap()
            .iter()
            .any(|warning| { warning.as_str().unwrap().contains("jq is absent") })
    );
}

#[test]
fn wrong_version_is_actionable_and_read_only() {
    let temp = tempfile::tempdir().unwrap();
    let manifest = temp.path().join(".claude-plugin/plugin.json");
    std::fs::create_dir_all(manifest.parent().unwrap()).unwrap();
    std::fs::write(&manifest, r#"{"name":"shepherd","version":"6.7.3"}"#).unwrap();
    let before = std::fs::read(&manifest).unwrap();
    let output = run("claude", temp.path());
    assert!(!output.status.success());
    let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
    assert!(
        report["findings"]
            .as_array()
            .unwrap()
            .iter()
            .any(|finding| {
                finding.as_str().unwrap().contains("6.7.3")
                    && finding
                        .as_str()
                        .unwrap()
                        .contains(env!("CARGO_PKG_VERSION"))
            })
    );
    assert_eq!(std::fs::read(&manifest).unwrap(), before);
}

#[test]
fn absent_plugin_and_relative_root_are_failures() {
    let temp = tempfile::tempdir().unwrap();
    let missing = run("pi", temp.path());
    assert!(!missing.status.success());
    let report: serde_json::Value = serde_json::from_slice(&missing.stdout).unwrap();
    assert_eq!(report["ok"], false);
    assert!(!report["findings"].as_array().unwrap().is_empty());

    let relative = run("claude", Path::new("relative-plugin"));
    assert!(!relative.status.success());
    let report: serde_json::Value = serde_json::from_slice(&relative.stdout).unwrap();
    assert!(
        report["findings"]
            .as_array()
            .unwrap()
            .iter()
            .any(|finding| { finding.as_str().unwrap().contains("absolute") })
    );
}