agentnative 0.4.0

The agent-native CLI linter — check whether your CLI follows agent-readiness principles
//! Check: `p8-must-bundle-install`.
//!
//! When a CLI ships a skill bundle, it MUST provide an install path
//! (`tool skill install [<host>]`) that registers the bundle with installed
//! agent runtimes. Non-canonical alternatives (`tool init --skill`,
//! `tool skills add`, `tool agents add`) are accepted as soft-pass with
//! advisory evidence.
//!
//! Applicability: gates on `find_bundle()` (the same heuristic
//! `p8-bundle-exists` uses). When no bundle is present at the project root,
//! the requirement is vacuously satisfied.

use crate::check::Check;
use crate::checks::project::bundle_exists::find_bundle;
use crate::project::Project;
use crate::runner::HelpOutput;
use crate::types::{CheckGroup, CheckLayer, CheckResult, CheckStatus, Confidence};

pub struct BundleInstallCheck;

impl Check for BundleInstallCheck {
    fn id(&self) -> &str {
        "p8-bundle-install"
    }

    fn label(&self) -> &'static str {
        "Skill bundle has install path (`tool skill install [<host>]`)"
    }

    fn group(&self) -> CheckGroup {
        CheckGroup::P8
    }

    fn layer(&self) -> CheckLayer {
        CheckLayer::Behavioral
    }

    fn covers(&self) -> &'static [&'static str] {
        &["p8-must-bundle-install"]
    }

    fn applicable(&self, project: &Project) -> bool {
        project.runner.is_some()
    }

    fn run(&self, project: &Project) -> anyhow::Result<CheckResult> {
        // Vacuous Pass when no bundle is present — the requirement is gated
        // on "if CLI ships an agent skill bundle".
        if find_bundle(&project.path).is_none() {
            return Ok(CheckResult {
                id: self.id().to_string(),
                label: self.label().into(),
                group: self.group(),
                layer: self.layer(),
                status: CheckStatus::Pass,
                confidence: Confidence::High,
            });
        }

        let status = match project.help_output() {
            None => CheckStatus::Skip("could not probe --help".into()),
            Some(help) => check_bundle_install(help),
        };

        Ok(CheckResult {
            id: self.id().to_string(),
            label: self.label().into(),
            group: self.group(),
            layer: self.layer(),
            status,
            confidence: Confidence::Medium,
        })
    }
}

/// Core unit for tests. Looks for `skill install` via subcommand parsing,
/// then falls back to non-canonical install patterns in raw help text.
pub(crate) fn check_bundle_install(help: &HelpOutput) -> CheckStatus {
    // Canonical: `skill` is a top-level subcommand. The `skill install`
    // detail is not always exposed at top-level help, so accept any
    // top-level `skill` subcommand as the canonical surface.
    let subs = help.subcommands();
    let has_skill_subcommand = subs.iter().any(|s| s.eq_ignore_ascii_case("skill"));
    if has_skill_subcommand {
        return CheckStatus::Pass;
    }

    // Non-canonical alternatives accepted as Pass — `init --skill`,
    // `skills add`, `agents add`. The spec calls these out explicitly:
    // "Non-canonical alternatives are acceptable but SHOULD migrate toward
    // `tool skill install`." Surfacing the migration hint requires an
    // advisory-evidence-on-Pass enum extension that isn't in scope here.
    let raw = help.raw().to_lowercase();
    let non_canonical_patterns = ["init --skill", "skills add", "agents add"];
    if non_canonical_patterns.iter().any(|p| raw.contains(p)) {
        return CheckStatus::Pass;
    }

    CheckStatus::Fail(
        "skill bundle present but no install path (`skill install`, \
         `init --skill`, etc.) advertised in --help. Without an install \
         path the bundle stays unread until a human manually copies it."
            .into(),
    )
}

#[cfg(test)]
mod tests {
    use super::*;

    const HELP_WITH_SKILL_SUBCMD: &str = r#"Usage: tool [OPTIONS] <COMMAND>

Commands:
  check     Run checks
  skill     Manage skill bundle installation
  schema    Print output schema

Options:
  -h, --help  Show help
"#;

    const HELP_NO_SKILL_SURFACE: &str = r#"Usage: tool [OPTIONS] <COMMAND>

Commands:
  check     Run checks
  schema    Print output schema

Options:
  -h, --help  Show help
"#;

    const HELP_INIT_SKILL: &str = r#"Usage: tool [OPTIONS] <COMMAND>

Commands:
  check     Run checks
  init      Initialize the project; pass `init --skill` to install bundle.

Options:
  -h, --help  Show help
"#;

    #[test]
    fn happy_path_skill_subcommand() {
        let help = HelpOutput::from_raw(HELP_WITH_SKILL_SUBCMD);
        assert_eq!(check_bundle_install(&help), CheckStatus::Pass);
    }

    #[test]
    fn pass_with_non_canonical_init_skill() {
        let help = HelpOutput::from_raw(HELP_INIT_SKILL);
        assert_eq!(check_bundle_install(&help), CheckStatus::Pass);
    }

    #[test]
    fn fail_no_skill_install_path() {
        let help = HelpOutput::from_raw(HELP_NO_SKILL_SURFACE);
        match check_bundle_install(&help) {
            CheckStatus::Fail(msg) => assert!(msg.contains("install")),
            other => panic!("expected Fail, got {other:?}"),
        }
    }
}