fix: make help and version successful discovery
Some checks failed
checks / flake (push) Has been cancelled

This commit is contained in:
hermes 2026-07-28 19:26:43 +00:00
commit 5c81b469e5
5 changed files with 88 additions and 1 deletions

6
PRD.md
View file

@ -813,6 +813,12 @@ JSON mode is a strict framing protocol:
- An error includes the path, byte size, and content digest of its Diagnostic - An error includes the path, byte size, and content digest of its Diagnostic
Log when one is available. Log when one is available.
CLI discovery is the sole framing exception. `ghidr --help`, every
`ghidr <command> --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. Human-readable mode may present progress and concise diagnostics on stderr.
Every Ghidra/adapter invocation captures diagnostics into a private temporary Every Ghidra/adapter invocation captures diagnostics into a private temporary

View file

@ -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 analysis, and produce versioned JSON by default. Human-readable presentation is
available explicitly through `--format human`. 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 ## Documents
- [PRD.md](./PRD.md) defines the product requirements and initial delivery - [PRD.md](./PRD.md) defines the product requirements and initial delivery

View file

@ -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. command has the same framing and semantics in every execution environment.
JSON mode never emits interactive prompts; required confirmation is represented JSON mode never emits interactive prompts; required confirmation is represented
as a structured error and satisfied by an explicit flag. 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.

View file

@ -5,7 +5,7 @@ use std::{
io::{self, Write}, io::{self, Write},
}; };
use clap::Parser as _; use clap::{Parser as _, error::ErrorKind};
use serde_json::Value; use serde_json::Value;
use crate::{ use crate::{
@ -46,6 +46,15 @@ where
let arguments: Vec<OsString> = arguments.into_iter().map(Into::into).collect(); let arguments: Vec<OsString> = arguments.into_iter().map(Into::into).collect();
match Cli::try_parse_from(arguments.clone()) { match Cli::try_parse_from(arguments.clone()) {
Ok(cli) => run_parsed(&cli, executor, stdout, stderr), 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) => { Err(error) => {
if requests_human_format(&arguments) { if requests_human_format(&arguments) {
stderr.write_all(error.to_string().as_bytes())?; stderr.write_all(error.to_string().as_bytes())?;
@ -173,6 +182,35 @@ mod tests {
assert_eq!(document["error"]["code"], "invalid_arguments"); 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] #[test]
fn oversized_executor_success_cannot_escape_inline_bound() { fn oversized_executor_success_cannot_escape_inline_bound() {
let mut stdout = Vec::new(); let mut stdout = Vec::new();

View file

@ -10,6 +10,31 @@ use std::{
}; };
use rustix::process::{Pid, Signal, kill_process}; 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] #[test]
fn doctor_reports_ready_with_closed_runtime_paths() { fn doctor_reports_ready_with_closed_runtime_paths() {
let temp = tempfile::tempdir().expect("temp"); let temp = tempfile::tempdir().expect("temp");