diff --git a/PRD.md b/PRD.md index 3adafd7..66ceb2c 100644 --- a/PRD.md +++ b/PRD.md @@ -813,6 +813,12 @@ JSON mode is a strict framing protocol: - An error includes the path, byte size, and content digest of its Diagnostic Log when one is available. +CLI discovery is the sole framing exception. `ghidr --help`, every +`ghidr --help`, and `ghidr --version` are successful metadata +requests rather than operations: they emit plain UTF-8 text on stdout, leave +stderr empty, exit with status 0, and never initialize Ghidra, access the +Analysis Store, or execute a command. + Human-readable mode may present progress and concise diagnostics on stderr. Every Ghidra/adapter invocation captures diagnostics into a private temporary diff --git a/README.md b/README.md index 78d22a4..1c89d68 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,17 @@ Each command will identify the sample by its content, reuse compatible cached analysis, and produce versioned JSON by default. Human-readable presentation is available explicitly through `--format human`. +Agents can discover the complete command surface without initializing Ghidra: + +```console +ghidr --help +ghidr decompile --help +ghidr --version +``` + +These discovery requests return plain text on stdout with exit status 0 and +leave stderr empty. + ## Documents - [PRD.md](./PRD.md) defines the product requirements and initial delivery diff --git a/docs/adr/0003-default-to-json-everywhere.md b/docs/adr/0003-default-to-json-everywhere.md index d0fbf78..99a1f38 100644 --- a/docs/adr/0003-default-to-json-everywhere.md +++ b/docs/adr/0003-default-to-json-everywhere.md @@ -11,3 +11,10 @@ whether stdout is a terminal or pipe; human-readable presentation requires command has the same framing and semantics in every execution environment. JSON mode never emits interactive prompts; required confirmation is represented as a structured error and satisfied by an explicit flag. + +Clap discovery requests are intentionally outside the operation-result +protocol. Root and subcommand `--help`, plus `--version`, return conventional +plain UTF-8 text on stdout with status 0 and no stderr. They do not initialize +the runtime or execute an operation. This narrow exception keeps the CLI +self-describing for both people and automation agents without weakening JSON +framing for analysis and cleanup commands. diff --git a/src/output.rs b/src/output.rs index 94a7f33..4cba2ec 100644 --- a/src/output.rs +++ b/src/output.rs @@ -5,7 +5,7 @@ use std::{ io::{self, Write}, }; -use clap::Parser as _; +use clap::{Parser as _, error::ErrorKind}; use serde_json::Value; use crate::{ @@ -46,6 +46,15 @@ where let arguments: Vec = arguments.into_iter().map(Into::into).collect(); match Cli::try_parse_from(arguments.clone()) { Ok(cli) => run_parsed(&cli, executor, stdout, stderr), + Err(error) + if matches!( + error.kind(), + ErrorKind::DisplayHelp | ErrorKind::DisplayVersion + ) => + { + stdout.write_all(error.to_string().as_bytes())?; + Ok(ExitStatus::Success) + } Err(error) => { if requests_human_format(&arguments) { stderr.write_all(error.to_string().as_bytes())?; @@ -173,6 +182,35 @@ mod tests { assert_eq!(document["error"]["code"], "invalid_arguments"); } + #[test] + fn discovery_requests_use_plain_stdout_and_succeed() { + for (arguments, expected) in [ + (&["ghidr", "--help"][..], "Usage: ghidr"), + ( + &["ghidr", "decompile", "--help"][..], + "Usage: ghidr decompile", + ), + (&["ghidr", "--version"][..], "ghidr 0.1.0"), + ] { + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let status = run_from( + arguments.iter().copied(), + &SuccessExecutor, + &mut stdout, + &mut stderr, + ) + .expect("write succeeds"); + assert_eq!(status, ExitStatus::Success); + assert!(stderr.is_empty()); + assert!( + String::from_utf8(stdout) + .expect("help is UTF-8") + .contains(expected) + ); + } + } + #[test] fn oversized_executor_success_cannot_escape_inline_bound() { let mut stdout = Vec::new(); diff --git a/tests/cli.rs b/tests/cli.rs index ace59a9..ec773d8 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -10,6 +10,31 @@ use std::{ }; use rustix::process::{Pid, Signal, kill_process}; + +#[test] +fn help_and_version_are_successful_discovery_output() { + let cases = [ + (vec!["--help"], "Usage: ghidr"), + (vec!["doctor", "--help"], "Usage: ghidr doctor"), + (vec!["inspect", "--help"], "Usage: ghidr inspect"), + (vec!["functions", "--help"], "Usage: ghidr functions"), + (vec!["decompile", "--help"], "Usage: ghidr decompile"), + (vec!["clean", "--help"], "Usage: ghidr clean"), + (vec!["--version"], "ghidr 0.1.0"), + ]; + + for (arguments, expected) in cases { + let output = Command::new(env!("CARGO_BIN_EXE_ghidr")) + .args(arguments) + .output() + .expect("run discovery request"); + assert_eq!(output.status.code(), Some(0), "{:?}", output.stderr); + assert!(output.stderr.is_empty()); + let stdout = String::from_utf8(output.stdout).expect("discovery output is UTF-8"); + assert!(stdout.contains(expected), "{stdout:?}"); + } +} + #[test] fn doctor_reports_ready_with_closed_runtime_paths() { let temp = tempfile::tempdir().expect("temp");