use std::path::Path;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use anyhow::Result;
use super::result::{CheckResult, VerifyReport};
const HELP_TIMEOUT: Duration = Duration::from_secs(8);
#[derive(Debug, Clone)]
pub struct InvocationInput {
pub skill_md: String,
pub has_cli: bool,
pub cli_command: Option<Vec<String>>,
pub skill_root: std::path::PathBuf,
pub spawn_cwd: std::path::PathBuf,
}
impl InvocationInput {
pub fn new(
skill_root: &Path,
spawn_cwd: &Path,
skill_md: &str,
has_cli: bool,
cli_command: Option<&[String]>,
) -> Self {
Self {
skill_md: skill_md.to_string(),
has_cli,
cli_command: cli_command.map(<[std::string::String]>::to_vec),
skill_root: skill_root.to_path_buf(),
spawn_cwd: spawn_cwd.to_path_buf(),
}
}
}
pub fn run(input: &InvocationInput, report: &mut VerifyReport) -> Result<()> {
if !input.has_cli {
report.push(CheckResult::skipped(
"invocation",
"CLI invocation drift checks",
"Skipped: pure-library project (no CLI to invoke)",
));
return Ok(());
}
let Some(cmd) = input.cli_command.as_ref() else {
report.push(CheckResult::skipped(
"invocation",
"CLI invocation drift checks",
"Skipped: has_cli set but no command recorded",
));
return Ok(());
};
if cmd.is_empty() {
report.push(CheckResult::skipped(
"invocation",
"CLI invocation drift checks",
"Skipped: empty command vector",
));
return Ok(());
}
let help = run_help(cmd, &input.spawn_cwd, report)?;
if report.has_critical_failure() {
return Ok(());
}
check_flag_drift(&help, &input.skill_md, report);
Ok(())
}
fn run_help(cmd: &[String], root: &Path, report: &mut VerifyReport) -> Result<String> {
let program = &cmd[0];
let mut c = Command::new(program);
for arg in &cmd[1..] {
c.arg(arg);
}
c.current_dir(root)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = match c.spawn() {
Ok(ch) => ch,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
report.push(CheckResult::fail(
"invocation.help_present",
"documented CLI is installed and runnable",
format!("CLI binary `{program}` not found on PATH"),
format!(
"To fix: build/install `{program}` so it's on PATH, then re-run `skillpack verify`."
),
));
return Ok(String::new());
}
Err(e) => {
report.push(CheckResult::fail(
"invocation.help_present",
"documented CLI is installed and runnable",
format!("could not spawn `{program}`: {e}"),
"To fix: check that the binary path in skillpack.toml is correct.",
));
return Ok(String::new());
}
};
let deadline = Instant::now() + HELP_TIMEOUT;
let status = loop {
match child.try_wait() {
Ok(Some(s)) => break Some(s),
Ok(None) => {}
Err(_) => break None,
}
if Instant::now() > deadline {
let _ = child.kill();
let _ = child.wait();
report.push(CheckResult::fail(
"invocation.help_present",
"CLI prints `--help` quickly",
format!("`{program} --help` exceeded {}s timeout", HELP_TIMEOUT.as_secs()),
"To fix: the CLI may hang waiting on input; guard it with `</dev/null` or fix the hang before shipping.",
));
return Ok(String::new());
}
std::thread::sleep(Duration::from_millis(20));
};
let output = child
.wait_with_output()
.map(|o| {
format!(
"{}{}",
String::from_utf8_lossy(&o.stdout),
String::from_utf8_lossy(&o.stderr)
)
})
.unwrap_or_default();
let status = if let Some(s) = status {
s
} else {
if !report.has_critical_failure() {
report.push(CheckResult::fail(
"invocation.help_present",
"documented `--help` exits cleanly",
format!("`{program}` could not be waited on (killed or errored)"),
"To fix: ensure the CLI exits promptly when given `--help`.",
));
}
return Ok(output);
};
if !status.success() {
report.push(CheckResult::fail(
"invocation.help_present",
"documented `--help` exits cleanly",
format!("`{program}` returned non-zero (exit {status})"),
"To fix: make `--help` exit 0, or correct the command in skillpack.toml.",
));
return Ok(output);
}
if output.trim().is_empty() {
report.push(CheckResult::fail(
"invocation.help_present",
"documented `--help` produces output",
format!("`{program} --help` printed nothing"),
"To fix: implement/generate `--help` output so an agent knows the available flags.",
));
return Ok(output);
}
report.push(CheckResult::pass(
"invocation.help_present",
"documented `--help` runs and produces output",
format!("`{program}` printed {} bytes of help", output.len()),
));
Ok(output)
}
fn check_flag_drift(help_output: &str, skill_md: &str, report: &mut VerifyReport) {
let help_flags = extract_flags(help_output);
let doc_flags = extract_flags(skill_md)
.into_iter()
.filter(|f| !is_meta_flag(f))
.collect::<Vec<_>>();
if doc_flags.is_empty() {
report.push(CheckResult::warn(
"invocation.flag_drift",
"SKILL.md documents flags that match `--help`",
"no flags appear to be documented in SKILL.md (no `--flag` tokens found)",
"To fix: document the CLI's flags so an agent knows what to pass.",
));
return;
}
let mut drifted: Vec<String> = doc_flags
.iter()
.filter(|f| !help_flags.contains(*f))
.cloned()
.collect();
drifted.sort();
drifted.dedup();
if drifted.is_empty() {
report.push(CheckResult::pass(
"invocation.flag_drift",
"every documented flag exists in `--help`",
format!(
"all {} documented flag(s) present in --help",
doc_flags.len()
),
));
return;
}
let first = &drifted[0];
let line_hint = skill_md
.lines()
.position(|l| l.contains(first.as_str()))
.map(|n| n + 1);
let mut fail = CheckResult::fail(
"invocation.flag_drift",
"every documented flag exists in `--help`",
format!(
"documented flag(s) missing from `--help`: {}",
drifted.join(", ")
),
format!("To fix: remove `{first}` from SKILL.md, or add `{first}` to your CLI's `--help`."),
);
fail.location = Some(("SKILL.md".to_string(), line_hint));
report.push(fail);
}
fn is_meta_flag(flag: &str) -> bool {
matches!(flag, "--help" | "-h" | "--version" | "-V" | "--help-all")
}
pub fn extract_flags(text: &str) -> Vec<String> {
let mut out = Vec::new();
for tok in text.split_whitespace() {
let t = tok
.trim_matches(|c: char| c.is_ascii_punctuation() && c != '-')
.to_string();
if !t.starts_with('-') || t.len() < 2 {
continue;
}
let first_letter = match t.chars().find(|c| *c != '-') {
Some(c) => c,
None => continue,
};
if !first_letter.is_ascii_alphabetic() {
continue;
}
let flag: String = t
.split('=')
.next()
.unwrap_or(&t)
.trim_end_matches([',', '.', ';', ':', ')', ']', '\''])
.to_string();
if flag.len() >= 2 && !out.contains(&flag) {
out.push(flag);
}
}
out
}
#[cfg(test)]
mod checks {
use super::*;
#[test]
fn extracts_double_and_single_flags() {
let f = extract_flags("Usage: foo --bar -x --baz=42 end");
assert!(f.contains(&"--bar".to_string()));
assert!(f.contains(&"--baz".to_string()));
assert!(f.contains(&"-x".to_string()));
assert!(!f.iter().any(|s| s == "Usage:"));
}
#[test]
fn ignores_hyphenated_prose() {
let f = extract_flags("a two-step process - and dash-2 numbers");
assert!(!f.iter().any(|s| s == "-2"));
assert!(!f.iter().any(|s| s == "two-step"));
}
}