use clap::{Parser, Subcommand, ValueEnum};
use minijinja::{AutoEscape, Environment, UndefinedBehavior};
use serde::Serialize;
use serde_json::Value;
use std::fs;
use std::io::{BufRead, BufReader, Read, Write};
use std::path::{Component, Path, PathBuf};
use std::process::{ChildStdout, Command as GitCommand, Stdio};
const VERSION: &str = env!("CARGO_PKG_VERSION");
const PORTABLE_SCHEMA: &str = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json";
const MAX_FILE_BYTES: u64 = 128 * 1024;
const MAX_RENDERED_BYTES: usize = 1024 * 1024;
const MAX_GIT_RECORD_BYTES: u64 = 1024 * 1024;
const ROLES: [&str; 9] = [
"auditor",
"coder",
"conductor",
"critic",
"discovery",
"engineer",
"planter",
"shepherd",
"worker",
];
const CHILD_ROLES: [&str; 7] = [
"auditor",
"coder",
"conductor",
"critic",
"discovery",
"engineer",
"worker",
];
const SKILLS: [&str; 15] = [
"artifact-work",
"bridge",
"debugging",
"gitflow",
"harness",
"herd",
"implementing",
"lane-execution",
"planning",
"planting",
"researching",
"reviewing",
"spawn",
"using-shepherd",
"verification",
];
#[derive(Parser)]
#[command(
name = "shepherd",
version,
about = "Focused workspace setup, diagnostics, and handoff artifacts"
)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Init {
#[arg(long, default_value = ".")]
root: PathBuf,
},
Lane {
#[arg(long, default_value = ".")]
root: PathBuf,
#[arg(long)]
run: String,
#[arg(long)]
name: String,
},
LaneCheck {
#[arg(long, default_value = ".")]
root: PathBuf,
#[arg(long)]
worktree: PathBuf,
#[arg(long)]
common_dir: PathBuf,
#[arg(long)]
branch: String,
#[arg(long)]
base_ref: String,
#[arg(long)]
base_sha: String,
#[arg(long)]
head: Option<String>,
#[arg(long)]
require_clean: bool,
},
Doctor {
#[arg(long, value_enum)]
host: Host,
#[arg(long)]
plugin_root: PathBuf,
#[arg(long)]
json: bool,
},
Handoff {
#[arg(long, value_enum)]
host: Host,
#[arg(long)]
plugin_root: PathBuf,
#[arg(long)]
context: PathBuf,
#[arg(long)]
output: Option<PathBuf>,
},
}
#[derive(Clone, Copy, ValueEnum, Serialize)]
#[serde(rename_all = "lowercase")]
enum Host {
Claude,
Codex,
Pi,
}
#[derive(Serialize)]
struct Report {
schema: &'static str,
host: Host,
plugin_root: String,
helper_path: Option<String>,
helper_version: &'static str,
plugin_version: Option<String>,
ok: bool,
findings: Vec<String>,
warnings: Vec<String>,
}
fn main() {
let cli = Cli::parse();
match cli.command {
Command::Init { root } => {
if let Err(error) = init_workspace(&root) {
eprintln!("shepherd init: {error}");
std::process::exit(1);
}
}
Command::Lane { root, run, name } => {
if let Err(error) = init_lane(&root, &run, &name) {
eprintln!("shepherd lane: {error}");
std::process::exit(1);
}
}
Command::LaneCheck {
root,
worktree,
common_dir,
branch,
base_ref,
base_sha,
head,
require_clean,
} => {
let report = lane_check(
&root,
&worktree,
&common_dir,
&branch,
&base_ref,
&base_sha,
head.as_deref(),
require_clean,
);
println!(
"{}",
serde_json::to_string_pretty(&report).expect("report serialization")
);
if !report.ok {
std::process::exit(1);
}
}
Command::Doctor {
host,
plugin_root,
json,
} => {
let report = diagnose(host, &plugin_root);
if json {
println!(
"{}",
serde_json::to_string_pretty(&report).expect("report serialization")
);
} else {
println!(
"Shepherd doctor: {}",
if report.ok { "ok" } else { "failed" }
);
println!("host: {}", host_name(host));
println!("plugin: {}", report.plugin_root);
println!(
"helper: {} ({VERSION})",
report.helper_path.as_deref().unwrap_or("unknown")
);
for finding in &report.findings {
println!("ERROR: {finding}");
}
for warning in &report.warnings {
println!("WARN: {warning}");
}
}
if !report.ok {
std::process::exit(1);
}
}
Command::Handoff {
host,
plugin_root,
context,
output,
} => {
if let Err(error) = handoff(host, &plugin_root, &context, output.as_deref()) {
eprintln!("shepherd handoff: {error}");
std::process::exit(1);
}
}
}
}
const WORKSPACE_IGNORE: &str = "# Shepherd local state\n*\n!.gitignore\n!runs/\n!runs/*/\n!runs/*/*.md\n!runs/*/reports/\n!runs/*/reports/*.md\n!runs/*/audits/\n!runs/*/audits/*.md\n!runs/*/lanes/\n!runs/*/lanes/*/\n!runs/*/lanes/*/*.md\n!runs/*/lanes/*/evidence/\n!runs/*/lanes/*/evidence/*.md\n!runs/*/lanes/*/evidence/*.json\n!runs/*/lanes/*/evidence/*.txt\n!runs/*/lanes/*/evidence/*.sh\n";
fn project_root(requested: &Path) -> Result<PathBuf, String> {
let root = requested
.canonicalize()
.map_err(|error| format!("cannot read project root {}: {error}", requested.display()))?;
if !root.is_dir() {
return Err(format!(
"project root is not a directory: {}",
root.display()
));
}
Ok(root)
}
fn init_workspace(requested_root: &Path) -> Result<(), String> {
let root = project_root(requested_root)?;
let workspace = root.join(".shepherd");
safe_create_dir(&root, Path::new(".shepherd/runs"))?;
write_expected(&workspace.join(".gitignore"), WORKSPACE_IGNORE.as_bytes())?;
println!("initialized {}", workspace.display());
Ok(())
}
fn init_lane(requested_root: &Path, run: &str, lane: &str) -> Result<(), String> {
validate_identifier("run", run)?;
validate_identifier("lane", lane)?;
let root = project_root(requested_root)?;
let workspace = root.join(".shepherd");
if !workspace.join(".gitignore").is_file() {
return Err(format!(
"{} is not initialized; run 'shepherd init --root {}' first",
workspace.display(),
root.display()
));
}
let workspace = workspace
.canonicalize()
.map_err(|error| format!("cannot resolve {}: {error}", workspace.display()))?;
if !workspace.starts_with(&root) {
return Err(".shepherd must not escape the project root".into());
}
let relative = PathBuf::from("runs")
.join(run)
.join("lanes")
.join(lane)
.join("evidence");
safe_create_dir(&workspace, &relative)?;
let lane_root = workspace.join("runs").join(run).join("lanes").join(lane);
let plan = format!(
"# Lane {lane}\n\n## Accepted plan revision\n\n## Outcome\n\n## Owned paths\n\n## Dependencies\n\n## Steps and checks\n"
);
let ledger = format!(
"# Lane ledger: {lane}\n\n- Run: `{run}`\n- Status: pending\n- Lane ID: `{lane}`\n- Issues:\n- Owner:\n- Accepted plan revision:\n- Absolute worktree or artifact root:\n- Git common directory:\n- Full branch ref:\n- Base ref and SHA:\n- Integration target:\n- Allowed paths:\n- Tested candidate SHA:\n- Evidence custody/location:\n- Writer/liveness evidence: unknown\n- Review status: pending\n- Integration disposition: pending\n- Updated:\n\n## Dispatches and gates\n"
);
write_expected(&lane_root.join("plan.md"), plan.as_bytes())?;
write_expected(&lane_root.join("ledger.md"), ledger.as_bytes())?;
println!("initialized {}", lane_root.display());
Ok(())
}
#[derive(Serialize)]
struct LaneReport {
schema: &'static str,
ok: bool,
worktree: Option<String>,
common_dir: Option<String>,
branch: Option<String>,
head: Option<String>,
base_ref: String,
base_sha: Option<String>,
dirty: Option<bool>,
untracked: Option<bool>,
ownership: &'static str,
findings: Vec<String>,
}
fn git_environment_key(key: &std::ffi::OsStr) -> bool {
key.as_encoded_bytes()
.get(..4)
.is_some_and(|prefix| prefix.eq_ignore_ascii_case(b"GIT_"))
}
fn git_read(root: &Path, args: &[&str]) -> Result<String, String> {
git_read_with_input(root, args, None)
}
fn git_read_with_input(root: &Path, args: &[&str], input: Option<&[u8]>) -> Result<String, String> {
git_inspect(root, args, input, |stdout| {
let mut bytes = Vec::new();
stdout
.take(MAX_GIT_RECORD_BYTES + 1)
.read_to_end(&mut bytes)
.map_err(|error| format!("cannot read control output: {error}"))?;
if bytes.len() as u64 > MAX_GIT_RECORD_BYTES {
return Err("control output exceeds 1 MiB".into());
}
String::from_utf8(bytes).map_err(|_| "git identity is not UTF-8".into())
})
}
fn git_records(
root: &Path,
args: &[&str],
delimiter: u8,
mut visit: impl FnMut(&str) -> Result<(), String>,
) -> Result<(), String> {
git_inspect(root, args, None, |stdout| {
let mut bytes = Vec::new();
loop {
bytes.clear();
(&mut *stdout)
.take(MAX_GIT_RECORD_BYTES + 1)
.read_until(delimiter, &mut bytes)
.map_err(|error| format!("cannot read inventory: {error}"))?;
if bytes.is_empty() {
break;
}
if bytes.len() as u64 > MAX_GIT_RECORD_BYTES {
return Err("inventory record exceeds 1 MiB".into());
}
if bytes.pop() != Some(delimiter) {
return Err("incomplete Git inventory record".into());
}
let record = std::str::from_utf8(&bytes).map_err(|_| "git identity is not UTF-8")?;
visit(record)?;
}
Ok(())
})
}
fn git_inspect<T>(
root: &Path,
args: &[&str],
input: Option<&[u8]>,
inspect: impl FnOnce(&mut BufReader<ChildStdout>) -> Result<T, String>,
) -> Result<T, String> {
let mut command = GitCommand::new("git");
command
.args([
"--no-optional-locks",
"--no-replace-objects",
"--no-lazy-fetch",
"-c",
"core.fsmonitor=false",
"-c",
"core.fileMode=true",
"-c",
"status.showUntrackedFiles=all",
"-C",
])
.arg(root)
.args(args)
.stdin(if input.is_some() {
Stdio::piped()
} else {
Stdio::null()
})
.stdout(Stdio::piped())
.stderr(Stdio::null());
for (key, _) in std::env::vars_os() {
if git_environment_key(&key) {
command.env_remove(key);
}
}
command
.env("GIT_GRAFT_FILE", "")
.env("GIT_NO_LAZY_FETCH", "1")
.env("GIT_TERMINAL_PROMPT", "0")
.env("GIT_ASKPASS", "");
let mut child = command
.spawn()
.map_err(|e| format!("cannot start git {}: {e}", args[0]))?;
if let Some(input) = input {
if child
.stdin
.take()
.expect("piped stdin")
.write_all(input)
.is_err()
{
let _ = child.kill();
let _ = child.wait();
return Err(format!(
"git {} inspection input could not be written",
args[0]
));
}
}
let result = inspect(&mut BufReader::new(
child.stdout.take().expect("piped stdout"),
));
if let Err(error) = result {
let _ = child.kill();
let _ = child.wait();
return Err(format!("git {} inspection: {error}", args[0]));
}
let status = child
.wait()
.map_err(|e| format!("cannot wait for git {}: {e}", args[0]))?;
if !status.success() {
return Err(format!("git {} failed ({status})", args.join(" ")));
}
result
}
fn git_output_line(output: &str) -> &str {
output.strip_suffix('\n').unwrap_or(output)
}
fn hash_batch_changed(root: &Path, batch: &[(String, String)]) -> Result<bool, String> {
if batch.is_empty() {
return Ok(false);
}
let mut args = vec!["hash-object", "--filters", "--"];
args.extend(batch.iter().map(|(name, _)| name.as_str()));
let output = git_read(root, &args)?;
let actual: Vec<_> = output.lines().collect();
if actual.len() != batch.len() || actual.iter().any(|id| !full_sha(id)) {
return Err("invalid Git content hash output".into());
}
Ok(actual
.iter()
.zip(batch)
.any(|(id, (_, expected))| id != expected))
}
fn tracked_content_changed(root: &Path) -> Result<(bool, bool), String> {
let mut regular = Vec::new();
let mut changed = false;
let mut has_submodules = false;
git_records(root, &["ls-files", "-v", "--stage", "-z"], 0, |entry| {
if entry
.as_bytes()
.first()
.is_some_and(|tag| *tag == b'S' || tag.is_ascii_lowercase())
{
return Err(
"cannot assess cleanliness: assume-unchanged or skip-worktree index flags present"
.into(),
);
}
let (metadata, name) = entry.split_once('\t').ok_or("invalid Git index entry")?;
let fields: Vec<_> = metadata.split_ascii_whitespace().collect();
if fields.len() != 4 || !full_sha(fields[2]) {
return Err("invalid Git index entry".into());
}
if fields[3] != "0" {
return Ok(());
} let path = Path::new(name);
if !path
.components()
.all(|part| matches!(part, Component::Normal(_)))
{
return Err("invalid Git index path".into());
}
match fields[1] {
"100644" | "100755" => {
let kind = fs::symlink_metadata(root.join(path)).map_err(|_| {
"cannot assess cleanliness: tracked file is missing".to_string()
})?;
if !kind.file_type().is_file() {
return Err(
"cannot assess cleanliness: tracked file type differs from index".into(),
);
}
regular.push((name.to_owned(), fields[2].to_owned()));
if regular.len() == 128 {
changed |= hash_batch_changed(root, ®ular)?;
regular.clear();
}
}
"120000" => {
let target = fs::read_link(root.join(path)).map_err(|_| {
"cannot assess cleanliness: tracked symlink is missing".to_string()
})?;
let target = target
.to_str()
.ok_or("cannot assess cleanliness: symlink target is not UTF-8")?;
let actual = git_read_with_input(
root,
&["hash-object", "--stdin"],
Some(target.as_bytes()),
)?;
changed |= git_output_line(&actual) != fields[2];
}
"160000" => {
has_submodules = true;
}
_ => return Err("cannot assess cleanliness: unsupported Git index file type".into()),
}
Ok(())
})?;
changed |= hash_batch_changed(root, ®ular)?;
Ok((changed, has_submodules))
}
fn git_status(root: &Path) -> Result<(bool, bool), String> {
let mut dirty = false;
let mut untracked = false;
git_records(
root,
&[
"status",
"--porcelain=v1",
"-z",
"--no-renames",
"--untracked-files=normal",
"--ignore-submodules=none",
],
0,
|record| {
dirty |= !record.is_empty();
untracked |= record.starts_with("?? ");
Ok(())
},
)?;
Ok((dirty, untracked))
}
fn full_sha(value: &str) -> bool {
matches!(value.len(), 40 | 64)
&& value
.bytes()
.all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
}
#[allow(clippy::too_many_arguments)]
fn lane_check(
root: &Path,
worktree: &Path,
common_dir: &Path,
branch: &str,
base_ref: &str,
base_sha: &str,
head: Option<&str>,
require_clean: bool,
) -> LaneReport {
let mut report = LaneReport {
schema: "shepherd.lane-check.v1",
ok: false,
worktree: None,
common_dir: None,
branch: None,
head: None,
base_ref: base_ref.into(),
base_sha: None,
dirty: None,
untracked: None,
ownership: "not_assessed",
findings: Vec::new(),
};
let inspected = (|| -> Result<(), String> {
for (label, expected) in [("worktree", worktree), ("common-dir", common_dir)] {
if !expected.is_absolute() {
return Err(format!("--{label} must be absolute"));
}
}
let project = project_root(root)?;
let root = project_root(worktree)?;
let top_output = git_read(&root, &["rev-parse", "--show-toplevel"])?;
let top = PathBuf::from(git_output_line(&top_output));
let top = top
.canonicalize()
.map_err(|e| format!("cannot resolve Git root: {e}"))?;
report.worktree = Some(top.display().to_string());
if root != top {
return Err("--worktree is not the Git worktree root".into());
}
let common_output = git_read(
&root,
&["rev-parse", "--path-format=absolute", "--git-common-dir"],
)?;
let common = PathBuf::from(git_output_line(&common_output));
let common = common
.canonicalize()
.map_err(|e| format!("cannot resolve Git common directory: {e}"))?;
report.common_dir = Some(common.display().to_string());
if common
!= common_dir
.canonicalize()
.map_err(|e| format!("cannot resolve expected common directory: {e}"))?
{
report
.findings
.push("actual common directory differs from expected common directory".into());
}
let project_common = git_read(
&project,
&["rev-parse", "--path-format=absolute", "--git-common-dir"],
)?;
let project_common = Path::new(git_output_line(&project_common))
.canonicalize()
.map_err(|e| format!("cannot resolve project Git common directory: {e}"))?;
if common != project_common {
return Err("--root and --worktree do not share the same Git common directory".into());
}
if !branch.starts_with("refs/heads/") {
return Err("--branch must be a full refs/heads/ ref".into());
}
if !(base_ref.starts_with("refs/heads/") || base_ref.starts_with("refs/tags/")) {
return Err("--base-ref must be a full refs/heads/ or refs/tags/ ref".into());
}
git_read(&root, &["check-ref-format", branch])?;
git_read(&root, &["check-ref-format", base_ref])?;
if !full_sha(base_sha) || head.is_some_and(|h| !full_sha(h)) {
return Err("expected SHA must be a full lowercase commit object ID".into());
}
report.branch = git_read(&root, &["symbolic-ref", "--quiet", "HEAD"])
.ok()
.map(|s| git_output_line(&s).into());
if report.branch.as_deref() != Some(branch) {
report
.findings
.push("actual branch differs from expected branch (or HEAD is detached)".into());
}
let actual_head = git_read(&root, &["rev-parse", "--verify", "HEAD^{commit}"])?;
let actual_head = git_output_line(&actual_head);
report.head = Some(actual_head.into());
if head.is_some_and(|expected| expected != actual_head) {
report
.findings
.push("HEAD differs from expected candidate".into());
}
let base = git_read(
&root,
&["rev-parse", "--verify", &format!("{base_ref}^{{commit}}")],
)?;
let base = git_output_line(&base);
report.base_sha = Some(base.into());
if base != base_sha {
report
.findings
.push("base ref differs from expected base SHA".into());
}
if git_read(
&root,
&["merge-base", "--is-ancestor", base_sha, actual_head],
)
.is_err()
{
report
.findings
.push("expected base is not an ancestor of HEAD".into());
}
let branch_line = format!("branch {branch}");
let mut count = 0;
let mut bound = false;
let mut path: Option<String> = None;
let mut matches = false;
let mut prunable = false;
let mut registered = false;
git_records(
&root,
&["worktree", "list", "--porcelain", "-z"],
0,
|field| {
if field.is_empty() {
let path = path
.take()
.ok_or("invalid Git worktree registration record")?;
registered = true;
if matches {
count += 1;
if !prunable
&& Path::new(&path)
.canonicalize()
.is_ok_and(|path| path == top)
{
bound = true;
}
}
matches = false;
prunable = false;
} else if path.is_none() {
path = Some(
field
.strip_prefix("worktree ")
.ok_or("invalid Git worktree registration record")?
.into(),
);
} else {
matches |= field == branch_line;
prunable |= field == "prunable" || field.starts_with("prunable ");
}
Ok(())
},
)?;
if path.is_some() || !registered {
return Err("incomplete Git worktree registration output".into());
}
if count > 1 {
report
.findings
.push("branch is checked out in multiple worktrees".into());
}
if !bound {
report.findings.push(
"branch registration does not match inspected worktree (or is prunable)".into(),
);
}
let (mut dirty, has_submodules) = tracked_content_changed(&root)?;
let (status_dirty, untracked) = git_status(&root)?;
dirty |= status_dirty;
if has_submodules {
git_records(
&root,
&["submodule", "status", "--recursive"],
b'\n',
|line| {
if line.starts_with('-') {
return Err(
"cannot assess cleanliness: uninitialized submodule present".into()
);
}
Ok(())
},
)?;
let mut found = false;
git_records(
&root,
&[
"submodule",
"foreach",
"--quiet",
"--recursive",
"printf '%s\\0' \"$toplevel/$sm_path\"",
],
0,
|path| {
found = true;
let module = PathBuf::from(path)
.canonicalize()
.map_err(|_| "cannot resolve initialized submodule".to_string())?;
if module == root || !module.starts_with(&root) {
return Err("initialized submodule escapes the inspected worktree".into());
}
dirty |= tracked_content_changed(&module)?.0;
dirty |= git_status(&module)?.0;
Ok(())
},
)?;
if !found {
return Err("incomplete Git submodule inspection output".into());
}
}
report.dirty = Some(dirty);
report.untracked = Some(untracked);
if require_clean && dirty {
report
.findings
.push("working tree is dirty (including untracked files)".into());
}
Ok(())
})();
if let Err(error) = inspected {
report.findings.push(error);
}
report.ok = report.findings.is_empty();
report
}
fn validate_identifier(label: &str, value: &str) -> Result<(), String> {
let valid = !value.is_empty()
&& value.len() <= 80
&& value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
&& value != "."
&& value != "..";
if valid {
Ok(())
} else {
Err(format!(
"{label} must be 1-80 ASCII letters, digits, '.', '-', or '_'"
))
}
}
fn write_expected(path: &Path, expected: &[u8]) -> Result<(), String> {
match fs::symlink_metadata(path) {
Ok(_) => match existing_output_bytes(path)? {
existing if existing == expected => Ok(()),
_ => Err(format!(
"refusing to overwrite existing file with different content: {}",
path.display()
)),
},
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
write_no_clobber(path, expected)
}
Err(error) => Err(format!("cannot inspect {}: {error}", path.display())),
}
}
fn safe_create_dir(base: &Path, relative: &Path) -> Result<(), String> {
let mut current = base.to_path_buf();
for component in relative.components() {
let Component::Normal(name) = component else {
return Err(format!(
"directory path must be relative: {}",
relative.display()
));
};
current.push(name);
match fs::symlink_metadata(¤t) {
Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => {}
Ok(_) => {
return Err(format!(
"workspace path is linked or not a directory: {}",
current.display()
));
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => fs::create_dir(¤t)
.map_err(|error| format!("cannot create {}: {error}", current.display()))?,
Err(error) => return Err(format!("cannot inspect {}: {error}", current.display())),
}
}
Ok(())
}
fn host_name(host: Host) -> &'static str {
match host {
Host::Claude => "claude",
Host::Codex => "codex",
Host::Pi => "pi",
}
}
fn manifest_location(host: Host, root: &Path) -> (&'static str, bool) {
match host {
Host::Codex if root.join("plugin.json").exists() => ("plugin.json", false),
Host::Codex if root.join(".codex-plugin/plugin.json").exists() => {
(".codex-plugin/plugin.json", false)
}
Host::Claude if root.join(".claude-plugin/plugin.json").exists() => {
(".claude-plugin/plugin.json", false)
}
Host::Codex if !root.join("package.json").exists() => (".codex-plugin/plugin.json", false),
Host::Claude if !root.join("package.json").exists() => {
(".claude-plugin/plugin.json", false)
}
_ => ("package.json", true),
}
}
fn codex_settings(root: &Path, manifest: &Value, portable: bool) -> Result<Value, String> {
if !portable {
return Ok(manifest.clone());
}
if manifest.get("$schema").and_then(Value::as_str) != Some(PORTABLE_SCHEMA) {
return Err("portable plugin manifest has an unsupported schema".into());
}
match manifest.pointer("/extensions/com.openai") {
Some(settings) if settings.is_object() => Ok(settings.clone()),
Some(_) => Err("portable extensions.com.openai must be an object".into()),
None if root.join(".codex-plugin/plugin.json").exists() => {
let path = contained_regular_file(root, Path::new(".codex-plugin/plugin.json"))?;
serde_json::from_slice(&bounded_read(&path)?)
.map_err(|error| format!("compatibility manifest is not valid JSON: {error}"))
}
None => Ok(serde_json::json!({})),
}
}
fn diagnose(host: Host, requested_root: &Path) -> Report {
let mut report = Report {
schema: "shepherd.doctor/1",
host,
plugin_root: requested_root.display().to_string(),
helper_path: std::env::current_exe()
.ok()
.map(|path| path.display().to_string()),
helper_version: VERSION,
plugin_version: None,
ok: false,
findings: Vec::new(),
warnings: Vec::new(),
};
if !requested_root.is_absolute() {
report
.findings
.push("--plugin-root must be an absolute installed plugin path".into());
return report;
}
let root = match requested_root.canonicalize() {
Ok(root) if root.is_dir() => root,
Ok(_) => {
report.findings.push(format!(
"plugin root is not a directory: {}",
requested_root.display()
));
return report;
}
Err(error) => {
report.findings.push(format!(
"cannot read plugin root {}: {error}",
requested_root.display()
));
return report;
}
};
report.plugin_root = root.display().to_string();
let (manifest_name, package_carrier) = manifest_location(host, &root);
let manifest = required_json(&root, Path::new(manifest_name), &mut report);
if let Some(manifest) = manifest {
let expected_name = match (host, package_carrier) {
(Host::Claude, true) => "@pzzld-org/claude-shepherd",
(Host::Codex, true) => "@pzzld-org/codex-shepherd",
(Host::Pi, _) => "@pzzld-org/pi-shepherd",
_ => "shepherd",
};
if manifest.get("name").and_then(Value::as_str) != Some(expected_name) {
report
.findings
.push(format!("plugin manifest name must be {expected_name}"));
}
match manifest.get("version").and_then(Value::as_str) {
Some(version) => {
report.plugin_version = Some(version.to_owned());
if version != VERSION {
report.findings.push(format!(
"plugin version {version} differs from installed shepherd helper {VERSION}; install matching shepherd-cli and plugin versions"
));
}
}
None => report
.findings
.push("plugin manifest has no string version".into()),
}
inspect_host(host, &root, &manifest, package_carrier, &mut report);
}
report.warnings.push("file checks do not prove host hook trust, event delivery, or child dispatch; verify those in the selected host".into());
report.ok = report.findings.is_empty();
report
}
fn inspect_host(
host: Host,
root: &Path,
manifest: &Value,
package_carrier: bool,
report: &mut Report,
) {
let portable = matches!(host, Host::Codex) && root.join("plugin.json").exists();
let settings = if matches!(host, Host::Codex) && !package_carrier {
match codex_settings(root, manifest, portable) {
Ok(settings) => settings,
Err(error) => {
report.findings.push(error);
return;
}
}
} else {
manifest.clone()
};
let (skill_root, role_root, template_root) = match host {
Host::Claude => (
PathBuf::from("skills"),
PathBuf::from("roles"),
PathBuf::from("templates"),
),
Host::Codex if !package_carrier => {
let skills = if portable {
PathBuf::from("skills")
} else {
declared_directory(root, settings.get("skills"), "skills", report)
.unwrap_or_else(|| PathBuf::from("missing-skills"))
};
let parent = skills.parent().unwrap_or_else(|| Path::new(""));
let roles = parent.join("roles");
let templates = parent.join("templates");
(skills, roles, templates)
}
Host::Codex => (
PathBuf::from("skills"),
PathBuf::from("roles"),
PathBuf::from("templates"),
),
Host::Pi => (
declared_directory(root, manifest.pointer("/pi/skills/0"), "pi.skills", report)
.unwrap_or_else(|| PathBuf::from("missing-skills")),
PathBuf::from("roles"),
PathBuf::from("templates"),
),
};
for role in ROLES {
required_file(root, &role_root.join(format!("{role}.md")), report);
}
required_file(root, &template_root.join("handoff.md"), report);
required_file(root, &template_root.join("fold.md"), report);
for skill in SKILLS {
let file = skill_root.join(skill).join("SKILL.md");
if let Some(path) = required_file(root, &file, report) {
match bounded_read(&path).and_then(|bytes| {
String::from_utf8(bytes).map_err(|error| {
format!("installed skill is not UTF-8: {}: {error}", file.display())
})
}) {
Ok(body) => inspect_references(root, &file, &body, report),
Err(error) => report.findings.push(error),
}
}
}
match host {
Host::Claude => {
for command in ["shepherd", "plant", "start"] {
required_file(
root,
&Path::new("commands").join(format!("{command}.md")),
report,
);
}
inspect_child_agents(root, Path::new("agents"), report);
inspect_hook(
root,
Path::new("hooks/hooks.json"),
"Write",
Path::new("hooks/scripts/seed_preflight_check.sh"),
report,
);
}
Host::Codex => {
if package_carrier {
report.warnings.push("Codex npm carrier contains role and skill resources only; inspect the marketplace plugin root to check its hook registration".into());
} else {
let default_hooks = Value::String("./hooks/hooks.json".into());
let hook_value = settings.get("hooks").or(portable.then_some(&default_hooks));
let hooks = declared_file(root, hook_value, "hooks", report);
if let Some(hooks) = hooks {
let script = hooks
.parent()
.unwrap_or_else(|| Path::new(""))
.join("scripts/seed_preflight_check.sh");
inspect_hook(root, &hooks, "^apply_patch$", &script, report);
}
}
}
Host::Pi => {
let prompts = declared_directory(
root,
manifest.pointer("/pi/prompts/0"),
"pi.prompts",
report,
);
if let Some(prompts) = prompts {
for role in ROLES {
required_file(root, &prompts.join(format!("{role}.md")), report);
}
}
if root.join("agents").is_dir() {
inspect_child_agents(root, Path::new("agents"), report);
}
report.warnings.push("Pi natively loads skills and prompts; agent resources require a separately verified dispatch extension and do not establish persistent team capability".into());
}
}
}
fn inspect_child_agents(root: &Path, relative: &Path, report: &mut Report) {
for role in CHILD_ROLES {
required_file(root, &relative.join(format!("{role}.md")), report);
}
let directory = root.join(relative);
let physical = match directory.canonicalize() {
Ok(path) if path.starts_with(root) && path.is_dir() => path,
_ => return,
};
match fs::read_dir(&physical) {
Ok(entries) => {
for entry in entries {
match entry {
Ok(entry) => {
let path = entry.path();
if path.extension().is_some_and(|extension| extension == "md") {
let name = path
.file_stem()
.and_then(|stem| stem.to_str())
.unwrap_or("");
if !CHILD_ROLES.contains(&name) {
report.findings.push(format!(
"unexpected child agent: {}",
path.file_name().unwrap().to_string_lossy()
));
}
}
}
Err(error) => report
.findings
.push(format!("cannot inspect child agent directory: {error}")),
}
}
}
Err(error) => report.findings.push(format!(
"cannot inspect child agent directory {}: {error}",
relative.display()
)),
}
}
fn declared_directory(
root: &Path,
value: Option<&Value>,
label: &str,
report: &mut Report,
) -> Option<PathBuf> {
let relative = declared_path(value, label, report)?;
let candidate = root.join(&relative);
match candidate.canonicalize() {
Ok(physical) if physical.starts_with(root) && physical.is_dir() => Some(relative),
_ => {
report.findings.push(format!(
"declared {label} directory is missing or escapes plugin root: {}",
relative.display()
));
None
}
}
}
fn declared_file(
root: &Path,
value: Option<&Value>,
label: &str,
report: &mut Report,
) -> Option<PathBuf> {
let relative = declared_path(value, label, report)?;
required_file(root, &relative, report).map(|_| relative)
}
fn declared_path(value: Option<&Value>, label: &str, report: &mut Report) -> Option<PathBuf> {
match relative_declared_path(value, label) {
Ok(path) => Some(path),
Err(error) => {
report.findings.push(error);
None
}
}
}
fn relative_declared_path(value: Option<&Value>, label: &str) -> Result<PathBuf, String> {
let Some(value) = value.and_then(Value::as_str) else {
return Err(format!("manifest has no string {label} path"));
};
let relative = PathBuf::from(value);
if relative.is_absolute()
|| !relative
.components()
.all(|part| matches!(part, Component::CurDir | Component::Normal(_)))
{
return Err(format!(
"declared {label} path must stay inside plugin root: {value}"
));
}
Ok(relative
.components()
.filter(|part| !matches!(part, Component::CurDir))
.collect())
}
fn required_file(root: &Path, relative: &Path, report: &mut Report) -> Option<PathBuf> {
let candidate = root.join(relative);
match candidate.canonicalize() {
Ok(physical) if physical.starts_with(root) && physical.is_file() => Some(physical),
Ok(_) => {
report.findings.push(format!(
"required file escapes plugin root or is not regular: {}",
relative.display()
));
None
}
Err(_) => {
report.findings.push(format!(
"required installed file is missing: {}; reinstall the matching Shepherd plugin",
relative.display()
));
None
}
}
}
fn required_json(root: &Path, relative: &Path, report: &mut Report) -> Option<Value> {
let file = required_file(root, relative, report)?;
let bytes = match bounded_read(&file) {
Ok(bytes) => bytes,
Err(error) => {
report.findings.push(error);
return None;
}
};
match serde_json::from_slice(&bytes) {
Ok(value) => Some(value),
Err(error) => {
report.findings.push(format!(
"manifest is not valid JSON: {}: {error}",
relative.display()
));
None
}
}
}
fn inspect_hook(
root: &Path,
manifest_path: &Path,
matcher: &str,
script_path: &Path,
report: &mut Report,
) {
if let Some(manifest) = required_json(root, manifest_path, report) {
let expected_command = if matcher == "Write" {
format!("bash \"${{CLAUDE_PLUGIN_ROOT}}/{}\"", script_path.display())
} else {
format!("bash \"$PLUGIN_ROOT/{}\"", script_path.display())
};
let expected_hooks = serde_json::json!({"PreToolUse": [{
"matcher": matcher,
"hooks": [{"type": "command", "command": expected_command, "timeout": 5}]
}]});
let valid = manifest.as_object().is_some_and(|object| {
object.len() == 2
&& object.get("description").is_some_and(Value::is_string)
&& object.get("hooks") == Some(&expected_hooks)
});
if !valid {
report.findings.push(format!(
"installed hook manifest does not register the bounded {matcher} seed check: {}",
manifest_path.display()
));
}
}
if let Some(script) = required_file(root, script_path, report) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if fs::metadata(&script)
.is_ok_and(|metadata| metadata.permissions().mode() & 0o111 == 0)
{
report.findings.push(format!(
"installed hook is not executable: {}",
script_path.display()
));
}
}
}
if !on_path("jq") {
report.warnings.push("jq is absent on PATH; the seed hook defers supplemental validation to host permissions".into());
}
}
fn on_path(name: &str) -> bool {
std::env::var_os("PATH").is_some_and(|paths| {
std::env::split_paths(&paths).any(|directory| {
let candidate = directory.join(name);
executable(&candidate)
|| cfg!(windows) && executable(&directory.join(format!("{name}.exe")))
})
})
}
fn executable(path: &Path) -> bool {
if !path.is_file() {
return false;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::metadata(path).is_ok_and(|metadata| metadata.permissions().mode() & 0o111 != 0)
}
#[cfg(not(unix))]
{
true
}
}
fn inspect_references(root: &Path, skill_file: &Path, body: &str, report: &mut Report) {
for (index, token) in body.split('`').enumerate() {
if index % 2 == 1 && token.starts_with("references/") && token.ends_with(".md") {
required_file(root, &skill_file.parent().unwrap().join(token), report);
}
}
for fragment in body.split("](").skip(1) {
let Some(target) = fragment.split(')').next() else {
continue;
};
let target = target.split('#').next().unwrap_or("");
if target.is_empty()
|| target.contains("://")
|| target.starts_with("mailto:")
|| target.contains(['*', '?', '{', '}', '<', '>'])
{
continue;
}
required_file(root, &skill_file.parent().unwrap().join(target), report);
}
}
fn handoff(
host: Host,
requested_root: &Path,
context_path: &Path,
output_path: Option<&Path>,
) -> Result<(), String> {
if !requested_root.is_absolute() {
return Err("--plugin-root must be an absolute installed plugin path".into());
}
let root = requested_root.canonicalize().map_err(|error| {
format!(
"cannot read plugin root {}: {error}",
requested_root.display()
)
})?;
if !root.is_dir() {
return Err(format!(
"plugin root is not a directory: {}",
root.display()
));
}
let (manifest_name, package_carrier) = manifest_location(host, &root);
let expected_name = match (host, package_carrier) {
(Host::Claude, true) => "@pzzld-org/claude-shepherd",
(Host::Codex, true) => "@pzzld-org/codex-shepherd",
(Host::Pi, _) => "@pzzld-org/pi-shepherd",
_ => "shepherd",
};
let manifest_file = contained_regular_file(&root, Path::new(manifest_name))?;
let manifest: Value = serde_json::from_slice(&bounded_read(&manifest_file)?)
.map_err(|error| format!("installed manifest {manifest_name} is invalid JSON: {error}"))?;
if manifest.get("name").and_then(Value::as_str) != Some(expected_name) {
return Err(format!(
"installed manifest name differs from {expected_name}"
));
}
let version = manifest
.get("version")
.and_then(Value::as_str)
.ok_or_else(|| "installed manifest has no string version".to_owned())?;
if version != VERSION {
return Err(format!(
"plugin version {version} differs from shepherd helper {VERSION}; install matching versions"
));
}
let template_name = if matches!(host, Host::Codex) && !package_carrier {
let portable = manifest_name == "plugin.json";
let settings = codex_settings(&root, &manifest, portable)?;
let skills = if portable {
PathBuf::from("skills")
} else {
relative_declared_path(settings.get("skills"), "skills")?
};
skills
.parent()
.unwrap_or_else(|| Path::new(""))
.join("templates/handoff.md")
} else {
PathBuf::from("templates/handoff.md")
};
let template_file = contained_regular_file(&root, &template_name)?;
let template_source = String::from_utf8(bounded_read(&template_file)?)
.map_err(|error| format!("installed handoff template is not UTF-8: {error}"))?;
let context_file = context_path
.canonicalize()
.map_err(|error| format!("cannot read context {}: {error}", context_path.display()))?;
if !context_file.is_file() {
return Err(format!(
"context is not a regular file: {}",
context_path.display()
));
}
let context: Value = serde_json::from_slice(&bounded_read(&context_file)?)
.map_err(|error| format!("context is not valid JSON: {error}"))?;
if !context.is_object() {
return Err("context must be a JSON object".into());
}
let mut env = Environment::new();
env.set_undefined_behavior(UndefinedBehavior::Strict);
env.set_trim_blocks(true);
env.set_lstrip_blocks(true);
env.set_keep_trailing_newline(true);
env.set_auto_escape_callback(|_| AutoEscape::None);
let rendered = env
.template_from_str(&template_source)
.map_err(|error| format!("installed handoff template is invalid: {error}"))?
.render(&context)
.map_err(|error| format!("handoff context does not satisfy installed template: {error}"))?;
if rendered.trim().is_empty() || rendered.len() > MAX_RENDERED_BYTES {
return Err(format!(
"rendered handoff is blank or exceeds {MAX_RENDERED_BYTES} bytes"
));
}
match output_path {
None => print!("{rendered}"),
Some(path) => write_no_clobber(path, rendered.as_bytes())?,
}
Ok(())
}
fn contained_regular_file(root: &Path, relative: &Path) -> Result<PathBuf, String> {
let physical = root.join(relative).canonicalize().map_err(|error| {
format!(
"required installed file is missing: {}: {error}",
relative.display()
)
})?;
if !physical.starts_with(root) || !physical.is_file() {
return Err(format!(
"required installed file escapes plugin root or is not regular: {}",
relative.display()
));
}
Ok(physical)
}
fn bounded_read(path: &Path) -> Result<Vec<u8>, String> {
let file =
fs::File::open(path).map_err(|error| format!("cannot open {}: {error}", path.display()))?;
let mut bytes = Vec::new();
file.take(MAX_FILE_BYTES + 1)
.read_to_end(&mut bytes)
.map_err(|error| format!("cannot read {}: {error}", path.display()))?;
if bytes.len() as u64 > MAX_FILE_BYTES {
return Err(format!(
"input exceeds {MAX_FILE_BYTES} bytes: {}",
path.display()
));
}
Ok(bytes)
}
fn write_no_clobber(path: &Path, bytes: &[u8]) -> Result<(), String> {
match fs::symlink_metadata(path) {
Ok(_) => {
let existing = existing_output_bytes(path)?;
return if existing == bytes {
Ok(())
} else {
Err(format!(
"output already exists with different bytes: {}",
path.display()
))
};
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(format!("cannot inspect output {}: {error}", path.display())),
}
let parent = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or(Path::new("."));
if !parent.is_dir() {
return Err(format!(
"output parent is not an existing directory: {}",
parent.display()
));
}
let mut staged = tempfile::NamedTempFile::new_in(parent)
.map_err(|error| format!("cannot stage output next to {}: {error}", path.display()))?;
staged
.write_all(bytes)
.and_then(|_| staged.flush())
.and_then(|_| staged.as_file().sync_all())
.map_err(|error| format!("cannot write staged output: {error}"))?;
match staged.persist_noclobber(path) {
Ok(_) => Ok(()),
Err(error) if error.error.kind() == std::io::ErrorKind::AlreadyExists => Err(format!(
"output appeared during publication; no existing file was replaced: {}",
path.display()
)),
Err(error) => Err(format!(
"cannot publish output {}: {}",
path.display(),
error.error
)),
}
}
fn existing_output_bytes(path: &Path) -> Result<Vec<u8>, String> {
let metadata = fs::symlink_metadata(path)
.map_err(|error| format!("cannot inspect existing output {}: {error}", path.display()))?;
if !metadata.is_file() || metadata.file_type().is_symlink() {
return Err(format!(
"existing output is linked or non-regular: {}",
path.display()
));
}
let file = fs::File::open(path)
.map_err(|error| format!("cannot open existing output {}: {error}", path.display()))?;
let mut bytes = Vec::new();
file.take(MAX_RENDERED_BYTES as u64 + 1)
.read_to_end(&mut bytes)
.map_err(|error| format!("cannot read existing output {}: {error}", path.display()))?;
if bytes.len() > MAX_RENDERED_BYTES {
return Err(format!(
"existing output exceeds {MAX_RENDERED_BYTES} bytes: {}",
path.display()
));
}
Ok(bytes)
}
#[cfg(test)]
mod tests {
#[test]
fn git_environment_namespace_accepts_windows_casing() {
for key in [
"GIT_DIR",
"Git_Dir",
"Git_Work_Tree",
"git_config_count",
"GIT_CONFIG_KEY_0",
] {
assert!(
super::git_environment_key(std::ffi::OsStr::new(key)),
"{key}"
);
}
for key in ["", "GIT", "XGIT_DIR", "PATH"] {
assert!(
!super::git_environment_key(std::ffi::OsStr::new(key)),
"{key}"
);
}
}
#[test]
#[cfg(unix)]
fn read_bound_applies_to_opened_stream_not_only_stat_size() {
let result = super::bounded_read(std::path::Path::new("/dev/zero"));
assert!(result.unwrap_err().contains("input exceeds"));
}
}