use std::path::Path;
use std::process::Command;
use std::time::Duration;
use anyhow::Result;
use super::result::{CheckResult, VerifyReport};
use crate::spawn::HELP_TIMEOUT;
#[derive(Debug, Clone)]
pub struct InvocationInput {
pub skill_md: String,
pub cli_command: Option<Vec<String>>,
pub skill_root: std::path::PathBuf,
pub spawn_cwd: std::path::PathBuf,
pub verify_stdin: Option<String>,
}
impl InvocationInput {
pub fn new(
skill_root: &Path,
spawn_cwd: &Path,
skill_md: &str,
cli_command: Option<&[String]>,
verify_stdin: Option<&str>,
) -> Self {
Self {
skill_md: skill_md.to_string(),
cli_command: cli_command.map(<[std::string::String]>::to_vec),
skill_root: skill_root.to_path_buf(),
spawn_cwd: spawn_cwd.to_path_buf(),
verify_stdin: verify_stdin.map(|s| s.to_string()),
}
}
}
pub fn run(input: &InvocationInput, report: &mut VerifyReport) -> Result<()> {
let skill_invocation = extract_documented_invocation(&input.skill_md);
if skill_invocation.is_none() {
report.push(CheckResult::skipped(
"invocation",
"CLI invocation drift checks",
"Skipped: pure-library project (no CLI documented in SKILL.md)",
));
return Ok(());
}
let Some(cmd) = input.cli_command.as_ref() else {
report.push(CheckResult::warn(
"invocation.not_runnable_here",
"documented CLI can be spawned for drift checks",
"SKILL.md documents a CLI invocation, but no runnable command was \
found on this machine (no built artifact / runtime missing)",
"To fix: build/install the CLI so `skillpack verify` can spawn its \
`--help`, or run verify on a machine where the CLI is installed.",
));
return Ok(());
};
if cmd.is_empty() {
report.push(CheckResult::warn(
"invocation.not_runnable_here",
"documented CLI can be spawned for drift checks",
"SKILL.md documents a CLI invocation, but the recorded command is empty",
"To fix: re-run `skillpack init` so a CLI command is recorded.",
));
return Ok(());
}
let help = run_help(cmd, &input.spawn_cwd, input.verify_stdin.as_deref(), report)?;
if report.has_critical_failure() {
return Ok(());
}
check_flag_drift(&help, skill_invocation.as_deref().unwrap_or(""), report);
check_subcommand_drift(
cmd,
&input.spawn_cwd,
&input.skill_md,
input.verify_stdin.as_deref(),
report,
)?;
check_version_drift(cmd, &input.spawn_cwd, &input.skill_root, report);
Ok(())
}
pub fn extract_documented_invocation(skill_md: &str) -> Option<String> {
if let Some(block) = heading_block(skill_md, "invocation") {
return Some(block);
}
let mut in_fence = false;
let mut block = String::new();
for line in skill_md.lines() {
let t = line.trim();
if t.starts_with("```") {
if in_fence {
if extract_flags(&block).iter().any(|f| !is_meta_flag(f)) {
return Some(block.clone());
}
block.clear();
}
in_fence = !in_fence;
continue;
}
if in_fence {
block.push_str(line);
block.push('\n');
}
}
None
}
pub fn command_from_documented(skill_md: &str) -> Option<Vec<String>> {
let block = extract_documented_invocation(skill_md)?;
let mut in_fence = false;
for line in block.lines() {
let t = line.trim();
if t.starts_with("```") {
in_fence = !in_fence;
continue;
}
if !in_fence || t.is_empty() {
continue;
}
let prog = t.split_whitespace().next()?.trim();
if prog.is_empty()
|| prog.starts_with('#')
|| prog.starts_with('-')
|| prog.starts_with('<')
|| !prog
.chars()
.all(|c| c.is_alphanumeric() || "_-./".contains(c))
{
continue;
}
return Some(vec![prog.to_string(), "--help".to_string()]);
}
None
}
fn heading_block(skill_md: &str, heading: &str) -> Option<String> {
let want = format!("## {heading}");
let mut in_block = false;
let mut out = String::new();
for line in skill_md.lines() {
let trimmed = line.trim();
if trimmed.starts_with("## ") || trimmed.starts_with("### ") {
if trimmed.starts_with("## ") && trimmed.eq_ignore_ascii_case(&want) {
in_block = true;
continue;
}
if in_block {
break;
}
continue;
}
if in_block {
out.push_str(line);
out.push('\n');
}
}
if in_block && !out.trim().is_empty() {
Some(out)
} else {
None
}
}
fn run_help(
cmd: &[String],
root: &Path,
stdin: Option<&str>,
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);
match crate::spawn::run_with_stdin(&mut c, HELP_TIMEOUT, stdin.map(|s| s.as_bytes())) {
crate::spawn::SpawnOutcome::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`."
),
));
Ok(String::new())
}
crate::spawn::SpawnOutcome::SpawnFailed(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.",
));
Ok(String::new())
}
crate::spawn::SpawnOutcome::TimedOut => {
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.",
));
Ok(String::new())
}
crate::spawn::SpawnOutcome::RanNonZero(output) => {
let mut msg = format!("`{program}` returned non-zero on `--help`");
if !output.trim().is_empty() {
msg.push_str(" (captured: ");
msg.push_str(&snippet(&output, 160));
msg.push(')');
}
report.push(CheckResult::fail(
"invocation.help_present",
"documented `--help` exits cleanly",
msg,
"To fix: make `--help` exit 0, or correct the command in skillpack.toml.",
));
Ok(String::new())
}
crate::spawn::SpawnOutcome::RanClean(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 snippet(s: &str, max: usize) -> String {
let flat: String = s.split_whitespace().collect::<Vec<_>>().join(" ");
let mut out: String = flat.chars().take(max).collect();
if flat.chars().count() > max {
out.push('…');
}
out
}
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() {
let subcommands = extract_subcommands(help_output);
if !subcommands.is_empty() {
return;
}
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()
),
));
} else {
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);
}
reverse_drift(&help_flags, &doc_flags, report);
}
fn reverse_drift(help_flags: &[String], doc_flags: &[String], report: &mut VerifyReport) {
let mut undocumented: Vec<String> = help_flags
.iter()
.filter(|f| !is_meta_flag(f) && !doc_flags.contains(f))
.cloned()
.collect();
undocumented.sort();
undocumented.dedup();
if undocumented.is_empty() {
return;
}
report.push(CheckResult::warn(
"invocation.undocumented_flags",
"every `--help` flag is documented in SKILL.md",
format!(
"`--help` advertises flags the skill doesn't document: {}",
undocumented.join(", ")
),
"To fix: document these flags in SKILL.md so an agent knows it can pass them.",
));
}
pub fn is_meta_flag(flag: &str) -> bool {
matches!(flag, "--help" | "-h" | "--version" | "-V" | "--help-all")
}
pub fn extract_subcommands(help_output: &str) -> Vec<String> {
let out = extract_header_subcommands(help_output);
if out.is_empty() {
extract_usage_brace_subcommands(help_output)
} else {
out
}
}
fn extract_header_subcommands(help_output: &str) -> Vec<String> {
let mut out = Vec::new();
let mut in_section = false;
for line in help_output.lines() {
let trimmed = line.trim();
let is_header = matches!(
trimmed.to_ascii_lowercase().as_str(),
"commands:" | "subcommands:" | "available commands:"
);
if is_header {
in_section = true;
continue;
}
if !in_section {
continue;
}
if trimmed.is_empty() {
break;
}
if line == trimmed {
break;
}
let Some(name) = trimmed.split_whitespace().next() else {
continue;
};
if name == "help" {
continue;
}
if !out.contains(&name.to_string()) {
out.push(name.to_string());
}
}
out
}
fn extract_usage_brace_subcommands(help_output: &str) -> Vec<String> {
for line in help_output.lines() {
let t = line.trim();
if !(t.starts_with("usage:") || t.starts_with("Usage:")) {
continue;
}
let Some(inner) = t
.split_once('{')
.and_then(|(_, after)| after.split_once('}'))
.map(|(inner, _)| inner)
else {
continue;
};
let mut out = Vec::new();
for name in inner.split(',') {
let name = name.trim();
if name.is_empty() || name == "help" || out.contains(&name.to_string()) {
continue;
}
out.push(name.to_string());
}
return out;
}
Vec::new()
}
pub fn extract_documented_subcommands(skill_md: &str) -> Vec<Vec<String>> {
documented_subcommand_bullets(skill_md)
.into_iter()
.map(|(path, _bullet)| path)
.collect()
}
pub fn documented_subcommand_bullets(skill_md: &str) -> Vec<(Vec<String>, String)> {
let want = "### Subcommands";
let mut in_block = false;
let mut block = String::new();
for line in skill_md.lines() {
let trimmed = line.trim();
if trimmed.starts_with("### ") || trimmed.starts_with("## ") {
if in_block {
break;
}
if trimmed.eq_ignore_ascii_case(want) {
in_block = true;
}
continue;
}
if in_block {
block.push_str(line);
block.push('\n');
}
}
let mut out = Vec::new();
let mut stack: Vec<(usize, String)> = Vec::new(); for line in block.lines() {
let trimmed = line.trim();
if !trimmed.starts_with("- ") {
continue;
}
let after = trimmed.strip_prefix("- ").unwrap_or(trimmed);
let Some(name) = after
.split('`')
.nth(1)
.map(str::trim)
.filter(|s| !s.is_empty())
else {
continue;
};
let indent = line.len() - line.trim_start_matches(' ').len();
let level = indent / 2;
while stack.len() > level {
stack.pop();
}
let mut path: Vec<String> = stack.iter().map(|(_, n)| n.clone()).collect();
path.push(name.to_string());
out.push((path, line.to_string()));
stack.push((level, name.to_string()));
}
out
}
fn spawn_capture(
cmd: &[String],
root: &Path,
timeout: Duration,
stdin: Option<&str>,
) -> Option<String> {
let mut c = Command::new(&cmd[0]);
for arg in &cmd[1..] {
c.arg(arg);
}
c.current_dir(root);
match crate::spawn::run_with_stdin(&mut c, timeout, stdin.map(|s| s.as_bytes())) {
crate::spawn::SpawnOutcome::RanClean(out) => Some(out),
crate::spawn::SpawnOutcome::RanNonZero(out) => Some(out),
_ => None,
}
}
fn check_subcommand_drift(
base_cmd: &[String],
spawn_cwd: &Path,
skill_md: &str,
stdin: Option<&str>,
report: &mut VerifyReport,
) -> Result<()> {
let bullets = documented_subcommand_bullets(skill_md);
if bullets.is_empty() {
return Ok(());
}
let mut base = base_cmd.to_vec();
if base.last().is_some_and(|t| t == "--help") {
base.pop();
}
for (path, bullet) in &bullets {
let mut cmd = base.clone();
cmd.extend(path.iter().cloned());
cmd.push("--help".to_string());
let captured = spawn_capture(&cmd, spawn_cwd, HELP_TIMEOUT, stdin);
let Some(help) = captured else {
report.push(CheckResult::fail(
"invocation.subcommand_drift",
"every documented subcommand can be spawned for drift checks",
format!("documented subcommand `{}` could not be spawned for `--help` (missing runtime / non-zero exit / timeout)", path.join(" ")),
"To fix: build/install the CLI so the subcommand is runnable, or remove the subcommand from SKILL.md.",
));
continue;
};
diff_one_subcommand(bullet, path, &help, report);
}
Ok(())
}
fn diff_one_subcommand(bullet: &str, path: &[String], help: &str, report: &mut VerifyReport) {
let sub = path.join(" ");
let doc_flags: Vec<String> = extract_flags(bullet)
.into_iter()
.filter(|f| !is_meta_flag(f))
.collect();
let help_flags = extract_flags(help);
let drifted: Vec<String> = doc_flags
.iter()
.filter(|f| !help_flags.contains(*f))
.cloned()
.collect();
let check_name = format!("documented subcommand `{sub}` flags match `--help`");
if drifted.is_empty() {
report.push(CheckResult::pass(
"invocation.subcommand_drift",
&check_name,
format!("`{sub}` documented flags all present in --help"),
));
} else {
report.push(CheckResult::fail(
"invocation.subcommand_drift",
&check_name,
format!("subcommand `{sub}` documents flags missing from `--help`: {}", drifted.join(", ")),
format!("To fix: remove the flags from SKILL.md's `{sub}` bullet, or add them to `{sub}`'s `--help`."),
));
}
let undocumented: Vec<String> = help_flags
.iter()
.filter(|f| !is_meta_flag(f) && !doc_flags.contains(*f))
.cloned()
.collect();
if !undocumented.is_empty() {
let warn_name = format!("subcommand `{sub}` advertises undocumented flags");
report.push(CheckResult::warn(
"invocation.subcommand_drift",
&warn_name,
format!(
"`{sub} --help` advertises flags the skill doesn't document: {}",
undocumented.join(", ")
),
format!("To fix: document these flags in SKILL.md's `{sub}` bullet."),
));
}
}
fn check_version_drift(
base_cmd: &[String],
spawn_cwd: &Path,
skill_root: &Path,
report: &mut VerifyReport,
) {
let plugin_path = skill_root.join(".claude-plugin").join("plugin.json");
let plugin_version = std::fs::read_to_string(&plugin_path)
.ok()
.and_then(|raw| serde_json::from_str::<serde_json::Value>(&raw).ok())
.and_then(|v| v.get("version").and_then(|v| v.as_str()).map(String::from));
let Some(plugin_version) = plugin_version else {
report.push(CheckResult::skipped(
"invocation.version_drift",
"CLI --version matches plugin.json version",
"Skipped: no plugin.json found or no version field",
));
return;
};
let mut version_cmd = base_cmd.to_vec();
if version_cmd.last().is_some_and(|t| t == "--help") {
version_cmd.pop();
}
version_cmd.push("--version".to_string());
let Some(stdout) = spawn_capture(&version_cmd, spawn_cwd, HELP_TIMEOUT, None) else {
report.push(CheckResult::skipped(
"invocation.version_drift",
"CLI --version matches plugin.json version",
"Skipped: `--version` could not be spawned or produced no output (some CLIs lack --version)",
));
return;
};
let stdout = stdout.trim();
if stdout.is_empty() {
report.push(CheckResult::skipped(
"invocation.version_drift",
"CLI --version matches plugin.json version",
"Skipped: `--version` produced empty output",
));
return;
}
let matches = extract_version_token(stdout)
.map(|tok| tok == plugin_version)
.unwrap_or_else(|| stdout.contains(&plugin_version));
if !matches {
report.push(CheckResult::warn(
"invocation.version_drift",
"CLI --version matches plugin.json version",
format!("`--version` output `{stdout}` does not match plugin.json version `{plugin_version}`"),
"To fix: re-run `skillpack update` to sync plugin.json with the CLI's version, or pin the version intentionally.",
));
} else {
report.push(CheckResult::pass(
"invocation.version_drift",
"CLI --version matches plugin.json version",
format!("`{stdout}` matches plugin.json version `{plugin_version}`"),
));
}
}
fn extract_version_token(stdout: &str) -> Option<String> {
for tok in stdout.split_whitespace() {
let t =
tok.trim_matches(|c: char| !c.is_alphanumeric() && c != '.' && c != '-' && c != '+');
if t.is_empty() || !t.chars().any(|c| c.is_ascii_digit()) {
continue;
}
let core = t
.strip_prefix('v')
.or_else(|| t.strip_prefix('V'))
.unwrap_or(t);
let first = core.chars().next()?;
if !first.is_ascii_digit() {
continue;
}
if core
.chars()
.any(|c| !(c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '+'))
{
continue;
}
return Some(core.to_string());
}
None
}
pub fn extract_flags(text: &str) -> Vec<String> {
let mut out = Vec::new();
for tok in text.split_whitespace() {
let t = if let Some(i) = tok.find("[=") {
tok[..i].to_string()
} else {
tok.to_string()
};
let t = t
.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;
}
if t.contains('/') || t.contains('\'') {
continue;
}
let dash_count = t.chars().take_while(|c| *c == '-').count();
if dash_count == 1 && t.len() > 2 {
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"));
}
#[test]
fn strips_clap_optional_arg_suffix_consistently() {
assert_eq!(
extract_flags("--hyperlink[=<when>]"),
vec!["--hyperlink".to_string()]
);
assert_eq!(
extract_flags("`--hyperlink`"),
vec!["--hyperlink".to_string()]
);
assert_eq!(
extract_flags("--strip-cwd-prefix[=<when>]"),
vec!["--strip-cwd-prefix".to_string()]
);
}
#[test]
fn ignores_prose_examples_in_help_text() {
let f = extract_flags("fd -tf -tl -tx -te -td");
assert!(f.is_empty(), "multi-char short flags from prose: got {f:?}");
let f = extract_flags("fd -- '-foo' pattern");
assert!(
!f.iter().any(|s| s == "-foo"),
"example pattern leaked: got {f:?}"
);
let f = extract_flags("place the -x'/\u{27}--exec option last");
assert!(f.is_empty(), "prose separators leaked: got {f:?}");
let f = extract_flags("Comparable to the -mount or -xdev filters of find(1)");
assert!(f.is_empty(), "find(1) prose leaked: got {f:?}");
}
#[test]
fn documented_invocation_from_heading() {
let skill = "---\nname: foo\n---\n\n## Invocation\n\n```\nfoo --new\n```\n";
let block = extract_documented_invocation(skill).expect("heading block");
assert!(block.contains("foo --new"));
assert!(extract_flags(&block).contains(&"--new".to_string()));
}
#[test]
fn documented_invocation_from_fenced_flags_for_handwritten_skill() {
let skill = "---\nname: sample-broken\n---\n\n# sample-broken\n\n```\nsample-broken --nonexistent --new\n```\n";
assert!(extract_documented_invocation(skill).is_some());
}
#[test]
fn command_from_documented_skips_prose_and_reads_fence() {
let skill = "\
## Invocation
The exact command an agent should run to use this tool:
```
chronicle --new \"entry\"
```
";
assert_eq!(
command_from_documented(skill),
Some(vec!["chronicle".to_string(), "--help".to_string()])
);
}
#[test]
fn documented_invocation_none_for_pure_library() {
let skill = "---\nname: x\n---\n\n## Usage\n\n```\nimport { parse } from 'fastcsv'\n```\n";
assert!(extract_documented_invocation(skill).is_none());
}
#[test]
fn reverse_drift_warns_on_undocumented_help_flag() {
let mut report = super::super::result::VerifyReport::default();
reverse_drift(
&["--new".to_string(), "--verbose".to_string()],
&["--new".to_string()],
&mut report,
);
assert_eq!(report.results.len(), 1);
assert_eq!(
report.results[0].severity,
super::super::result::Severity::Warn
);
assert!(report.results[0].message.contains("--verbose"));
}
#[test]
fn extract_subcommands_from_skillpack_help() {
let help = "\
Generate and verify the agent-distribution layer for any OSS project.
Usage: skillpack [OPTIONS] <COMMAND>
Commands:
init Scaffold the distribution layer
verify Check the distribution files against the schema
help Print this message or the help of the given subcommand(s)
Options:
--verbose Print what skillpack detected in the repo
-h, --help Print help
-V, --version Print version
";
assert_eq!(
extract_subcommands(help),
vec!["init".to_string(), "verify".to_string()]
);
}
#[test]
fn extract_subcommands_empty_for_non_subcommand_help() {
assert_eq!(
extract_subcommands("Usage: chronicle [--new <entry>] [--verbose]"),
Vec::<String>::new()
);
assert_eq!(extract_subcommands(""), Vec::<String>::new());
}
#[test]
fn extract_subcommands_from_cobra_help() {
let help = "\
A CLI for gophers.
Usage:
gocli [command]
Available Commands:
completion Generate the autocompletion script for the specified shell
serve Start the server
help Help about any command
Flags:
-h, --help help for gocli
";
assert_eq!(
extract_subcommands(help),
vec!["completion".to_string(), "serve".to_string()]
);
}
#[test]
fn extract_subcommands_from_argparse_usage() {
let help = "\
usage: pycli [-h] {build,test,run} ...
positional arguments:
command subcommand to run
";
assert_eq!(
extract_subcommands(help),
vec!["build".to_string(), "test".to_string(), "run".to_string()]
);
}
#[test]
fn extract_subcommands_from_argparse_usage_filters_help() {
assert_eq!(
extract_subcommands("usage: prog [-h] {serve,help}"),
vec!["serve".to_string()]
);
}
#[test]
fn extract_subcommands_stops_at_blank_gap() {
let help = "\
Usage: x <COMMAND>
Commands:
foo one
bar two
Options:
--global g
";
assert_eq!(
extract_subcommands(help),
vec!["foo".to_string(), "bar".to_string()]
);
}
#[test]
fn extract_documented_subcommands_from_skill_bullets() {
let skill = "\
# x
## Invocation
```
x --new
```
### Subcommands
- `init` — flags: `--root`, `--non-interactive`
- `verify` — flags: `--format`
";
assert_eq!(
extract_documented_subcommands(skill),
vec![vec!["init".to_string()], vec!["verify".to_string()]]
);
}
#[test]
fn documented_subcommand_bullets_parse_nested_paths() {
let skill = "\
### Subcommands
- `remote` — flags: `--verbose`
- `add` — flags: `--name`
- `remove` — flags: `--name`
- `status` — flags: `--porcelain`
";
assert_eq!(
documented_subcommand_bullets(skill),
vec![
(
vec!["remote".to_string()],
"- `remote` — flags: `--verbose`".to_string()
),
(
vec!["remote".to_string(), "add".to_string()],
" - `add` — flags: `--name`".to_string()
),
(
vec!["remote".to_string(), "remove".to_string()],
" - `remove` — flags: `--name`".to_string()
),
(
vec!["status".to_string()],
"- `status` — flags: `--porcelain`".to_string()
),
]
);
}
#[test]
fn extract_documented_subcommands_empty_when_no_block() {
let skill = "## Invocation\n\n```\nchronicle --new\n```\n";
assert!(extract_documented_subcommands(skill).is_empty());
}
#[test]
fn extract_version_token_normalizes_common_shapes() {
assert_eq!(
extract_version_token("skillpack 0.13.0"),
Some("0.13.0".into())
);
assert_eq!(extract_version_token("v0.13.0"), Some("0.13.0".into()));
assert_eq!(extract_version_token("V1.2.3"), Some("1.2.3".into()));
assert_eq!(
extract_version_token("0.13.0 (build abc)"),
Some("0.13.0".into())
);
assert_eq!(
extract_version_token("1.0.0-rc.1+build2"),
Some("1.0.0-rc.1+build2".into())
);
assert_eq!(extract_version_token("(0.1.0)"), Some("0.1.0".into()));
}
#[test]
fn extract_version_token_returns_none_for_non_version_output() {
assert_eq!(extract_version_token("build abc123def"), None);
assert_eq!(extract_version_token(""), None);
assert_eq!(extract_version_token("unknown"), None);
}
#[test]
fn version_drift_exact_match_rejects_prefix_substrings() {
assert_ne!(
extract_version_token("0.1.0").as_deref(),
Some("0.1"),
"0.1.0 must not equal 0.1 under exact token matching"
);
assert_ne!(
extract_version_token("11.0").as_deref(),
Some("1"),
"11.0 must not equal 1 under exact token matching"
);
}
#[test]
fn subcommand_reverse_drift_hint_interpolates_name() {
let bullet = "- `init`: create\n `--foo`";
let help = "Usage: init\n\nOptions:\n --foo f\n --secret s\n";
let mut report = VerifyReport::default();
diff_one_subcommand(bullet, &["init".to_string()], help, &mut report);
let warn = report
.results
.iter()
.find(|r| r.suggestion.is_some())
.expect("reverse-drift warn should produce a suggestion");
let s = warn.suggestion.as_deref().unwrap();
assert!(
s.contains("`init`"),
"suggestion must carry the real subcommand name `init`, got: {s}"
);
assert!(
!s.contains("{sub}"),
"suggestion must NOT leak the literal `{{sub}}` placeholder, got: {s}"
);
assert!(s.contains("SKILL.md"), "suggestion should name SKILL.md");
assert!(
warn.check_name.contains("undocumented flags"),
"check_name should describe undocumented flags, got: {}",
warn.check_name
);
assert!(
!warn.check_name.contains("no undocumented"),
"check_name must NOT carry the old `no undocumented` inversion, got: {}",
warn.check_name
);
}
}