From 6d9a04614d2935544b57d3bbf48afe8b41a43c3a Mon Sep 17 00:00:00 2001 From: Owain Lewis Date: Wed, 15 Jul 2026 12:46:27 +0100 Subject: [PATCH 1/3] fix(cli): handle help without side effects --- docs/reference/cli.md | 3 ++- src/main.rs | 49 +++++++++++++++++++++++++++++++++++++++++++ tests/init_cli.rs | 27 ++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 1 deletion(-) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index b884774..0c72d31 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -6,6 +6,7 @@ The default is `~/.push/config.toml`. | Command | Purpose | | --- | --- | +| `push --help` | Print command and option help without loading config or changing files | | `push init [path]` | Create and Git-initialize the one assistant repository; defaults to `./assistant` | | `push` | Start the configured channel gateway and scheduler | | `push doctor` | Validate config, paths, channel requirements, and required backend binaries | @@ -29,7 +30,7 @@ push job runs repo-review ``` Unknown commands and missing values fail with the accepted command forms. The -CLI does not currently provide shell completion or a generated `--help` page. +CLI does not currently provide shell completion. `push restart` targets the service definitions documented by Push: `com.owainlewis.push` under launchd on macOS and the `push.service` user unit diff --git a/src/main.rs b/src/main.rs index c7ede3c..7ca0e2a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -30,6 +30,23 @@ mod voice; use anyhow::{bail, Context, Result}; const DEFAULT_CONFIG_PATH: &str = "~/.push/config.toml"; +const HELP: &str = "Push turns coding agents into a personal assistant you can text. + +Usage: push [OPTIONS] [COMMAND] + +Commands: + init [path] Create an assistant repository (default: ./assistant) + doctor Validate the configuration and dependencies + job validate Validate all installed jobs + job list List installed jobs + job show Show an installed job + job run Run an installed job + job runs [name] Show job run history + +Options: + --config Use a configuration file (default: ~/.push/config.toml) + -h, --help Print help +"; #[tokio::main] async fn main() -> Result<()> { @@ -37,6 +54,10 @@ async fn main() -> Result<()> { let args = Args::parse(std::env::args().skip(1).collect())?; match args.command { + Command::Help => { + print!("{HELP}"); + Ok(()) + } Command::Init(path) => { let result = assistant::init(&path, &args.config_path)?; println!("Initialized assistant at {}", result.root.display()); @@ -121,6 +142,7 @@ struct Args { #[derive(Debug, PartialEq, Eq)] enum Command { + Help, Run, Init(String), Doctor, @@ -139,6 +161,16 @@ enum JobCommand { impl Args { fn parse(args: Vec) -> Result { + if args + .iter() + .any(|arg| matches!(arg.as_str(), "-h" | "--help")) + { + return Ok(Self { + command: Command::Help, + config_path: DEFAULT_CONFIG_PATH.to_string(), + }); + } + let mut config_path = DEFAULT_CONFIG_PATH.to_string(); let mut positional = Vec::new(); let mut i = 0; @@ -328,6 +360,23 @@ mod tests { ); } + #[test] + fn parses_help_without_treating_it_as_a_command_argument() { + assert_eq!( + Args::parse(vec!["--help".into()]).unwrap(), + Args { + command: Command::Help, + config_path: DEFAULT_CONFIG_PATH.to_string(), + } + ); + assert_eq!( + Args::parse(vec!["job".into(), "--help".into()]) + .unwrap() + .command, + Command::Help + ); + } + #[test] fn parses_all_job_commands_with_config_anywhere() { assert_eq!( diff --git a/tests/init_cli.rs b/tests/init_cli.rs index 3f65693..df8751c 100644 --- a/tests/init_cli.rs +++ b/tests/init_cli.rs @@ -2,6 +2,33 @@ use std::process::Command; use uuid::Uuid; +#[test] +fn init_help_prints_usage_without_creating_files() { + let root = temp_dir("help"); + let home = root.join("home"); + let workdir = root.join("workdir"); + std::fs::create_dir_all(&home).unwrap(); + std::fs::create_dir_all(&workdir).unwrap(); + + let output = Command::new(env!("CARGO_BIN_EXE_push")) + .args(["init", "--help"]) + .current_dir(&workdir) + .env("HOME", &home) + .output() + .unwrap(); + + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(String::from_utf8_lossy(&output.stdout).contains("Usage: push")); + assert!(output.stderr.is_empty()); + assert_eq!(std::fs::read_dir(&workdir).unwrap().count(), 0); + assert_eq!(std::fs::read_dir(&home).unwrap().count(), 0); + let _ = std::fs::remove_dir_all(root); +} + #[test] fn init_without_path_creates_assistant_in_current_directory() { let root = temp_dir("default"); From 770abb78dcbc77c05656a1b8546386b9e2ec156d Mon Sep 17 00:00:00 2001 From: Owain Lewis Date: Thu, 16 Jul 2026 10:55:09 +0100 Subject: [PATCH 2/3] feat(cli): add help command --- docs/reference/cli.md | 3 ++- src/main.rs | 11 ++++++++++- tests/init_cli.rs | 32 +++++++++++++++++--------------- 3 files changed, 29 insertions(+), 17 deletions(-) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 0c72d31..c57d83f 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -6,7 +6,7 @@ The default is `~/.push/config.toml`. | Command | Purpose | | --- | --- | -| `push --help` | Print command and option help without loading config or changing files | +| `push help`, `push --help` | Print command and option help without loading config or changing files | | `push init [path]` | Create and Git-initialize the one assistant repository; defaults to `./assistant` | | `push` | Start the configured channel gateway and scheduler | | `push doctor` | Validate config, paths, channel requirements, and required backend binaries | @@ -21,6 +21,7 @@ Examples: ```sh push init ~/Code/assistant +push help push doctor push push restart diff --git a/src/main.rs b/src/main.rs index 7ca0e2a..849e9f3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -35,6 +35,7 @@ const HELP: &str = "Push turns coding agents into a personal assistant you can t Usage: push [OPTIONS] [COMMAND] Commands: + help Print this help init [path] Create an assistant repository (default: ./assistant) doctor Validate the configuration and dependencies job validate Validate all installed jobs @@ -191,6 +192,7 @@ impl Args { } let command = match positional.iter().map(String::as_str).collect::>().as_slice() { [] => Command::Run, + ["help"] => Command::Help, ["init"] => Command::Init("./assistant".to_string()), ["init", path] => Command::Init((*path).to_string()), ["doctor"] => Command::Doctor, @@ -202,7 +204,7 @@ impl Args { ["job", "runs"] => Command::Job(JobCommand::Runs(None)), ["job", "runs", name] => Command::Job(JobCommand::Runs(Some((*name).to_string()))), _ => bail!( - "unknown command; expected init [path], doctor, restart, job validate, job list, job show , job run , job runs [], or --config " + "unknown command; expected help, init [path], doctor, restart, job validate, job list, job show , job run , job runs [], or --config " ), }; Ok(Self { @@ -362,6 +364,13 @@ mod tests { #[test] fn parses_help_without_treating_it_as_a_command_argument() { + assert_eq!( + Args::parse(vec!["help".into()]).unwrap(), + Args { + command: Command::Help, + config_path: DEFAULT_CONFIG_PATH.to_string(), + } + ); assert_eq!( Args::parse(vec!["--help".into()]).unwrap(), Args { diff --git a/tests/init_cli.rs b/tests/init_cli.rs index df8751c..b6cfaa4 100644 --- a/tests/init_cli.rs +++ b/tests/init_cli.rs @@ -3,27 +3,29 @@ use std::process::Command; use uuid::Uuid; #[test] -fn init_help_prints_usage_without_creating_files() { +fn help_commands_print_usage_without_creating_files() { let root = temp_dir("help"); let home = root.join("home"); let workdir = root.join("workdir"); std::fs::create_dir_all(&home).unwrap(); std::fs::create_dir_all(&workdir).unwrap(); - let output = Command::new(env!("CARGO_BIN_EXE_push")) - .args(["init", "--help"]) - .current_dir(&workdir) - .env("HOME", &home) - .output() - .unwrap(); - - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - assert!(String::from_utf8_lossy(&output.stdout).contains("Usage: push")); - assert!(output.stderr.is_empty()); + for args in [&["help"][..], &["init", "--help"][..]] { + let output = Command::new(env!("CARGO_BIN_EXE_push")) + .args(args) + .current_dir(&workdir) + .env("HOME", &home) + .output() + .unwrap(); + + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(String::from_utf8_lossy(&output.stdout).contains("Usage: push")); + assert!(output.stderr.is_empty()); + } assert_eq!(std::fs::read_dir(&workdir).unwrap().count(), 0); assert_eq!(std::fs::read_dir(&home).unwrap().count(), 0); let _ = std::fs::remove_dir_all(root); From 580e6e1c78423c2206cf5e973933ba2d597d9a4d Mon Sep 17 00:00:00 2001 From: Owain Lewis Date: Thu, 16 Jul 2026 11:00:22 +0100 Subject: [PATCH 3/3] fix(cli): include restart in help --- src/main.rs | 1 + tests/init_cli.rs | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index 849e9f3..6402dcb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -38,6 +38,7 @@ Commands: help Print this help init [path] Create an assistant repository (default: ./assistant) doctor Validate the configuration and dependencies + restart Restart the installed gateway service job validate Validate all installed jobs job list List installed jobs job show Show an installed job diff --git a/tests/init_cli.rs b/tests/init_cli.rs index b6cfaa4..bbb2231 100644 --- a/tests/init_cli.rs +++ b/tests/init_cli.rs @@ -23,7 +23,9 @@ fn help_commands_print_usage_without_creating_files() { "{}", String::from_utf8_lossy(&output.stderr) ); - assert!(String::from_utf8_lossy(&output.stdout).contains("Usage: push")); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Usage: push")); + assert!(stdout.contains("restart")); assert!(output.stderr.is_empty()); } assert_eq!(std::fs::read_dir(&workdir).unwrap().count(), 0);