agentlint-cli 0.1.0

A fast, local-first linter for AI coding-agent configuration
Documentation
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::process::ExitCode;

use anyhow::{Context, Result, bail};
use clap::{Parser, Subcommand};

use agentlint::output::OutputFormat;
use agentlint::{LintOptions, Severity};

#[derive(Debug, Parser)]
#[command(name = "agentlint", version, about = "ESLint for your AI coding setup", long_about = None)]
#[allow(clippy::struct_excessive_bools)] // Each bool is an independent conventional CLI flag.
struct Cli {
    #[command(subcommand)]
    command: Option<Command>,

    /// Repository or agent configuration directory to scan
    #[arg(value_name = "PATH", default_value = ".")]
    path: PathBuf,

    /// Output format
    #[arg(long, value_enum, default_value = "stylish", global = true)]
    format: OutputFormat,

    /// Only report diagnostics at or above this severity
    #[arg(long, value_enum, value_name = "LEVEL", global = true)]
    severity: Option<Severity>,

    /// Disable ANSI colors
    #[arg(long, global = true)]
    no_color: bool,

    /// Hide informational suggestions
    #[arg(long, global = true)]
    quiet: bool,

    /// Print scanning details to stderr
    #[arg(long, global = true)]
    verbose: bool,

    /// Apply safe deterministic fixes
    #[arg(long, global = true)]
    fix: bool,

    /// Use a specific configuration file
    #[arg(long, value_name = "FILE", global = true)]
    config: Option<PathBuf>,

    /// Add an ignore glob (repeatable)
    #[arg(long, value_name = "GLOB", action = clap::ArgAction::Append, global = true)]
    ignore: Vec<String>,
}

#[derive(Debug, Subcommand)]
enum Command {
    /// Check a repository (equivalent to `agentlint [PATH]`)
    Check {
        #[arg(default_value = ".")]
        path: PathBuf,
    },
    /// List all built-in rules
    Rules,
    /// Explain one rule with examples and disable instructions
    Explain { rule: String },
    /// Create .agentlint.yaml in a repository
    Init {
        #[arg(default_value = ".")]
        path: PathBuf,
    },
}

fn main() -> ExitCode {
    match run(Cli::parse()) {
        Ok(code) => code,
        Err(error) => {
            let _ = writeln!(io::stderr(), "agentlint: {error:#}");
            ExitCode::from(2)
        }
    }
}

fn run(cli: Cli) -> Result<ExitCode> {
    match &cli.command {
        Some(Command::Rules) => {
            print_rules();
            return Ok(ExitCode::SUCCESS);
        }
        Some(Command::Explain { rule }) => {
            print_explanation(rule)?;
            return Ok(ExitCode::SUCCESS);
        }
        Some(Command::Init { path }) => {
            init(path)?;
            return Ok(ExitCode::SUCCESS);
        }
        _ => {}
    }

    let path = match &cli.command {
        Some(Command::Check { path }) => path,
        _ => &cli.path,
    };
    let root = path
        .canonicalize()
        .with_context(|| format!("cannot access {}", path.display()))?;
    if !root.is_dir() {
        bail!("scan target must be a directory: {}", root.display());
    }
    if cli.verbose && cli.format != OutputFormat::Json {
        let _ = writeln!(io::stderr(), "Scanning {}", root.display());
    }
    let options = LintOptions {
        config_path: cli.config,
        extra_ignores: cli.ignore,
        minimum_severity: cli.severity,
    };
    let mut report = agentlint::lint(&root, &options)?;
    if cli.fix {
        let config = agentlint::Config::load(&root, options.config_path.as_deref())?;
        let files = agentlint::scanner::scan(&root, &config, &options.extra_ignores)?;
        let project = agentlint::project::ProjectFacts::inspect(&root)?;
        let fixed = agentlint::fix::apply(&files, &project)?;
        if !fixed.is_empty() {
            report = agentlint::lint(&root, &options)?;
            report.fixed_files = fixed;
        }
    }
    let rendered = agentlint::output::render(
        &report,
        cli.format,
        !cli.no_color && console::colors_enabled(),
        cli.quiet,
    )?;
    print!("{rendered}");
    Ok(if report.error_count() > 0 {
        ExitCode::from(1)
    } else {
        ExitCode::SUCCESS
    })
}

fn print_rules() {
    println!("RULE    DEFAULT  CATEGORY     NAME");
    for rule in agentlint::rules::definitions() {
        println!(
            "{:<7} {:<8} {:<12} {}",
            rule.id, rule.default_severity, rule.category, rule.name
        );
    }
}

fn print_explanation(id: &str) -> Result<()> {
    let Some(rule) = agentlint::rules::definition(id) else {
        bail!("unknown rule `{id}`; run `agentlint rules` to list available rules");
    };
    println!("{} — {}\n", rule.id, rule.name);
    println!("{}\n", rule.description);
    println!("Why it matters\n  {}\n", rule.why);
    println!("Bad\n  {}\n", rule.bad_example.replace('\n', "\n  "));
    println!("Good\n  {}\n", rule.good_example.replace('\n', "\n  "));
    println!("Default severity: {}", rule.default_severity);
    println!("Disable with:\n  rules:\n    {}: off", rule.id);
    Ok(())
}

fn init(path: &Path) -> Result<()> {
    let directory = path
        .canonicalize()
        .with_context(|| format!("cannot access {}", path.display()))?;
    if !directory.is_dir() {
        bail!("init target must be a directory");
    }
    let target = directory.join(".agentlint.yaml");
    if target.exists() {
        bail!("{} already exists", target.display());
    }
    agentlint::fix::create_starter(&target)?;
    println!("Created {}", target.display());
    Ok(())
}