shepherd-cli 6.7.1

The canonical shepherd command-line interface over the per-project registry, run artifacts, and sprint pipeline.
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};

fn binary() -> &'static str {
    env!("CARGO_BIN_EXE_shepherd")
}

struct Fixture {
    _guard: tempfile::TempDir,
    root: PathBuf,
}

impl Fixture {
    fn new() -> Self {
        let guard = tempfile::tempdir().expect("fixture");
        let root = std::fs::canonicalize(guard.path()).expect("canonical fixture root");
        std::fs::create_dir_all(root.join(".shepherd")).expect("native context");
        Self {
            _guard: guard,
            root,
        }
    }

    fn run(&self, args: &[&str], input: &[u8]) -> Output {
        let mut child = Command::new(binary())
            .args(args)
            .current_dir(&self.root)
            .env("SHEPHERD_HOME", self.root.join("home"))
            .stdin(Stdio::piped())
            .stdout(Stdio::piped())
            .stderr(Stdio::piped())
            .spawn()
            .expect("spawn shepherd");
        child
            .stdin
            .take()
            .expect("stdin")
            .write_all(input)
            .expect("write stdin");
        child.wait_with_output().expect("wait shepherd")
    }
}

#[cfg(unix)]
fn mode(path: &Path, value: u32) {
    use std::os::unix::fs::PermissionsExt;
    std::fs::set_permissions(path, std::fs::Permissions::from_mode(value)).expect("mode");
}

#[cfg(not(unix))]
fn mode(_path: &Path, _value: u32) {}

#[test]
fn campaign_bundle_hash_is_canonical_and_rendered() {
    let fixture = Fixture::new();
    let skill = fixture.root.join("skill");
    std::fs::create_dir_all(skill.join("references")).expect("references");
    std::fs::write(skill.join("SKILL.md"), b"# skill\n").expect("skill");
    std::fs::write(skill.join("references/guide.md"), b"guide\n").expect("guide");
    mode(&skill.join("SKILL.md"), 0o644);
    mode(&skill.join("references/guide.md"), 0o644);
    let render = fixture.root.join("render.txt");
    let output = fixture.run(
        &[
            "eval",
            "campaign",
            "bundle",
            skill.to_str().unwrap(),
            "--render",
            render.to_str().unwrap(),
        ],
        b"",
    );
    assert!(
        output.status.success(),
        "{}",
        String::from_utf8_lossy(&output.stderr)
    );
    let metadata: serde_json::Value = serde_json::from_slice(&output.stdout).expect("metadata");
    assert_eq!(metadata["schema"], "shepherd.skill-bundle/1");
    assert_eq!(metadata["files"].as_array().unwrap().len(), 2);
    assert_eq!(
        std::fs::read_to_string(render).unwrap(),
        "=== SKILL.md (0644) ===\n# skill\n=== references/guide.md (0644) ===\nguide\n"
    );
}

#[test]
fn campaign_canonicalize_and_hash_reject_non_json_and_sort_keys() {
    let fixture = Fixture::new();
    let source = fixture.root.join("source.json");
    let canonical = fixture.root.join("canonical.json");
    std::fs::write(&source, br#"{"z":1,"a":{"y":2,"x":3}}"#).expect("source");
    mode(&source, 0o600);
    let output = fixture.run(
        &[
            "eval",
            "campaign",
            "canonicalize",
            source.to_str().unwrap(),
            canonical.to_str().unwrap(),
        ],
        b"",
    );
    assert!(
        output.status.success(),
        "{}",
        String::from_utf8_lossy(&output.stderr)
    );
    assert_eq!(
        std::fs::read(&canonical).unwrap(),
        b"{\"a\":{\"x\":3,\"y\":2},\"z\":1}"
    );
    let hashed = fixture.run(&["eval", "campaign", "hash", source.to_str().unwrap()], b"");
    assert!(hashed.status.success());
    assert_eq!(String::from_utf8_lossy(&hashed.stdout).trim().len(), 64);
    let invalid = fixture.root.join("invalid.json");
    std::fs::write(&invalid, b"NaN").expect("invalid");
    mode(&invalid, 0o600);
    let rejected = fixture.run(
        &["eval", "campaign", "hash", invalid.to_str().unwrap()],
        b"",
    );
    assert!(!rejected.status.success());
    assert!(String::from_utf8_lossy(&rejected.stderr).contains("invalid JSON"));
}

#[cfg(unix)]
#[test]
fn campaign_provider_inspection_preserves_alias_and_rejects_unsafe_auth() {
    let fixture = Fixture::new();
    let target = fixture.root.join("versions/v1/claude");
    let alias = fixture.root.join("bin/claude");
    std::fs::create_dir_all(target.parent().unwrap()).expect("target parent");
    std::fs::create_dir_all(alias.parent().unwrap()).expect("alias parent");
    std::fs::write(&target, b"#!/bin/sh\n").expect("provider");
    mode(&target, 0o755);
    std::os::unix::fs::symlink(&target, &alias).expect("alias");
    let provider = fixture.run(
        &[
            "eval",
            "campaign",
            "inspect-provider",
            alias.to_str().unwrap(),
        ],
        b"",
    );
    assert!(
        provider.status.success(),
        "{}",
        String::from_utf8_lossy(&provider.stderr)
    );
    let identity: serde_json::Value = serde_json::from_slice(&provider.stdout).expect("identity");
    assert_eq!(identity["alias"]["kind"], "symlink");
    assert_eq!(identity["target_path"], target.to_str().unwrap());
    std::fs::rename(&target, target.with_extension("old")).expect("move provider target");
    std::fs::copy(target.with_extension("old"), &target).expect("replace provider target");
    mode(&target, 0o755);
    let replaced = fixture.run(
        &[
            "eval",
            "campaign",
            "inspect-provider",
            alias.to_str().unwrap(),
        ],
        b"",
    );
    assert!(replaced.status.success());
    let replaced_identity: serde_json::Value =
        serde_json::from_slice(&replaced.stdout).expect("replaced identity");
    assert_ne!(
        identity["target"]["ino"],
        replaced_identity["target"]["ino"]
    );
    let auth = fixture.root.join("auth.json");
    std::fs::write(&auth, br#"{"claudeAiOauth":{"accessToken":"fixture"}}"#).expect("auth");
    mode(&auth, 0o644);
    let rejected = fixture.run(
        &[
            "eval",
            "campaign",
            "inspect-auth",
            auth.to_str().unwrap(),
            "session",
        ],
        b"",
    );
    assert!(!rejected.status.success());
    assert!(String::from_utf8_lossy(&rejected.stderr).contains("0600"));
}