cleanlib-cli 0.1.5

Terminal interface to CleanLibrary — query dependency verdicts and scan package manifests for ALLOW / DENY / WARN signals from the terminal or CI pipelines.
//! Cycle-7 Cli1-Cli8 — `cleanlib` CLI bin entry point.
//!
//! As of cycle-7 the handler logic lives in `commands/*.rs`, the envelope
//! renderer in `render/terminal.rs`, the bearer resolver in `auth/bearer.rs`,
//! the persistent verdict cache in `cache/persistent.rs`, and the per-pkg-mgr
//! wrappers in `wrappers/{npm,pip,cargo,go}.rs`. This file is the thin
//! clap-router into those modules.

mod auth;
mod cache;
mod commands;
mod render;
#[allow(dead_code)]
mod wrappers;

use std::path::PathBuf;

use clap::{Parser, Subcommand, ValueEnum};

/// CLEANLIB-166 — canonical `--output` value type. Prior to this ticket, each
/// subcommand accepted `--output <String>` and the handler did
/// `match output.as_str() { "json" => …, _ => text-fallback }`, so invalid
/// values (e.g. `--output invalid`) silently defaulted to text rendering with
/// no user-visible signal. Scripts/CI pipelines that misspelled the format
/// (`--output josn`) would receive text and fail downstream parse, hiding the
/// root cause. Deriving `ValueEnum` on this shared type moves validation into
/// clap's parse pass — an unknown value now short-circuits with exit code 2
/// and the canonical `error: invalid value 'invalid' for '--output <FORMAT>'
/// [possible values: text, json, sarif]` message, matching npm/cargo/git convention.
///
/// Shared across all `--output`-carrying subcommands (Verdict, Scan, Audit,
/// Policy.Preview, Fix) so the set of valid values cannot drift per-command.
///
/// CLEANLIB-196 (Client-3.1) — adds `Sarif` variant. Emits SARIF v2.1.0
/// (OASIS static-analysis interchange standard) so verdict findings can
/// be ingested by GitHub Code Scanning, GitLab MR Security Dashboard,
/// SonarQube, and every third-party CI security dashboard that supports
/// SARIF. Serialization is handled by `render::sarif::to_json_pretty`.
#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
#[value(rename_all = "lowercase")]
pub enum OutputFormat {
    Text,
    Json,
    Sarif,
}

impl OutputFormat {
    /// Canonical wire string handlers match on (`"text"` / `"json"` /
    /// `"sarif"`). Kept as `&'static str` so it threads through the existing
    /// `output: String` handler signatures without touching downstream
    /// match arms.
    pub fn as_wire_str(self) -> &'static str {
        match self {
            OutputFormat::Text => "text",
            OutputFormat::Json => "json",
            OutputFormat::Sarif => "sarif",
        }
    }
}

/// CleanLibrary CLI — verdict-aware package proxy companion.
///
/// CLEANLIB-132 / Jira CLEANLIB-29: `arg_required_else_help = true` so
/// invoking `cleanlib` with no subcommand prints help text + exits non-
/// zero (clap convention exit 2 = "user error"), instead of the pre-fix
/// behavior of silently exiting 0 with no output. Sister of CLEANLIB-130
/// exit-code semantics philosophy (fail loud, never silent fail-open).
#[derive(Parser)]
#[command(name = "cleanlib", version, about, long_about = None, arg_required_else_help = true)]
struct Cli {
    #[command(subcommand)]
    command: Option<Command>,
}

#[derive(Subcommand)]
enum Command {
    /// Display CleanLibrary client state — config file, endpoint, telemetry,
    /// auth source (does NOT print the bearer or API key).
    Status,
    /// Config subcommand (currently exposes `init`).
    Config {
        #[command(subcommand)]
        action: ConfigAction,
    },
    /// Store API key. Default writes to `~/.cleanlibrary/config.toml`;
    /// `--keyring` stores in the OS keyring instead (cycle-7 Cli6).
    Login {
        /// Opaque CleanLibrary API key (sent as `Authorization: Bearer ...`).
        #[arg(long)]
        api_key: String,
        /// Store in the OS keyring (Keychain / Secret Service / Credential
        /// Manager) under `cleanstart.com/cleanlib-enrich` instead of the
        /// config file.
        #[arg(long)]
        keyring: bool,
    },
    /// Remove API key from `~/.cleanlibrary/config.toml`. With `--keyring`,
    /// also removes the keyring entry.
    Logout {
        /// Also remove the OS keyring entry (cycle-7 Cli6).
        #[arg(long)]
        keyring: bool,
    },
    /// Emit a risk-acceptance rule YAML.
    #[command(name = "risk-accept")]
    RiskAccept {
        /// Package ecosystem (`npm`, `pypi`, `cargo`, `go`, `maven`,
        /// `nuget`, `rubygems`, `composer`). CLEANLIB-80: required so the
        /// emitted rule is disambiguated across ecosystems (a `requests`
        /// package exists in both PyPI and RubyGems; `lodash` only in npm).
        /// Matches the ecosystem argument shape of every other CLI command
        /// (`verdict`, `scan`, `policy preview`, `audit`).
        #[arg(long)]
        ecosystem: String,
        /// Package name to accept (must not be empty).
        #[arg(long)]
        package: String,
        /// Version range to accept (must not be empty).
        #[arg(long)]
        version: String,
        /// Security rationale for the exception (must not be empty).
        ///
        /// Tip: wrap the text in SINGLE quotes if it contains shell
        /// metacharacters such as `!`. Inside double quotes, bash performs
        /// history expansion on `!` BEFORE cleanlib runs (e.g.
        /// `--justification "waived until Q3!"` fails with
        /// "event not found"). Use `--justification 'waived until Q3!'`.
        #[arg(long)]
        justification: String,
        #[arg(long)]
        proposed_by: Option<String>,
        /// Write the rule YAML to this path instead of stdout. An existing
        /// file is backed up (timestamped) before being overwritten unless
        /// --force is given. The parent directory must already exist.
        #[arg(long)]
        write_to: Option<PathBuf>,
        /// Overwrite the --write-to target without creating a backup.
        #[arg(long)]
        force: bool,
    },
    /// Fetch + display the verdict for a (ecosystem, package, version) tuple.
    Verdict {
        #[arg(long)]
        ecosystem: String,
        #[arg(long)]
        package: String,
        #[arg(long)]
        version: String,
        #[arg(long, value_enum, default_value_t = OutputFormat::Text)]
        output: OutputFormat,
    },
    /// Scan a packages file against the active customer policy.
    Scan {
        #[arg(long)]
        ecosystem: String,
        #[arg(long)]
        packages: PathBuf,
        #[arg(long, value_enum, default_value_t = OutputFormat::Text)]
        output: OutputFormat,
    },
    /// Query customer audit log.
    Audit {
        #[arg(long)]
        since: Option<String>,
        #[arg(long)]
        decision: Option<String>,
        #[arg(long)]
        ecosystem: Option<String>,
        #[arg(long, value_enum, default_value_t = OutputFormat::Text)]
        output: OutputFormat,
    },
    /// `cleanlib policy <action>` — currently exposes `preview`.
    Policy {
        #[command(subcommand)]
        action: PolicyAction,
    },
    /// Fetch artifact bytes for (ecosystem, package, version) via App's
    /// per-ecosystem proxy.
    Fetch {
        ecosystem: String,
        package: String,
        version: String,
        #[arg(long)]
        output: Option<PathBuf>,
    },
    /// Wrap `npm install/i/add` — parse the wrapped command's positional
    /// args, extract package coordinates, and emit verdicts for each
    /// targeted package. Cycle-9 CLEANLIB-112 runtime exposure of the
    /// `cleanlib-cli::wrappers::npm` parser substrate (33 unit tests prove
    /// the parser; this variant makes it user-reachable).
    Npm {
        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
        args: Vec<String>,
    },
    /// Wrap `pip install` — sister of `Npm`.
    Pip {
        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
        args: Vec<String>,
    },
    /// Wrap `cargo install/add` — sister of `Npm`.
    Cargo {
        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
        args: Vec<String>,
    },
    /// Wrap `go get/install` — sister of `Npm`.
    Go {
        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
        args: Vec<String>,
    },
    /// App-4 CLEANLIB-211..214 — autonomous remediation. Reads a customer
    /// lockfile (package-lock.json, Cargo.lock, poetry.lock, go.sum), resolves
    /// verdicts per node via `cleanlib-client`, and emits an upgrade plan.
    ///
    /// Safety (CLEANLIB-214): default is DRY RUN — nothing on disk moves
    /// without `--apply`. `--allow-list <file>` restricts which packages can
    /// be auto-fixed; `--risk-accept <file>` reads the customer's
    /// risk-acceptance YAML (same shape `cleanlib risk-accept` emits) and
    /// skips matching packages (CLEANLIB-213).
    ///
    /// PR mode (CLEANLIB-212): `--auto-pr` emits a host-agnostic PR bundle to
    /// stdout instead of touching the working tree; a downstream driver
    /// (github/gitlab/bitbucket REST) consumes it.
    ///
    /// Exit codes: `0` when no upgrades to apply (or `--apply` succeeded);
    /// `4` when dry-run found pending upgrades (so pre-commit hooks can gate).
    Fix {
        #[arg(long)]
        lockfile: PathBuf,
        #[arg(long)]
        apply: bool,
        #[arg(long)]
        risk_accept: Option<PathBuf>,
        #[arg(long)]
        allow_list: Option<PathBuf>,
        #[arg(long)]
        auto_pr: bool,
        #[arg(long)]
        pr_remote: Option<String>,
        #[arg(long, value_enum, default_value_t = OutputFormat::Text)]
        output: OutputFormat,
    },
}

#[derive(Subcommand)]
enum PolicyAction {
    /// Preview decisions under a hypothetical policy via App
    /// `POST /v1/policy/preview`.
    Preview {
        #[arg(long)]
        policy: PathBuf,
        #[arg(long)]
        packages: PathBuf,
        #[arg(long)]
        ecosystem: String,
        #[arg(long, value_enum, default_value_t = OutputFormat::Text)]
        output: OutputFormat,
    },
}

#[derive(Subcommand)]
enum ConfigAction {
    /// Emit per-ecosystem proxy-config snippets for `.npmrc`, `pip.conf`, or
    /// Go shell-env.
    Init {
        #[arg(long, value_delimiter = ',', default_values_t = vec!["npm".to_string(), "pypi".to_string(), "go".to_string()])]
        ecosystem: Vec<String>,
        #[arg(long)]
        scope: Option<String>,
        #[arg(long)]
        write: bool,
        #[arg(long)]
        write_to: Option<PathBuf>,
        #[arg(long)]
        inline_token: bool,
        #[arg(long)]
        force: bool,
    },
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let cli = Cli::parse();
    match cli.command {
        Some(Command::Status) => commands::status::run(),
        Some(Command::Config { action }) => match action {
            ConfigAction::Init {
                ecosystem,
                scope,
                write,
                write_to,
                inline_token,
                force,
            } => commands::config_init::run(ecosystem, scope, write, write_to, inline_token, force),
        },
        Some(Command::Login { api_key, keyring }) => commands::login::run(api_key, keyring).await,
        Some(Command::Logout { keyring }) => commands::logout::run(keyring),
        Some(Command::RiskAccept {
            ecosystem,
            package,
            version,
            justification,
            proposed_by,
            write_to,
            force,
        }) => commands::risk_accept::run(
            ecosystem,
            package,
            version,
            justification,
            proposed_by,
            write_to,
            force,
        ),
        Some(Command::Verdict {
            ecosystem,
            package,
            version,
            output,
        }) => commands::verdict::run(ecosystem, package, version, output.as_wire_str().to_string()).await,
        Some(Command::Scan {
            ecosystem,
            packages,
            output,
        }) => commands::scan::run(ecosystem, packages, output.as_wire_str().to_string()).await,
        Some(Command::Audit {
            since,
            decision,
            ecosystem,
            output,
        }) => commands::audit::run(since, decision, ecosystem, output.as_wire_str().to_string()).await,
        Some(Command::Policy { action }) => match action {
            PolicyAction::Preview {
                policy,
                packages,
                ecosystem,
                output,
            } => commands::policy::preview(policy, packages, ecosystem, output.as_wire_str().to_string()).await,
        },
        Some(Command::Fetch {
            ecosystem,
            package,
            version,
            output,
        }) => commands::fetch::run(ecosystem, package, version, output).await,
        Some(Command::Npm { args }) => commands::wrap::run("npm", args).await,
        Some(Command::Pip { args }) => commands::wrap::run("pip", args).await,
        Some(Command::Cargo { args }) => commands::wrap::run("cargo", args).await,
        Some(Command::Go { args }) => commands::wrap::run("go", args).await,
        Some(Command::Fix {
            lockfile,
            apply,
            risk_accept,
            allow_list,
            auto_pr,
            pr_remote,
            output,
        }) => {
            commands::fix::run(commands::fix::FixArgs {
                lockfile,
                apply,
                risk_accept,
                allow_list,
                auto_pr,
                pr_remote,
                output: output.as_wire_str().to_string(),
            })
            .await
        }
        None => Ok(()),
    }
}