diff --git a/CLAUDE.md b/CLAUDE.md index 747f516..cc4c8d5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1 +1,29 @@ -See @AGENTS.md \ No newline at end of file +# ghidra-cli Navigation Index + +See @AGENTS.md for agent-specific instructions. + +## Key Files + +| What | When | +|------|------| +| `src/main.rs` | Modifying CLI entry point, daemon lifecycle, or output format detection | +| `src/cli.rs` | Adding/modifying CLI arguments and subcommands | +| `src/format/mod.rs` | Implementing new output formats or changing format detection logic | +| `src/daemon/handlers/*.rs` | Implementing daemon command handlers | +| `PLAN.md` | Understanding current implementation plan or reviewing decision rationale | +| `README.md` | Understanding project architecture or user-facing command documentation | + +## Modules + +| What | When | +|------|------| +| `src/daemon/` | Working with persistent Ghidra daemon or IPC communication | +| `src/format/` | Handling output format conversion (Table, Compact, JSON, CSV, etc.) | +| `tests/` | Writing integration or unit tests | + +## Documentation + +| What | When | +|------|------| +| `src/daemon/README.md` | Understanding daemon architecture and IPC protocol | +| `tests/README.md` | Understanding test structure and conventions | diff --git a/README.md b/README.md index 1938ab6..cafec53 100644 --- a/README.md +++ b/README.md @@ -196,20 +196,26 @@ ghidra daemon restart --project myproject --program otherbinary ## Output Formats -```bash -# Human-readable (default) -ghidra function list +Default output adapts to context: +- **Interactive (TTY)**: Compact human-readable format +- **Piped/scripted**: Compact JSON for machine parsing -# JSON output +Override with flags: +```bash +# Force JSON output (compact, single-line) ghidra function list --json -# Pretty JSON +# Force pretty JSON (indented, multi-line) ghidra function list --pretty # Select specific fields ghidra function list --fields "name,address,size" ``` +### Output Format Design + +Format detection occurs at the CLI boundary rather than in daemon handlers. Handlers always return compact JSON for IPC efficiency and caching stability. The CLI applies format transformation (human-readable, pretty JSON) at the output boundary based on TTY detection or explicit flags. This design maintains a stable IPC protocol with a single format decision point, preventing daemon cache invalidation from format variations. + ## Filtering Use expressions to filter results: diff --git a/src/cli.rs b/src/cli.rs index 828eefe..74f75bd 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -15,6 +15,14 @@ pub struct Cli { /// Suppress non-essential output #[arg(short, long, global = true)] pub quiet: bool, + + /// Output as JSON + #[arg(long, global = true)] + pub json: bool, + + /// Output JSON with pretty formatting + #[arg(long, global = true)] + pub pretty: bool, } #[derive(Subcommand, Clone, Serialize, Deserialize, Debug)] diff --git a/src/daemon/handlers/batch.rs b/src/daemon/handlers/batch.rs index 8be11fc..5c5e1d2 100644 --- a/src/daemon/handlers/batch.rs +++ b/src/daemon/handlers/batch.rs @@ -40,7 +40,7 @@ pub async fn handle_batch(file_path: &str) -> Result { "results": results }); - serde_json::to_string_pretty(&response) + serde_json::to_string(&response) .context("Failed to serialize batch results") } diff --git a/src/daemon/handlers/comments.rs b/src/daemon/handlers/comments.rs index 97f67b2..202f239 100644 --- a/src/daemon/handlers/comments.rs +++ b/src/daemon/handlers/comments.rs @@ -12,7 +12,7 @@ pub async fn handle_comment_list(bridge: &mut GhidraBridge) -> Result { if response.status == "success" { let data = response.data.unwrap_or(json!({})); - serde_json::to_string_pretty(&data) + serde_json::to_string(&data) .context("Failed to serialize response") } else { let message = response.message.unwrap_or_else(|| "Failed to list comments".to_string()); @@ -31,7 +31,7 @@ pub async fn handle_comment_get( if response.status == "success" { let data = response.data.unwrap_or(json!({})); - serde_json::to_string_pretty(&data) + serde_json::to_string(&data) .context("Failed to serialize response") } else { let message = response.message.unwrap_or_else(|| "Failed to get comment".to_string()); diff --git a/src/daemon/handlers/diff.rs b/src/daemon/handlers/diff.rs index 2067121..bb472ec 100644 --- a/src/daemon/handlers/diff.rs +++ b/src/daemon/handlers/diff.rs @@ -16,7 +16,7 @@ pub async fn handle_diff_programs( if response.status == "success" { let data = response.data.unwrap_or(json!({})); - serde_json::to_string_pretty(&data) + serde_json::to_string(&data) .context("Failed to serialize response") } else { let message = response.message.unwrap_or_else(|| "Failed to diff programs".to_string()); @@ -36,7 +36,7 @@ pub async fn handle_diff_functions( if response.status == "success" { let data = response.data.unwrap_or(json!({})); - serde_json::to_string_pretty(&data) + serde_json::to_string(&data) .context("Failed to serialize response") } else { let message = response.message.unwrap_or_else(|| "Failed to diff functions".to_string()); diff --git a/src/daemon/handlers/disasm.rs b/src/daemon/handlers/disasm.rs index 69e6297..0dc3d33 100644 --- a/src/daemon/handlers/disasm.rs +++ b/src/daemon/handlers/disasm.rs @@ -22,7 +22,7 @@ pub async fn handle_disasm( if response.status == "success" { let data = response.data.unwrap_or(json!({})); - serde_json::to_string_pretty(&data) + serde_json::to_string(&data) .context("Failed to serialize response") } else { let message = response.message.unwrap_or_else(|| "Failed to disassemble".to_string()); diff --git a/src/daemon/handlers/find.rs b/src/daemon/handlers/find.rs index 374b9cf..3625a98 100644 --- a/src/daemon/handlers/find.rs +++ b/src/daemon/handlers/find.rs @@ -15,7 +15,7 @@ pub async fn handle_find_string( if response.status == "success" { let data = response.data.unwrap_or(json!({})); - serde_json::to_string_pretty(&data) + serde_json::to_string(&data) .context("Failed to serialize response") } else { let message = response.message.unwrap_or_else(|| "Failed to find string".to_string()); @@ -34,7 +34,7 @@ pub async fn handle_find_bytes( if response.status == "success" { let data = response.data.unwrap_or(json!({})); - serde_json::to_string_pretty(&data) + serde_json::to_string(&data) .context("Failed to serialize response") } else { let message = response.message.unwrap_or_else(|| "Failed to find bytes".to_string()); @@ -53,7 +53,7 @@ pub async fn handle_find_function( if response.status == "success" { let data = response.data.unwrap_or(json!({})); - serde_json::to_string_pretty(&data) + serde_json::to_string(&data) .context("Failed to serialize response") } else { let message = response.message.unwrap_or_else(|| "Failed to find function".to_string()); @@ -72,7 +72,7 @@ pub async fn handle_find_calls( if response.status == "success" { let data = response.data.unwrap_or(json!({})); - serde_json::to_string_pretty(&data) + serde_json::to_string(&data) .context("Failed to serialize response") } else { let message = response.message.unwrap_or_else(|| "Failed to find calls".to_string()); @@ -88,7 +88,7 @@ pub async fn handle_find_crypto(bridge: &mut GhidraBridge) -> Result { if response.status == "success" { let data = response.data.unwrap_or(json!({})); - serde_json::to_string_pretty(&data) + serde_json::to_string(&data) .context("Failed to serialize response") } else { let message = response.message.unwrap_or_else(|| "Failed to find crypto constants".to_string()); @@ -104,7 +104,7 @@ pub async fn handle_find_interesting(bridge: &mut GhidraBridge) -> Result Result { if response.status == "success" { let data = response.data.unwrap_or(json!({})); - serde_json::to_string_pretty(&data) + serde_json::to_string(&data) .context("Failed to serialize response") } else { let message = response.message.unwrap_or_else(|| "Failed to get program info".to_string()); @@ -73,7 +73,7 @@ pub async fn handle_program_export( if response.status == "success" { let data = response.data.unwrap_or(json!({})); - serde_json::to_string_pretty(&data) + serde_json::to_string(&data) .context("Failed to serialize response") } else { let message = response.message.unwrap_or_else(|| "Failed to export program".to_string()); diff --git a/src/daemon/handlers/script.rs b/src/daemon/handlers/script.rs index c7b4769..6de33a2 100644 --- a/src/daemon/handlers/script.rs +++ b/src/daemon/handlers/script.rs @@ -16,7 +16,7 @@ pub async fn handle_script_run( if response.status == "success" { let data = response.data.unwrap_or(json!({})); - serde_json::to_string_pretty(&data) + serde_json::to_string(&data) .context("Failed to serialize response") } else { let message = response.message.unwrap_or_else(|| "Failed to run script".to_string()); @@ -35,7 +35,7 @@ pub async fn handle_script_python( if response.status == "success" { let data = response.data.unwrap_or(json!({})); - serde_json::to_string_pretty(&data) + serde_json::to_string(&data) .context("Failed to serialize response") } else { let message = response.message.unwrap_or_else(|| "Failed to execute Python code".to_string()); @@ -54,7 +54,7 @@ pub async fn handle_script_java( if response.status == "success" { let data = response.data.unwrap_or(json!({})); - serde_json::to_string_pretty(&data) + serde_json::to_string(&data) .context("Failed to serialize response") } else { let message = response.message.unwrap_or_else(|| "Failed to execute Java code".to_string()); @@ -70,7 +70,7 @@ pub async fn handle_script_list(bridge: &mut GhidraBridge) -> Result { if response.status == "success" { let data = response.data.unwrap_or(json!({})); - serde_json::to_string_pretty(&data) + serde_json::to_string(&data) .context("Failed to serialize response") } else { let message = response.message.unwrap_or_else(|| "Failed to list scripts".to_string()); diff --git a/src/daemon/handlers/stats.rs b/src/daemon/handlers/stats.rs index da64cef..62e1240 100644 --- a/src/daemon/handlers/stats.rs +++ b/src/daemon/handlers/stats.rs @@ -11,7 +11,7 @@ pub async fn handle_stats(bridge: &mut GhidraBridge) -> Result { if response.status == "success" { let data = response.data.unwrap_or(serde_json::json!({})); - serde_json::to_string_pretty(&data) + serde_json::to_string(&data) .context("Failed to serialize response") } else { let message = response.message.unwrap_or_else(|| "Failed to get program statistics".to_string()); diff --git a/src/daemon/handlers/symbols.rs b/src/daemon/handlers/symbols.rs index 7dca4c9..57c650e 100644 --- a/src/daemon/handlers/symbols.rs +++ b/src/daemon/handlers/symbols.rs @@ -21,7 +21,7 @@ pub async fn handle_symbol_list( if response.status == "success" { let data = response.data.unwrap_or(json!({})); - serde_json::to_string_pretty(&data) + serde_json::to_string(&data) .context("Failed to serialize response") } else { let message = response.message.unwrap_or_else(|| "Failed to list symbols".to_string()); @@ -40,7 +40,7 @@ pub async fn handle_symbol_get( if response.status == "success" { let data = response.data.unwrap_or(json!({})); - serde_json::to_string_pretty(&data) + serde_json::to_string(&data) .context("Failed to serialize response") } else { let message = response.message.unwrap_or_else(|| "Failed to get symbol".to_string()); diff --git a/src/daemon/handlers/types.rs b/src/daemon/handlers/types.rs index a284ca2..6ba2de1 100644 --- a/src/daemon/handlers/types.rs +++ b/src/daemon/handlers/types.rs @@ -12,7 +12,7 @@ pub async fn handle_type_list(bridge: &mut GhidraBridge) -> Result { if response.status == "success" { let data = response.data.unwrap_or(json!({})); - serde_json::to_string_pretty(&data) + serde_json::to_string(&data) .context("Failed to serialize response") } else { let message = response.message.unwrap_or_else(|| "Failed to list types".to_string()); @@ -31,7 +31,7 @@ pub async fn handle_type_get( if response.status == "success" { let data = response.data.unwrap_or(json!({})); - serde_json::to_string_pretty(&data) + serde_json::to_string(&data) .context("Failed to serialize response") } else { let message = response.message.unwrap_or_else(|| "Failed to get type".to_string()); diff --git a/src/format/mod.rs b/src/format/mod.rs index 5474096..4036222 100644 --- a/src/format/mod.rs +++ b/src/format/mod.rs @@ -91,6 +91,7 @@ impl Formatter for DefaultFormatter { OutputFormat::Tsv => { format_csv(data, '\t') } + OutputFormat::Compact => format_minimal(data), OutputFormat::Minimal | OutputFormat::Ids => { format_minimal(data) } @@ -232,7 +233,7 @@ fn format_json_value(value: &JsonValue) -> String { pub fn auto_detect_format(is_tty: bool) -> OutputFormat { if is_tty { - OutputFormat::Table + OutputFormat::Compact } else { OutputFormat::JsonCompact } diff --git a/src/main.rs b/src/main.rs index 712d3ce..7c76e55 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,6 +17,8 @@ use error::{GhidraError, Result}; use ghidra::GhidraClient; use std::path::PathBuf; use tracing::info; +use atty; +use format::{auto_detect_format, DefaultFormatter, Formatter, OutputFormat}; #[tokio::main] @@ -121,27 +123,18 @@ async fn run_with_daemon_check(cli: Cli) -> anyhow::Result<()> { } let config = Config::load()?; - let project_path = match &cli.command { - Commands::Import(args) => { - resolve_project_path(&args.project, &config)? - } - Commands::Analyze(args) => { - resolve_project_path(&args.project, &config)? - } - Commands::Quick(args) => { - resolve_project_path(&args.project, &config)? - } - _ => { - resolve_project_path(&None, &config)? - } - }; - ensure_daemon_running(&project_path).await?; + // Extract project and program from command arguments + let (project_opt, program_opt) = extract_project_program(&cli.command); + let project_path = resolve_project_path(&project_opt, &config)?; + let program_name = program_opt.or(config.default_program.clone()); + + ensure_daemon_running(&project_path, program_name.as_deref()).await?; match ipc::client::DaemonClient::connect().await { Ok(mut client) => { info!("Connected to daemon via IPC"); - let output = execute_via_daemon(&mut client, &cli.command).await?; + let output = execute_via_daemon(&mut client, &cli.command, cli.json, cli.pretty).await?; if !output.is_empty() { println!("{}", output); } @@ -193,6 +186,8 @@ async fn ensure_daemon_running(project_path: &PathBuf) -> anyhow::Result<()> { async fn execute_via_daemon( client: &mut ipc::client::DaemonClient, command: &Commands, + json_flag: bool, + pretty_flag: bool, ) -> anyhow::Result { let result = match command { Commands::Import(args) => { @@ -339,8 +334,23 @@ async fn execute_via_daemon( _ => anyhow::bail!("Command not supported via daemon"), }; - // Format the JSON output nicely - serde_json::to_string_pretty(&result).map_err(Into::into) + // Determine output format based on flags and TTY detection + let format = if pretty_flag { + OutputFormat::Json + } else if json_flag { + OutputFormat::JsonCompact + } else { + auto_detect_format(atty::is(atty::Stream::Stdout)) + }; + + // Detect if result is already an array before wrapping + let values = match result { + serde_json::Value::Array(arr) => arr, + single => vec![single], + }; + + let formatter = DefaultFormatter; + formatter.format(&values, format).map_err(Into::into) } /// Handle daemon management commands. diff --git a/tests/output_format_integration.rs b/tests/output_format_integration.rs new file mode 100644 index 0000000..8de8651 --- /dev/null +++ b/tests/output_format_integration.rs @@ -0,0 +1,52 @@ +//! Integration tests for output format +//! Tests require real Ghidra installation + +use assert_cmd::Command; + +/// Helper to verify Ghidra is installed before running tests +fn require_ghidra() { + let output = Command::cargo_bin("ghidra") + .unwrap() + .arg("doctor") + .output() + .expect("Failed to run ghidra doctor"); + + if !output.status.success() { + panic!("Ghidra is not installed. Tests require Ghidra installation per AGENTS.md"); + } +} + +#[test] +fn test_format_detection_tty() { + require_ghidra(); + + // Test that --help shows both --json and --pretty flags + let mut cmd = Command::cargo_bin("ghidra").unwrap(); + cmd.arg("--help"); + cmd.assert().success(); + + let output = cmd.output().unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("--json"), "Help should show --json flag"); + assert!(stdout.contains("--pretty"), "Help should show --pretty flag"); +} + +#[test] +fn test_json_flag() { + require_ghidra(); + + // Test --json flag is recognized + let mut cmd = Command::cargo_bin("ghidra").unwrap(); + cmd.arg("--json").arg("--help"); + cmd.assert().success(); +} + +#[test] +fn test_pretty_flag() { + require_ghidra(); + + // Test --pretty flag is recognized + let mut cmd = Command::cargo_bin("ghidra").unwrap(); + cmd.arg("--pretty").arg("--help"); + cmd.assert().success(); +}