shepherd-cli 6.4.5

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

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

fn fixture(label: &str) -> PathBuf {
    let nonce = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .expect("clock")
        .as_nanos();
    let root = std::env::temp_dir().join(format!(
        "shepherd-compile-{label}-{}-{nonce:x}",
        std::process::id()
    ));
    fs::create_dir_all(&root).expect("fixture");
    root
}

fn run(cwd: &Path, args: &[&str]) -> Output {
    Command::new(binary())
        .args(args)
        .current_dir(cwd)
        .output()
        .expect("run shepherd")
}

fn copy_tree(source: &Path, destination: &Path) {
    fs::create_dir_all(destination).expect("copy destination");
    for entry in fs::read_dir(source).expect("copy source") {
        let entry = entry.expect("copy entry");
        let target = destination.join(entry.file_name());
        if entry.file_type().expect("copy type").is_dir() {
            copy_tree(&entry.path(), &target);
        } else {
            fs::copy(entry.path(), target).expect("copy file");
        }
    }
}

#[test]
fn standalone_binary_compiles_embedded_content_without_a_checkout() {
    let root = fixture("embedded");
    let copied = root.join("shepherd");
    fs::copy(binary(), &copied).expect("copy binary");
    let output = Command::new(&copied)
        .args(["compile", "--target", "claude"])
        .current_dir(&root)
        .output()
        .expect("run copied binary");
    assert!(
        output.status.success(),
        "stderr={}",
        String::from_utf8_lossy(&output.stderr)
    );
    let manifest: serde_json::Value = serde_json::from_slice(&output.stdout).expect("manifest");
    assert_eq!(manifest["schema"], "shepherd.compiled-tree/2");
    assert_eq!(manifest["target"], "claude");
    assert_eq!(manifest["files"].as_array().expect("files").len(), 16);
    assert_eq!(manifest["roles"].as_array().expect("roles").len(), 9);
    let coder = manifest["roles"]
        .as_array()
        .expect("roles")
        .iter()
        .find(|role| role["role"] == "coder")
        .expect("coder role contract");
    assert_eq!(coder["carrier_path"], "agents/coder.md");
    assert_eq!(coder["model"], "sonnet");
    assert!(coder["profile"].is_null());
    assert!(
        coder["tools"]
            .as_array()
            .expect("tools")
            .iter()
            .any(|tool| tool == "Write")
    );
    assert!(output.stderr.is_empty());
    fs::remove_dir_all(root).expect("cleanup");
}

#[test]
fn materialize_is_reproducible_and_check_is_read_only() {
    let root = fixture("materialize");
    let output_root = root.join("codex");
    let output = run(
        &root,
        &[
            "compile",
            "--target",
            "codex",
            "--out",
            output_root.to_str().expect("output path"),
        ],
    );
    assert!(
        output.status.success(),
        "stderr={}",
        String::from_utf8_lossy(&output.stderr)
    );
    assert!(output.stdout.is_empty());
    assert!(output_root.join(".shepherd-generated.json").is_file());
    assert!(output_root.join("shepherd.codex.toml").is_file());

    let check = run(
        &root,
        &[
            "compile",
            "--target",
            "codex",
            "--out",
            output_root.to_str().expect("output path"),
            "--check",
        ],
    );
    assert!(
        check.status.success(),
        "stderr={}",
        String::from_utf8_lossy(&check.stderr)
    );
    assert!(check.stdout.is_empty());
    assert!(check.stderr.is_empty());

    let generated = output_root.join("skills/thinking/SKILL.md");
    let original = fs::read(&generated).expect("generated skill");
    fs::write(&generated, b"hand edited\n").expect("drift");
    let drift = run(
        &root,
        &[
            "compile",
            "--target",
            "codex",
            "--out",
            output_root.to_str().expect("output path"),
            "--check",
        ],
    );
    assert_eq!(drift.status.code(), Some(1));
    assert!(drift.stdout.is_empty());
    assert!(String::from_utf8_lossy(&drift.stderr).contains("generated file drift"));
    assert_eq!(
        fs::read(&generated).expect("preserved drift"),
        b"hand edited\n"
    );

    let overwrite = run(
        &root,
        &[
            "compile",
            "--target",
            "codex",
            "--out",
            output_root.to_str().expect("output path"),
        ],
    );
    assert_eq!(overwrite.status.code(), Some(1));
    assert_eq!(
        fs::read(&generated).expect("still preserved"),
        b"hand edited\n"
    );

    fs::write(&generated, original).expect("restore generated file");
    let replay = run(
        &root,
        &[
            "compile",
            "--target",
            "codex",
            "--out",
            output_root.to_str().expect("output path"),
        ],
    );
    assert!(replay.status.success());
    fs::remove_dir_all(root).expect("cleanup");
}

#[test]
fn materialize_refuses_unowned_targets_and_symlink_roots() {
    let root = fixture("ownership");
    let output_root = root.join("unowned");
    fs::create_dir_all(&output_root).expect("unowned root");
    fs::write(output_root.join("shepherd.codex.toml"), b"user file\n").expect("user file");
    let unowned = run(
        &root,
        &[
            "compile",
            "--target",
            "codex",
            "--out",
            output_root.to_str().expect("output path"),
        ],
    );
    assert_eq!(unowned.status.code(), Some(1));
    assert!(String::from_utf8_lossy(&unowned.stderr).contains("not owned"));
    assert_eq!(
        fs::read(output_root.join("shepherd.codex.toml")).expect("user file"),
        b"user file\n"
    );

    #[cfg(unix)]
    {
        let real = root.join("real");
        fs::create_dir_all(&real).expect("real root");
        let linked = root.join("linked");
        std::os::unix::fs::symlink(&real, &linked).expect("symlink root");
        let linked_output = run(
            &root,
            &[
                "compile",
                "--target",
                "codex",
                "--out",
                linked.to_str().expect("linked path"),
            ],
        );
        assert_eq!(linked_output.status.code(), Some(1));
        assert!(real.read_dir().expect("real directory").next().is_none());
    }
    fs::remove_dir_all(root).expect("cleanup");
}

#[test]
fn stale_generated_files_are_removed_only_with_intact_prior_provenance() {
    let root = fixture("stale");
    let authored = root.join("content");
    copy_tree(
        &PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../content"),
        &authored,
    );
    let output_root = root.join("claude");
    let common = [
        "compile",
        "--target",
        "claude",
        "--out",
        output_root.to_str().expect("output path"),
        "--content-dir",
        authored.to_str().expect("content path"),
    ];
    let initial = run(&root, &common);
    assert!(
        initial.status.success(),
        "stderr={}",
        String::from_utf8_lossy(&initial.stderr)
    );
    let stale = output_root.join("skills/adaptation/SKILL.md");
    assert!(stale.is_file());
    fs::remove_dir_all(authored.join("skills/adaptation")).expect("remove authored skill");
    fs::write(&stale, b"operator change\n").expect("drift stale file");

    let refused = run(&root, &common);
    assert_eq!(refused.status.code(), Some(1));
    assert_eq!(
        fs::read(&stale).expect("preserved stale drift"),
        b"operator change\n"
    );

    let original = embedded_skill("adaptation");
    fs::write(&stale, original).expect("restore generated stale file");
    let updated = run(&root, &common);
    assert!(
        updated.status.success(),
        "stderr={}",
        String::from_utf8_lossy(&updated.stderr)
    );
    assert!(!stale.exists());
    assert!(!output_root.join("skills/adaptation").exists());
    let manifest: serde_json::Value = serde_json::from_slice(
        &fs::read(output_root.join(".shepherd-generated.json")).expect("manifest"),
    )
    .expect("manifest JSON");
    assert_eq!(manifest["files"].as_array().expect("files").len(), 15);
    fs::remove_dir_all(root).expect("cleanup");
}

fn embedded_skill(name: &str) -> Vec<u8> {
    let root = fixture("expected-skill");
    let output = root.join("claude");
    let result = run(
        &root,
        &[
            "compile",
            "--target",
            "claude",
            "--out",
            output.to_str().expect("output path"),
        ],
    );
    assert!(result.status.success());
    let bytes = fs::read(output.join(format!("skills/{name}/SKILL.md"))).expect("embedded skill");
    fs::remove_dir_all(root).expect("cleanup expected skill");
    bytes
}

#[test]
fn removed_legacy_commands_never_fall_through_to_another_cli() {
    let root = fixture("retired-command");
    let output = run(&root, &["style", "show"]);
    assert_eq!(output.status.code(), Some(1));
    assert!(output.stdout.is_empty());
    assert_eq!(
        String::from_utf8(output.stderr).expect("UTF-8 stderr"),
        "ERROR: command `style` is unavailable in the canonical Rust CLI; legacy Python, Bash, and Node command authorities are retired\n"
    );
    fs::remove_dir_all(root).expect("cleanup");
}