use clap::{Parser, Subcommand, ValueEnum};
use minijinja::{AutoEscape, Environment, UndefinedBehavior};
use serde::Serialize;
use serde_json::Value;
use std::fs;
use std::io::{Read, Write};
use std::path::{Component, Path, PathBuf};
use std::process::{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 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; 22] = [
"adaptation",
"artifact-work",
"bridge",
"context",
"debugging",
"harness",
"herd",
"implementing",
"lane-execution",
"motivation",
"planning",
"plant",
"planting",
"researching",
"reviewing",
"shepherd",
"spawn",
"sprint-graph",
"start",
"thinking",
"verification",
"workflow",
];
#[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/*/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> {
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}"))?;
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("git inspection input could not be written".into());
}
}
let mut bytes = Vec::new();
let read = child.stdout.take().expect("piped stdout")
.take(MAX_RENDERED_BYTES as u64 + 1).read_to_end(&mut bytes);
if read.is_err() || bytes.len() > MAX_RENDERED_BYTES {
let _ = child.kill();
let _ = child.wait();
return Err("git inspection output could not be read or exceeds 1 MiB".into());
}
let status = child.wait().map_err(|e| format!("cannot wait for git: {e}"))?;
if !status.success() { return Err(format!("git {} failed ({status})", args.join(" "))); }
String::from_utf8(bytes).map_err(|_| "git identity is not UTF-8".into())
}
fn git_output_line(output: &str) -> &str {
output.strip_suffix('\n').unwrap_or(output)
}
fn index_flags_present(entries: &str) -> bool {
entries.split('\0').any(|entry| entry.as_bytes().first()
.is_some_and(|tag| *tag == b'S' || tag.is_ascii_lowercase()))
}
fn tracked_content_changed(root: &Path, entries: &str) -> Result<bool, String> {
if !entries.is_empty() && !entries.ends_with('\0') {
return Err("incomplete Git index inventory".into());
}
let mut regular = Vec::new();
for entry in entries.split_terminator('\0') {
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" { continue; } 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, fields[2]));
}
"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()))?;
if git_output_line(&actual) != fields[2] { return Ok(true); }
}
"160000" => {} _ => return Err("cannot assess cleanliness: unsupported Git index file type".into()),
}
}
for batch in regular.chunks(128) {
let mut args = vec!["hash-object", "--filters", "--"];
args.extend(batch.iter().map(|(name, _)| *name));
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());
}
if actual.iter().zip(batch).any(|(id, (_, expected))| id != expected) { return Ok(true); }
}
Ok(false)
}
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> {
let root = project_root(root)?;
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 { report.findings.push("--root is not the Git worktree root".into()); }
for (label, expected) in [("worktree", worktree), ("common-dir", common_dir)] {
if !expected.is_absolute() { return Err(format!("--{label} must be absolute")); }
}
let expected_worktree = worktree.canonicalize().map_err(|e| format!("cannot resolve expected worktree: {e}"))?;
if top != expected_worktree { report.findings.push("actual worktree differs from expected worktree".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());
}
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 worktrees = git_read(&root, &["worktree", "list", "--porcelain", "-z"])?;
let branch_line = format!("branch {branch}");
let records = worktrees.strip_suffix("\0\0").ok_or("incomplete Git worktree registration output")?;
let mut count = 0;
let mut bound = false;
for record in records.split("\0\0") {
let mut fields = record.split('\0');
let path = fields.next().and_then(|field| field.strip_prefix("worktree "))
.ok_or("invalid Git worktree registration record")?;
let fields: Vec<_> = fields.collect();
if fields.contains(&branch_line.as_str()) {
count += 1;
let prunable = fields.iter().any(|field| *field == "prunable" || field.starts_with("prunable "));
if !prunable && Path::new(path).canonicalize().is_ok_and(|path| path == top) { bound = true; }
}
}
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 entries = git_read(&root, &["ls-files", "-v", "--stage", "-z"])?;
if index_flags_present(&entries) {
return Err("cannot assess cleanliness: assume-unchanged or skip-worktree index flags present".into());
}
let mut repositories = vec![(root.clone(), entries)];
if repositories[0].1.split('\0').any(|entry| entry.get(2..).is_some_and(|rest| rest.starts_with("160000 "))) {
let submodules = git_read(&root, &["submodule", "status", "--recursive"])?;
if submodules.lines().any(|line| line.starts_with('-')) {
return Err("cannot assess cleanliness: uninitialized submodule present".into());
}
let paths = git_read(&root, &["submodule", "foreach", "--quiet", "--recursive",
"printf '%s\\0' \"$toplevel/$sm_path\""])?;
if !paths.ends_with('\0') { return Err("incomplete Git submodule inspection output".into()); }
for path in paths.split_terminator('\0') {
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());
}
let entries = git_read(&module, &["ls-files", "-v", "--stage", "-z"])?;
if index_flags_present(&entries) {
return Err("cannot assess cleanliness: assume-unchanged or skip-worktree index flags present".into());
}
repositories.push((module, entries));
}
}
let status = git_read(&root, &["status", "--porcelain=v1", "-z", "--no-renames", "--untracked-files=normal", "--ignore-submodules=none"])?;
let mut dirty = !status.is_empty();
for (repository, entries) in &repositories {
if tracked_content_changed(repository, entries)? { dirty = true; }
if repository != &root {
let nested = git_read(repository, &["status", "--porcelain=v1", "-z", "--no-renames", "--untracked-files=normal", "--ignore-submodules=none"])?;
if !nested.is_empty() { dirty = true; }
}
}
report.dirty = Some(dirty);
report.untracked = Some(status.split('\0').any(|line| line.starts_with("?? ")));
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/claude-shepherd",
(Host::Codex, true) => "@pzzld/codex-shepherd",
(Host::Pi, _) => "@pzzld/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 => {
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/claude-shepherd",
(Host::Codex, true) => "@pzzld/codex-shepherd",
(Host::Pi, _) => "@pzzld/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"));
}
}