diff --git a/CHANGELOG.md b/CHANGELOG.md index fb343fe..b62f964 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Session cleanup uses correct scope key when `session_id` is empty ### Changed +- CLI argument parsing uses `clap`: adds `steplock help`, per-command `--help`, and clearer usage errors (still exit 1) - Idempotent ack: re-acknowledging the current step is a no-op, not an error ## [0.1.0] - Initial release diff --git a/core/Cargo.lock b/core/Cargo.lock index 3791c57..6f3ec5c 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -26,6 +26,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + [[package]] name = "antlr4rust" version = "0.3.0-rc2" @@ -155,6 +205,52 @@ dependencies = [ "windows-link", ] +[[package]] +name = "clap" +version = "4.5.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52fa72306bb30daf11bc97773431628e5b4916e97aaa74b7d3f625d4d495da02" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2071365c5c56eae7d77414029dde2f4f4ba151cf68d5a3261c9a40de428ace93" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec5be1eea072311774b7b84ded287adbd9f293f9d23456817605c6042f4f5e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "clap_lex" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e78417baa3b3114dc0e95e7357389a249c4da97c3c2b540700079db6171bfd7" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -357,6 +453,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.18" @@ -475,6 +577,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "option-ext" version = "0.2.0" @@ -890,6 +998,7 @@ version = "0.1.0" dependencies = [ "cel-interpreter", "chrono", + "clap", "dirs", "polyhook", "proptest", @@ -902,6 +1011,12 @@ dependencies = [ "uuid", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "syn" version = "2.0.117" @@ -931,7 +1046,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.3.4", "once_cell", "rustix", "windows-sys", @@ -1087,6 +1202,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.24.0" diff --git a/core/Cargo.toml b/core/Cargo.toml index 943d159..bd02780 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -86,9 +86,11 @@ enum_glob_use = "deny" single_match_else = "deny" manual_let_else = "deny" items_after_statements = "deny" +needless_pass_by_value = "deny" [dependencies] cel-interpreter = "0.10" +clap = { version = "4.5", features = ["derive"] } chrono = { version = "0.4", features = ["serde"] } dirs = "6" polyhook = "0.1.5" diff --git a/core/src/bin/main.rs b/core/src/bin/main.rs index 19bafc8..4231822 100644 --- a/core/src/bin/main.rs +++ b/core/src/bin/main.rs @@ -7,32 +7,72 @@ use std::io::{self, Read}; use std::path::{Path, PathBuf}; use std::process; +use clap::{Parser, Subcommand}; use polyhook::parse; use steplock::{global_steplock_dir, run_with_global, HookEvent, HookResponse}; +/// Extra help text shown after the generated command list. +const AFTER_HELP: &str = "\ +With no command, steplock reads a hook event from stdin and responds (used by polyhook). + +CHECKLIST FILES: + .steplock/checklists//config.toml Gate trigger and reset configuration + .steplock/checklists//flow.mmd Mermaid stateDiagram-v2 checklist flow + +GLOBAL CHECKLISTS: + Checklists in /checklists// apply to every project. They run after + the project checklists. A project checklist with the same name replaces the global one. + is $STEPLOCK_GLOBAL_DIR, else $XDG_CONFIG_HOME/steplock, else + ~/.config/steplock. Set STEPLOCK_GLOBAL_DIR=\"\" to turn global checklists off. + +For more information: https://github.com/polyhook/steplock"; + +/// Stateful quality gate for AI coding agents. +#[derive(Debug, Parser)] +#[command(name = "steplock", version, after_help = AFTER_HELP)] +struct Cli { + /// Command to run. Omit it to handle a hook event from stdin. + #[command(subcommand)] + command: Option, +} + +/// `steplock` subcommands. +#[derive(Debug, Subcommand)] +enum CliCommand { + /// Create .steplock/checklists/ with a sample checklist in the current directory + Init { + /// Create checklists/ in the global steplock directory instead + #[arg(long)] + global: bool, + }, + /// Check all project and global checklist configs for errors + Validate, + /// Remove all session state (forces checklists to restart) + Clean { + /// Remove session state in the global steplock directory instead + #[arg(long)] + global: bool, + }, +} + fn main() { - let args: Vec = env::args().skip(1).collect(); - match args.as_slice() { - [flag] if flag == "--version" || flag == "-V" => { - println!("steplock {}", env!("CARGO_PKG_VERSION")); - } - [flag] if flag == "--help" || flag == "-h" => { - print_help(); - } - [cmd] if cmd == "init" => { + let cli = Cli::try_parse().unwrap_or_else(|e| { + // Help and version go to stdout and exit 0; usage errors exit 1 (not clap's 2, + // which steplock reserves for hook failures). + let code = i32::from(e.use_stderr()); + let _: io::Result<()> = e.print(); + process::exit(code); + }); + match cli.command { + None => run_hook(), + Some(CliCommand::Init { global: false }) => { let cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); - if let Err(e) = run_init(&cwd) { - eprintln!("steplock: init failed: {e}"); - process::exit(1); - } + exit_on_error("init", run_init(&cwd)); } - [cmd, flag] if cmd == "init" && flag == "--global" => { - if let Err(e) = init_steplock_dir(&require_global_dir(), false) { - eprintln!("steplock: init failed: {e}"); - process::exit(1); - } + Some(CliCommand::Init { global: true }) => { + exit_on_error("init", init_steplock_dir(&require_global_dir(), false)); } - [cmd] if cmd == "validate" => { + Some(CliCommand::Validate) => { let dir = env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let root = find_repo_root_from(&dir).unwrap_or(dir); match run_validate(&root, global_steplock_dir().as_deref()) { @@ -44,56 +84,22 @@ fn main() { } } } - [cmd] if cmd == "clean" => { - if let Err(e) = run_clean(&env::current_dir().unwrap_or_else(|_| PathBuf::from("."))) { - eprintln!("steplock: clean failed: {e}"); - process::exit(1); - } - } - [cmd, flag] if cmd == "clean" && flag == "--global" => { - if let Err(e) = clean_sessions(&require_global_dir()) { - eprintln!("steplock: clean failed: {e}"); - process::exit(1); - } + Some(CliCommand::Clean { global: false }) => { + let cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + exit_on_error("clean", run_clean(&cwd)); } - [] => run_hook(), - _ => { - eprintln!("steplock: unknown arguments"); - eprintln!("Run 'steplock --help' for usage."); - process::exit(1); + Some(CliCommand::Clean { global: true }) => { + exit_on_error("clean", clean_sessions(&require_global_dir())); } } } -fn print_help() { - println!( - "steplock {} - -Stateful quality gate for AI coding agents. - -USAGE: - steplock Read hook event from stdin and respond (used by polyhook) - steplock init Create .steplock/checklists/ in the current directory - steplock init --global Create checklists/ in the global steplock directory - steplock validate Check all project and global checklist configs for errors - steplock clean Remove all session state (forces checklists to restart) - steplock clean --global - Remove all session state in the global steplock directory - steplock --version Print version - -CHECKLIST FILES: - .steplock/checklists//config.toml Gate trigger and reset configuration - .steplock/checklists//flow.mmd Mermaid stateDiagram-v2 checklist flow - -GLOBAL CHECKLISTS: - Checklists in /checklists// apply to every project. They run after - the project checklists. A project checklist with the same name replaces the global one. - is $STEPLOCK_GLOBAL_DIR, else $XDG_CONFIG_HOME/steplock, else - ~/.config/steplock. Set STEPLOCK_GLOBAL_DIR=\"\" to turn global checklists off. - -For more information: https://github.com/polyhook/steplock", - env!("CARGO_PKG_VERSION") - ); +/// Print `steplock: failed: ` and exit 1 when `result` is an error. +fn exit_on_error(command: &str, result: io::Result<()>) { + if let Err(e) = result { + eprintln!("steplock: {command} failed: {e}"); + process::exit(1); + } } /// Validate all checklists in `.steplock/checklists/` and in the global steplock directory. diff --git a/core/src/bin/main_tests.rs b/core/src/bin/main_tests.rs index 9b05b13..9b38c26 100644 --- a/core/src/bin/main_tests.rs +++ b/core/src/bin/main_tests.rs @@ -1,6 +1,8 @@ //! Unit tests for `main`. use super::*; +use clap::CommandFactory; use std::fs; +use std::iter; use tempfile::TempDir; fn claude_stdin(cmd: &str, session: &str) -> String { @@ -299,3 +301,67 @@ fn validate_continues_checking_all_checklists_after_failure() { .unwrap(); assert!(!run_validate(tmp.path(), None).unwrap()); } + +fn parse(args: &[&str]) -> Cli { + Cli::try_parse_from(iter::once("steplock").chain(args.iter().copied())).unwrap() +} + +#[test] +fn cli_no_args_runs_hook() { + assert!( + parse(&[]).command.is_none(), + "no subcommand means hook mode" + ); +} + +#[test] +fn cli_parses_init_and_global_flag() { + assert!( + matches!( + parse(&["init"]).command, + Some(CliCommand::Init { global: false }) + ), + "plain init" + ); + assert!( + matches!( + parse(&["init", "--global"]).command, + Some(CliCommand::Init { global: true }) + ), + "init --global" + ); +} + +#[test] +fn cli_parses_validate_and_clean() { + assert!( + matches!(parse(&["validate"]).command, Some(CliCommand::Validate)), + "validate" + ); + assert!( + matches!( + parse(&["clean", "--global"]).command, + Some(CliCommand::Clean { global: true }) + ), + "clean --global" + ); +} + +#[test] +fn cli_rejects_unknown_arguments() { + let err = Cli::try_parse_from(["steplock", "--unknown-flag"]).unwrap_err(); + assert!(err.use_stderr(), "usage errors go to stderr and exit 1"); +} + +#[test] +fn cli_help_and_version_are_not_errors() { + for flag in ["--help", "-h", "--version", "-V"] { + let err = Cli::try_parse_from(["steplock", flag]).unwrap_err(); + assert!(!err.use_stderr(), "{flag} prints to stdout and exits 0"); + } +} + +#[test] +fn cli_definition_is_valid() { + Cli::command().debug_assert(); +} diff --git a/core/tests/cli.rs b/core/tests/cli.rs index 15bd627..b156006 100644 --- a/core/tests/cli.rs +++ b/core/tests/cli.rs @@ -102,6 +102,19 @@ fn short_version_flag() { assert!(stdout.starts_with("steplock ")); } +#[test] +fn help_flag_lists_commands_and_exits_zero() { + let output = Command::new(STEPLOCK) + .arg("--help") + .output() + .expect("failed to run steplock"); + assert!(output.status.success(), "--help exits 0"); + let stdout = String::from_utf8_lossy(&output.stdout); + for word in ["init", "validate", "clean", "GLOBAL CHECKLISTS"] { + assert!(stdout.contains(word), "help must mention {word}: {stdout}"); + } +} + #[test] fn unknown_arg_exits_nonzero() { let output = Command::new(STEPLOCK)