diff --git a/Cargo.lock b/Cargo.lock index 3822dc9..14bbaa3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -397,6 +397,17 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -1530,6 +1541,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e712f64ec3850b98572bffac52e2c6f282b29fe6c5fa6d42334b30be438d95c1" +[[package]] +name = "hifijson" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "242402749acf71e6f32f5857598b7002c4058a4e3c3b22b4c7d51cab9aea754e" + [[package]] name = "hkdf" version = "0.13.0" @@ -1907,6 +1924,95 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jaq-core" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7561783b20275a6c9cb576e39208b0c635f34ef14357f1f05a2927a774f3adec" +dependencies = [ + "dyn-clone", + "once_cell", + "typed-arena", +] + +[[package]] +name = "jaq-json" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ec9aaad7340e6990c6c1878ef3b46dbec624e535d7f786cc9ddcf94f773d33" +dependencies = [ + "bstr", + "bytes", + "foldhash 0.1.5", + "hifijson", + "indexmap 2.14.0", + "jaq-core", + "jaq-std", + "num-bigint", + "num-traits", + "ryu", + "self_cell", + "serde_core", +] + +[[package]] +name = "jaq-std" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bdc5a74b0feeb5e6a1dc2dd08c34280a61e37668d10a6a3b27ad69d0fb9ce2e" +dependencies = [ + "aho-corasick", + "base64 0.22.1", + "bstr", + "jaq-core", + "jiff", + "libm", + "log", + "regex-bites", + "urlencoding", +] + +[[package]] +name = "jiff" +version = "0.2.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4603d3033e49e2b0e31229fcab20a5d40089c607d975cd9c80551dc69eed9102" +dependencies = [ + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-static" +version = "0.2.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "782d32378dddf207193ac91cefb848ad41abb58195c95168e1291227a0832b47" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c900ef84826f1338a557697dc8fc601df9ca9af4ac137c7fb61d4c6f2dfd3076" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "jni" version = "0.21.1" @@ -2757,6 +2863,21 @@ dependencies = [ "universal-hash 0.6.1", ] +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -2860,6 +2981,9 @@ dependencies = [ "futures-util", "getrandom 0.4.2", "http", + "jaq-core", + "jaq-json", + "jaq-std", "js-sys", "jsonschema", "keyring", @@ -3125,6 +3249,12 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-bites" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6a15a2fa0bfda9361941c45550896ae87b15cc6c8c939ea350079670332e211" + [[package]] name = "regex-syntax" version = "0.8.10" @@ -3662,6 +3792,12 @@ dependencies = [ "libc", ] +[[package]] +name = "self_cell" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" + [[package]] name = "semver" version = "1.0.28" @@ -4411,6 +4547,12 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "typed-arena" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" + [[package]] name = "typenum" version = "1.20.0" @@ -4503,6 +4645,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf8_iter" version = "1.0.4" diff --git a/Cargo.toml b/Cargo.toml index 1fbf1c4..af67f35 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -158,6 +158,9 @@ serde-wasm-bindgen = { version = "0.6", optional = true } # RNG support in browser WASM (getrandom 0.4 — wasm_js feature enables JS crypto API) getrandom = { version = "0.4", features = ["wasm_js"], optional = true } jsonschema = { version = "0.46.5", default-features = false } +jaq-core = "3.1.0" +jaq-std = "3.0.1" +jaq-json = { version = "2.0.1", features = ["serde"] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] reqwest = { version = ">=0.13, <0.13.3", features = ["stream"] } diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index bfd1a51..e106909 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -219,11 +219,52 @@ Available on all commands: --config string Config file path (default: ~/.config/pup/config.yaml) --site string Datadog site (default: datadoghq.com) --output string Output format: json, yaml, table (default: json) +--jq string Filter/transform output with a jq expression (applied before formatting) --verbose Enable verbose logging --yes Skip confirmation prompts --read-only Block all write operations (create, update, delete) ``` +### `--jq` filtering + +`--jq` applies a [jq](https://jqlang.github.io/jq/) expression to the raw JSON response +**before** output formatting, so it works with every `-o` format: + +```bash +# Extract a single field across all monitors +pup monitors list --jq '.[].name' + +# Select matching records and then format as a table +pup monitors list --jq '.[] | select(.name | endswith("prod"))' -o table + +# Compose with other jq features +pup logs search --query="status:error" --jq '.data | length' +``` + +**Cardinality:** the jq expression may produce a stream of values. +- 0 outputs → `null` +- 1 output → the value (unwrapped) +- 2+ outputs → an array + +**Agent mode — filter target:** `--jq` runs on the **raw response payload**, which +is the value that appears under `.data` in agent mode. Write expressions against the +payload (e.g. `.[]`), **not** against the envelope (`.data[]` will not work): + +```bash +# correct — targets the payload array +pup monitors list --agent --jq '.[0]' + +# wrong — .data does not exist in the payload --jq sees +pup monitors list --agent --jq '.data[0]' +``` + +**Agent mode — metadata:** when `--jq` is active, `metadata.count` and +`metadata.truncated` are omitted from the envelope because they describe the +pre-filter data, not the filtered result. + +**Limitation:** commands that print output directly (e.g. `pup auth login`, some runbook +steps) bypass `format_and_print` and do not honor `--jq`. + ## Recent Enhancements ### v0.64.x — Error Tracking Issue Filters (SDK PRs #1568, #1480) diff --git a/src/api.rs b/src/api.rs index 675b962..b461993 100644 --- a/src/api.rs +++ b/src/api.rs @@ -135,6 +135,7 @@ mod tests { auto_approve: false, agent_mode: false, read_only: false, + jq: None, }; let mock = server @@ -170,6 +171,7 @@ mod tests { auto_approve: false, agent_mode: false, read_only: false, + jq: None, }; let mock = server @@ -209,6 +211,7 @@ mod tests { auto_approve: false, agent_mode: false, read_only: false, + jq: None, }; let mock = server @@ -243,6 +246,7 @@ mod tests { auto_approve: false, agent_mode: false, read_only: false, + jq: None, }; let mock = server @@ -277,6 +281,7 @@ mod tests { auto_approve: false, agent_mode: false, read_only: false, + jq: None, }; let mock = server @@ -311,6 +316,7 @@ mod tests { auto_approve: false, agent_mode: false, read_only: false, + jq: None, }; let mock = server @@ -344,6 +350,7 @@ mod tests { auto_approve: false, agent_mode: false, read_only: false, + jq: None, }; let mock = server @@ -378,6 +385,7 @@ mod tests { auto_approve: false, agent_mode: false, read_only: false, + jq: None, }; let mock = server @@ -410,6 +418,7 @@ mod tests { auto_approve: false, agent_mode: false, read_only: false, + jq: None, }; let result = super::get(&cfg, "/api/v1/test", &[]).await; @@ -438,6 +447,7 @@ mod tests { auto_approve: false, agent_mode: false, read_only: false, + jq: None, }; let mock = server @@ -472,6 +482,7 @@ mod tests { auto_approve: false, agent_mode: false, read_only: false, + jq: None, }; let mock = server diff --git a/src/client.rs b/src/client.rs index 4ba6a2d..95098fe 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1117,6 +1117,7 @@ mod tests { auto_approve: false, agent_mode: false, read_only: false, + jq: None, } } diff --git a/src/commands/alias.rs b/src/commands/alias.rs index fc216a0..a79ee39 100644 --- a/src/commands/alias.rs +++ b/src/commands/alias.rs @@ -45,7 +45,13 @@ pub fn list(cfg: &crate::config::Config) -> Result<()> { .iter() .map(|(name, command)| serde_json::json!({"name": name, "command": command})) .collect(); - crate::formatter::format_and_print(&items, &cfg.output_format, cfg.agent_mode, None)?; + crate::formatter::format_and_print( + &items, + &cfg.output_format, + cfg.agent_mode, + None, + cfg.jq.as_deref(), + )?; } } Ok(()) diff --git a/src/commands/api.rs b/src/commands/api.rs index ef6de6d..20b3761 100644 --- a/src/commands/api.rs +++ b/src/commands/api.rs @@ -247,7 +247,13 @@ pub async fn run( if let Ok(json) = serde_json::from_slice::(&body_bytes) { // Render through the shared formatter so `--output`/agent mode are // honored, matching every other pup command. - crate::formatter::format_and_print(&json, &cfg.output_format, cfg.agent_mode, None)?; + crate::formatter::format_and_print( + &json, + &cfg.output_format, + cfg.agent_mode, + None, + cfg.jq.as_deref(), + )?; } else { print!("{}", String::from_utf8_lossy(&body_bytes)); } @@ -709,6 +715,7 @@ mod tests { auto_approve: false, agent_mode: false, read_only: false, + jq: None, }; assert!(targets_configured_host( "https://api.datadoghq.com/api/v2/monitors", @@ -777,6 +784,7 @@ mod tests { auto_approve: false, agent_mode: false, read_only: false, + jq: None, }; let _mock = server .mock("GET", "/api/v2/api_keys") @@ -831,6 +839,7 @@ mod tests { auto_approve: false, agent_mode: false, read_only: false, + jq: None, }; let _mock = server .mock("GET", "/api/v2/monitors") diff --git a/src/commands/auth.rs b/src/commands/auth.rs index 9683b87..1ea3cc0 100644 --- a/src/commands/auth.rs +++ b/src/commands/auth.rs @@ -656,6 +656,7 @@ mod tests { auto_approve: false, agent_mode: false, read_only: false, + jq: None, } } diff --git a/src/commands/cost_ccm.rs b/src/commands/cost_ccm.rs index c6df9e9..87aa057 100644 --- a/src/commands/cost_ccm.rs +++ b/src/commands/cost_ccm.rs @@ -612,6 +612,7 @@ mod tests { auto_approve: false, agent_mode: false, read_only: false, + jq: None, } } diff --git a/src/commands/ddsql.rs b/src/commands/ddsql.rs index cbafd43..4c68614 100644 --- a/src/commands/ddsql.rs +++ b/src/commands/ddsql.rs @@ -219,7 +219,13 @@ fn output_items( command: None, next_action, }; - formatter::format_and_print(items, &cfg.output_format, cfg.agent_mode, Some(&meta)) + formatter::format_and_print( + items, + &cfg.output_format, + cfg.agent_mode, + Some(&meta), + cfg.jq.as_deref(), + ) } fn parse_ddsql_docs(resp: Value) -> Result { diff --git a/src/commands/debugger.rs b/src/commands/debugger.rs index 943e55f..2a04313 100644 --- a/src/commands/debugger.rs +++ b/src/commands/debugger.rs @@ -717,6 +717,7 @@ mod tests { auto_approve: false, agent_mode: false, read_only: false, + jq: None, } } diff --git a/src/commands/events.rs b/src/commands/events.rs index 37b6faa..f449c5b 100644 --- a/src/commands/events.rs +++ b/src/commands/events.rs @@ -144,6 +144,7 @@ mod tests { auto_approve: false, agent_mode: false, read_only: false, + jq: None, }; let result = diff --git a/src/commands/extension.rs b/src/commands/extension.rs index 423ec6a..5d6912a 100644 --- a/src/commands/extension.rs +++ b/src/commands/extension.rs @@ -20,6 +20,7 @@ pub fn list(cfg: &Config) -> Result<()> { &cfg.output_format, cfg.agent_mode, None, + cfg.jq.as_deref(), )?; } } @@ -50,7 +51,13 @@ pub fn list(cfg: &Config) -> Result<()> { }) }) .collect(); - crate::formatter::format_and_print(&items, &cfg.output_format, cfg.agent_mode, None)?; + crate::formatter::format_and_print( + &items, + &cfg.output_format, + cfg.agent_mode, + None, + cfg.jq.as_deref(), + )?; } } Ok(()) diff --git a/src/commands/format.rs b/src/commands/format.rs index 6bc4cac..21c4b8a 100644 --- a/src/commands/format.rs +++ b/src/commands/format.rs @@ -69,7 +69,13 @@ fn render( None }; - formatter::format_and_print(&value, &cfg.output_format, cfg.agent_mode, meta.as_ref()) + formatter::format_and_print( + &value, + &cfg.output_format, + cfg.agent_mode, + meta.as_ref(), + cfg.jq.as_deref(), + ) } #[cfg(test)] @@ -126,6 +132,7 @@ mod tests { auto_approve: false, agent_mode, read_only: false, + jq: None, } } diff --git a/src/commands/idp/mod.rs b/src/commands/idp/mod.rs index 58f39e6..e25ff6f 100644 --- a/src/commands/idp/mod.rs +++ b/src/commands/idp/mod.rs @@ -517,7 +517,13 @@ pub async fn assist(cfg: &Config, entity: &str) -> Result<()> { )), }; - formatter::format_and_print(&response, &cfg.output_format, cfg.agent_mode, Some(&meta)) + formatter::format_and_print( + &response, + &cfg.output_format, + cfg.agent_mode, + Some(&meta), + cfg.jq.as_deref(), + ) } /// Find entities matching a query. @@ -541,7 +547,13 @@ pub async fn find(cfg: &Config, query: &str) -> Result<()> { ), }; - formatter::format_and_print(&data, &cfg.output_format, cfg.agent_mode, Some(&meta)) + formatter::format_and_print( + &data, + &cfg.output_format, + cfg.agent_mode, + Some(&meta), + cfg.jq.as_deref(), + ) } /// Resolve owner, team, and on-call context for an entity. @@ -587,7 +599,13 @@ pub async fn owner(cfg: &Config, entity: &str) -> Result<()> { next_action: None, }; - formatter::format_and_print(&response, &cfg.output_format, cfg.agent_mode, Some(&meta)) + formatter::format_and_print( + &response, + &cfg.output_format, + cfg.agent_mode, + Some(&meta), + cfg.jq.as_deref(), + ) } /// Show dependency and relationship context for an entity. @@ -611,7 +629,13 @@ pub async fn deps(cfg: &Config, entity: &str) -> Result<()> { next_action: Some("Use `pup idp assist ` to inspect any dependency".to_string()), }; - formatter::format_and_print(&response, &cfg.output_format, cfg.agent_mode, Some(&meta)) + formatter::format_and_print( + &response, + &cfg.output_format, + cfg.agent_mode, + Some(&meta), + cfg.jq.as_deref(), + ) } /// Register a service definition from a YAML file. @@ -641,7 +665,13 @@ pub async fn register(cfg: &Config, file: &str) -> Result<()> { )), }; - formatter::format_and_print(&data, &cfg.output_format, cfg.agent_mode, Some(&meta)) + formatter::format_and_print( + &data, + &cfg.output_format, + cfg.agent_mode, + Some(&meta), + cfg.jq.as_deref(), + ) } #[cfg(test)] diff --git a/src/commands/llm_obs.rs b/src/commands/llm_obs.rs index af05a95..d3d8738 100644 --- a/src/commands/llm_obs.rs +++ b/src/commands/llm_obs.rs @@ -774,6 +774,7 @@ mod tests { auto_approve: false, agent_mode: false, read_only: false, + jq: None, }; let result = super::projects_list(&cfg).await; assert!(result.is_err(), "should fail without auth"); @@ -1186,6 +1187,7 @@ mod tests { auto_approve: false, agent_mode: false, read_only: false, + jq: None, }; let result = super::datasets_create(&cfg, "proj-1", tmp.to_str().unwrap()).await; assert!(result.is_err(), "should fail without auth"); @@ -1272,6 +1274,7 @@ mod tests { auto_approve: false, agent_mode: false, read_only: false, + jq: None, }; let result = super::experiments_summary(&cfg, "exp-1").await; @@ -1420,6 +1423,7 @@ mod tests { auto_approve: false, agent_mode: false, read_only: false, + jq: None, }; let result = super::experiments_events_get(&cfg, "exp-1", "evt-1").await; @@ -1856,6 +1860,7 @@ mod tests { auto_approve: false, agent_mode: false, read_only: false, + jq: None, }; let result = super::spans_search( diff --git a/src/commands/logs.rs b/src/commands/logs.rs index baf5bc4..a4e506b 100644 --- a/src/commands/logs.rs +++ b/src/commands/logs.rs @@ -231,7 +231,13 @@ pub async fn search( } else { None }; - formatter::format_and_print(&resp, &cfg.output_format, cfg.agent_mode, meta.as_ref())?; + formatter::format_and_print( + &resp, + &cfg.output_format, + cfg.agent_mode, + meta.as_ref(), + cfg.jq.as_deref(), + )?; Ok(()) } @@ -683,6 +689,7 @@ mod tests { auto_approve: false, agent_mode: false, read_only: false, + jq: None, }; let _mock = mock_any(&mut server, "POST", r#"{"data": []}"#).await; diff --git a/src/commands/monitors.rs b/src/commands/monitors.rs index daa1856..9edf765 100644 --- a/src/commands/monitors.rs +++ b/src/commands/monitors.rs @@ -47,7 +47,13 @@ pub async fn list( command: Some("monitors list".to_string()), next_action: None, }; - formatter::format_and_print(&monitors, &cfg.output_format, cfg.agent_mode, Some(&meta))?; + formatter::format_and_print( + &monitors, + &cfg.output_format, + cfg.agent_mode, + Some(&meta), + cfg.jq.as_deref(), + )?; Ok(()) } @@ -63,7 +69,13 @@ pub async fn get(cfg: &Config, monitor_id: i64) -> Result<()> { command: Some("monitors get".to_string()), next_action: None, }; - formatter::format_and_print(&resp, &cfg.output_format, cfg.agent_mode, Some(&meta)) + formatter::format_and_print( + &resp, + &cfg.output_format, + cfg.agent_mode, + Some(&meta), + cfg.jq.as_deref(), + ) } pub async fn create(cfg: &Config, file: &str) -> Result<()> { diff --git a/src/commands/skills.rs b/src/commands/skills.rs index c7b2b3b..b01a820 100644 --- a/src/commands/skills.rs +++ b/src/commands/skills.rs @@ -68,7 +68,13 @@ pub fn list(cfg: &crate::config::Config, entry_type: Option) -> Result<( }) .collect(); - crate::formatter::format_and_print(&items, &cfg.output_format, cfg.agent_mode, None)?; + crate::formatter::format_and_print( + &items, + &cfg.output_format, + cfg.agent_mode, + None, + cfg.jq.as_deref(), + )?; Ok(()) } @@ -246,7 +252,13 @@ pub fn install( "directories": directories, "platforms": platforms_hit.iter().collect::>(), }); - crate::formatter::format_and_print(&result, &cfg.output_format, cfg.agent_mode, None)?; + crate::formatter::format_and_print( + &result, + &cfg.output_format, + cfg.agent_mode, + None, + cfg.jq.as_deref(), + )?; } else { for d in &dirs_used { println!(" {d}"); @@ -306,6 +318,7 @@ mod tests { auto_approve: false, agent_mode: false, read_only: false, + jq: None, } } diff --git a/src/commands/traces.rs b/src/commands/traces.rs index 0cdabd8..a7140cc 100644 --- a/src/commands/traces.rs +++ b/src/commands/traces.rs @@ -159,7 +159,13 @@ pub async fn search( } else { None }; - formatter::format_and_print(&resp, &cfg.output_format, cfg.agent_mode, meta.as_ref())?; + formatter::format_and_print( + &resp, + &cfg.output_format, + cfg.agent_mode, + meta.as_ref(), + cfg.jq.as_deref(), + )?; Ok(()) } @@ -217,7 +223,13 @@ pub async fn aggregate( } else { None }; - formatter::format_and_print(&resp, &cfg.output_format, cfg.agent_mode, meta.as_ref())?; + formatter::format_and_print( + &resp, + &cfg.output_format, + cfg.agent_mode, + meta.as_ref(), + cfg.jq.as_deref(), + )?; Ok(()) } diff --git a/src/commands/workflows.rs b/src/commands/workflows.rs index 9a7f8ca..e55ca78 100644 --- a/src/commands/workflows.rs +++ b/src/commands/workflows.rs @@ -189,7 +189,13 @@ pub async fn instance_list(cfg: &Config, workflow_id: &str, limit: i64, page: i6 command: Some("workflows instances list".to_string()), next_action: None, }; - formatter::format_and_print(&resp, &cfg.output_format, cfg.agent_mode, Some(&meta)) + formatter::format_and_print( + &resp, + &cfg.output_format, + cfg.agent_mode, + Some(&meta), + cfg.jq.as_deref(), + ) } pub async fn instance_get(cfg: &Config, workflow_id: &str, instance_id: &str) -> Result<()> { diff --git a/src/config.rs b/src/config.rs index ea8d1ff..1751106 100644 --- a/src/config.rs +++ b/src/config.rs @@ -23,6 +23,8 @@ pub struct Config { pub auto_approve: bool, pub agent_mode: bool, pub read_only: bool, + /// jq expression applied to command output before formatting (`--jq` flag). + pub jq: Option, } #[derive(Clone, Debug, PartialEq)] @@ -173,6 +175,7 @@ impl Config { || env_bool("DD_CLI_READ_ONLY") || env_bool("PUP_READ_ONLY") || file_cfg.read_only.unwrap_or(false), + jq: None, // set by caller from --jq flag }; Ok(cfg) @@ -203,6 +206,7 @@ impl Config { auto_approve: false, agent_mode: false, read_only: false, + jq: None, } } @@ -892,6 +896,7 @@ mod tests { auto_approve: false, agent_mode: false, read_only: false, + jq: None, } } @@ -1772,6 +1777,7 @@ mod tests { auto_approve: false, agent_mode: false, read_only: false, + jq: None, }; super::apply_org_override(&mut cfg, "org-b".into()).unwrap(); @@ -1816,6 +1822,7 @@ mod tests { auto_approve: false, agent_mode: false, read_only: false, + jq: None, }; super::apply_org_override(&mut cfg, "org-a".into()).unwrap(); @@ -1853,6 +1860,7 @@ mod tests { auto_approve: false, agent_mode: false, read_only: false, + jq: None, }; super::apply_org_override(&mut cfg, "unknown-org".into()).unwrap(); @@ -1890,6 +1898,7 @@ mod tests { auto_approve: false, agent_mode: false, read_only: false, + jq: None, }; super::apply_org_override(&mut cfg, "any-org".into()).unwrap(); @@ -1936,6 +1945,7 @@ mod tests { auto_approve: false, agent_mode: false, read_only: false, + jq: None, }; let result = super::apply_org_override(&mut cfg, "bad-org".into()); @@ -1966,6 +1976,7 @@ mod tests { auto_approve: false, agent_mode: false, read_only: false, + jq: None, }; cfg.set_site_explicit("app.datadoghq.eu".into()).unwrap(); @@ -1987,6 +1998,7 @@ mod tests { auto_approve: false, agent_mode: false, read_only: false, + jq: None, }; // site and site_explicit must remain unchanged on failure. assert!(cfg.set_site_explicit("evil.com/path".into()).is_err()); @@ -2007,6 +2019,7 @@ mod tests { auto_approve: false, agent_mode: false, read_only: false, + jq: None, }; // An empty --site must not silently route to datadoghq.com via // normalize_site's empty-string fallback. diff --git a/src/extensions/exec.rs b/src/extensions/exec.rs index c6f1065..fda8889 100644 --- a/src/extensions/exec.rs +++ b/src/extensions/exec.rs @@ -92,4 +92,15 @@ fn inject_auth_env(cmd: &mut std::process::Command, cfg: &Config) { } else { cmd.env_remove("PUP_AGENT_MODE"); } + // Pass --jq expression to extension subprocesses so they can self-apply it. + // Note: pup does not post-filter an extension's stdout; this only lets an + // extension read the expression via PUP_FILTER if it chooses. + match &cfg.jq { + Some(expr) => { + cmd.env("PUP_FILTER", expr); + } + None => { + cmd.env_remove("PUP_FILTER"); + } + } } diff --git a/src/extensions/mod.rs b/src/extensions/mod.rs index 72c94be..58fb273 100644 --- a/src/extensions/mod.rs +++ b/src/extensions/mod.rs @@ -28,6 +28,7 @@ pub(crate) struct PreParsedGlobals { pub agent: bool, pub read_only: bool, pub org: Option, + pub jq: Option, } /// Parse the CLI arguments in a single left-to-right pass. @@ -39,6 +40,7 @@ pub(crate) fn parse_extension_args(args: &[String]) -> ParsedArgs { agent: false, read_only: false, org: None, + jq: None, }; let mut candidate: Option = None; let mut ext_args: Vec = Vec::new(); @@ -63,6 +65,11 @@ pub(crate) fn parse_extension_args(args: &[String]) -> ParsedArgs { globals.org = Some(val.clone()); } } + "--jq" => { + if let Some(val) = iter.next() { + globals.jq = Some(val.clone()); + } + } // Boolean flags "--yes" | "-y" => globals.yes = true, "--agent" => globals.agent = true, @@ -74,6 +81,9 @@ pub(crate) fn parse_extension_args(args: &[String]) -> ParsedArgs { s if s.starts_with("--org=") => { globals.org = Some(s["--org=".len()..].to_string()); } + s if s.starts_with("--jq=") => { + globals.jq = Some(s["--jq=".len()..].to_string()); + } // Short flag with attached value: -ojson, -otable s if s.starts_with("-o") && s.len() > 2 => { globals.output = Some(s[2..].to_string()); @@ -134,6 +144,9 @@ impl PreParsedGlobals { cfg.org = Some(org.clone()); } } + if self.jq.is_some() { + cfg.jq = self.jq.clone(); + } Ok(()) } } diff --git a/src/filter.rs b/src/filter.rs new file mode 100644 index 0000000..a75d84e --- /dev/null +++ b/src/filter.rs @@ -0,0 +1,167 @@ +//! jq filter support via the [`jaq`](https://github.com/01mf02/jaq) engine. +//! +//! [`apply_jq`] is the sole public entry point; the jaq API is encapsulated +//! here so version-churn in jaq-core never spreads across the codebase. + +use anyhow::{bail, Result}; +use jaq_core::{ + data, + load::{Arena, File, Loader}, + unwrap_valr, Compiler, Ctx, Vars, +}; +use jaq_json::Val; + +/// Apply a jq expression to a JSON value. +/// +/// The jq output stream is collapsed to a single `serde_json::Value`: +/// - 0 outputs → `Value::Null` +/// - 1 output → the value itself (not wrapped) +/// - ≥ 2 outputs → `Value::Array` of all outputs +/// +/// The cardinality-dependent shape (1 vs. 2+) only surfaces for json/yaml +/// output; the table/csv/tsv renderers already normalise both shapes via +/// `extract_rows`. +/// +/// Returns a clean [`anyhow::Error`] — never panics — on parse, compile, or +/// runtime errors. +pub fn apply_jq(value: serde_json::Value, expr: &str) -> Result { + // ---- compile -------------------------------------------------------- + let program = File { + code: expr, + path: (), + }; + + let defs = jaq_core::defs() + .chain(jaq_std::defs()) + .chain(jaq_json::defs()); + let funs = jaq_core::funs::>() + .chain(jaq_std::funs()) + .chain(jaq_json::funs()); + + let arena = Arena::default(); + let loader = Loader::new(defs); + + let modules = loader.load(&arena, program).map_err(|errs| { + let msg = errs + .into_iter() + .map(|(_, e)| format!("{e:?}")) + .collect::>() + .join("; "); + anyhow::anyhow!("invalid --jq expression: {msg}") + })?; + + let filter = Compiler::default() + .with_funs(funs) + .compile(modules) + .map_err(|errs| { + let msg = errs + .into_iter() + .map(|(_, e)| format!("{e:?}")) + .collect::>() + .join("; "); + anyhow::anyhow!("invalid --jq expression: {msg}") + })?; + + // ---- convert input -------------------------------------------------- + // Val implements Deserialize (jaq-json `serde` feature), so we can + // convert directly through serde's in-memory data model. + let input: Val = serde_json::from_value(value) + .map_err(|e| anyhow::anyhow!("--jq input conversion error: {e}"))?; + + // ---- run filter ----------------------------------------------------- + let ctx = Ctx::>::new(&filter.lut, Vars::new([])); + + let outputs: Vec = filter + .id + .run((ctx, input)) + .map(unwrap_valr) + .map(|r| match r { + // Val implements Display as compact JSON; parse back to Value. + Ok(v) => serde_json::from_str(&v.to_string()) + .map_err(|e| anyhow::anyhow!("--jq output conversion error: {e}")), + Err(e) => bail!("--jq filter error: {e}"), + }) + .collect::>>()?; + + // ---- collapse cardinality ------------------------------------------- + Ok(match outputs.len() { + 0 => serde_json::Value::Null, + 1 => outputs.into_iter().next().unwrap(), + _ => serde_json::Value::Array(outputs), + }) +} + +#[cfg(test)] +mod tests { + use super::apply_jq; + use serde_json::json; + + fn jq(v: serde_json::Value, expr: &str) -> serde_json::Value { + apply_jq(v, expr).unwrap() + } + + // --- positive tests -------------------------------------------------- + + #[test] + fn extract_field_array() { + let input = json!([{"name": "foo"}, {"name": "bar"}]); + assert_eq!(jq(input, ".[].name"), json!(["foo", "bar"])); + } + + #[test] + fn select_endswith() { + // mirrors the user's example: select(endswith("baz")) + let input = json!([ + {"name": "foo-baz"}, + {"name": "foo-bar"}, + {"name": "baz"}, + ]); + let result = jq(input, "[.[] | select(.name | endswith(\"baz\"))]"); + assert_eq!(result, json!([{"name": "foo-baz"}, {"name": "baz"}])); + } + + #[test] + fn scalar_result_unwrapped() { + let input = json!({"foo": 42}); + // single output is NOT wrapped in an array + assert_eq!(jq(input, ".foo"), json!(42)); + } + + #[test] + fn empty_result_is_null() { + let input = json!([]); + assert_eq!(jq(input, ".[] | select(. > 100)"), json!(null)); + } + + #[test] + fn two_outputs_wrapped_in_array() { + let input = json!([1, 2]); + assert_eq!(jq(input, ".[]"), json!([1, 2])); + } + + #[test] + fn single_output_unwrapped() { + let input = json!([42]); + // only one element → not wrapped + assert_eq!(jq(input, ".[]"), json!(42)); + } + + // --- negative tests -------------------------------------------------- + + #[test] + fn invalid_expression_returns_error_no_panic() { + let result = apply_jq(json!(null), "this is not jq . . ."); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("invalid --jq"), + "expected 'invalid --jq' in error, got: {msg}" + ); + } + + #[test] + fn unclosed_bracket_returns_error() { + let result = apply_jq(json!({}), ".foo | ["); + assert!(result.is_err()); + } +} diff --git a/src/formatter.rs b/src/formatter.rs index 296b321..19360fe 100644 --- a/src/formatter.rs +++ b/src/formatter.rs @@ -2,6 +2,7 @@ use anyhow::Result; use serde::Serialize; use crate::config::OutputFormat; +use crate::filter; /// Agent mode metadata envelope. #[derive(Serialize)] @@ -26,6 +27,13 @@ pub const AGENT_ENVELOPE_NOTE: &str = "This envelope (status/data/metadata) \ run outside this agent session, append --no-agent so the output format \ matches what they will see."; +/// Appended to `metadata.note` when `--jq` ran, so an agent reading the +/// enveloped output knows to write jq expressions against the raw payload +/// (the value under `.data`), not against the envelope itself. +pub const JQ_FILTER_NOTE: &str = "This output was filtered by --jq, which runs on \ + the response payload (the value shown under .data), not on this envelope. \ + Write jq expressions against the payload (e.g. .[]), not .data[]."; + /// Recursively sort all JSON object keys alphabetically. fn sort_json_value(v: serde_json::Value) -> serde_json::Value { match v { @@ -52,15 +60,40 @@ fn go_html_escape(json: &str) -> String { .replace('>', "\\u003e") } +/// After a `--jq` filter rewrites the payload, the caller's `count`/`truncated` +/// describe the pre-filter data and would mislead agents. Drop them, keeping +/// `command`/`next_action`. `None` in → `None` out (envelope behaves as for a +/// command that supplies no metadata). `Metadata`'s `skip_serializing_if` on +/// both fields makes them disappear from the JSON. +fn strip_counts_after_filter(meta: Option<&Metadata>) -> Option { + let m = meta?; + Some(Metadata { + count: None, + truncated: false, + command: m.command.clone(), + next_action: m.next_action.clone(), + }) +} + +/// Append `JQ_FILTER_NOTE` to the `metadata.note` field of an agent envelope. +/// Called only when `--jq` ran so agents learn the filter targets the payload, +/// not the envelope. +fn append_jq_note(envelope: &mut serde_json::Value) { + if let Some(serde_json::Value::String(note)) = envelope.pointer_mut("/metadata/note") { + note.push(' '); + note.push_str(JQ_FILTER_NOTE); + } +} + /// Build the agent-mode envelope as a JSON value. Always sets `status`, /// `data`, and `metadata` — `metadata.note` is always present so an LLM /// authoring a script for the user is reminded to pass `--no-agent`. /// Extracted from `format_and_print` for unit-testability. -pub fn build_agent_envelope( - data: &T, +pub fn build_agent_envelope( + data: &serde_json::Value, meta: Option<&Metadata>, ) -> Result { - let sorted_data = sort_json_value(serde_json::to_value(data)?); + let sorted_data = sort_json_value(data.clone()); // Hoist: when the API wraps its list/object in a nested "data" key, // use that inner value directly so agents see .data[*] instead of .data.data[*]. let effective_data = match &sorted_data { @@ -88,42 +121,74 @@ pub fn build_agent_envelope( } /// Format and print data to stdout. +/// +/// The `jq` parameter, when `Some`, applies a jq expression to the serialized +/// data **before** envelope wrapping or format rendering. The filter runs on +/// the raw API payload regardless of `--agent`/`-o`, so the same expression +/// works consistently across all output modes. pub fn format_and_print( data: &T, format: &OutputFormat, agent_mode: bool, meta: Option<&Metadata>, + jq: Option<&str>, ) -> Result<()> { + // Serialize once; all renderers and the filter operate on this Value. + let mut value = serde_json::to_value(data)?; + if let Some(expr) = jq { + value = filter::apply_jq(value, expr)?; + } + if agent_mode && *format == OutputFormat::Json { - let envelope = build_agent_envelope(data, meta)?; + // A --jq filter rewrites the payload, so the caller's count/truncated + // (computed on the pre-filter data) no longer describe .data. Drop them; + // keep command/next_action. + let stripped_meta; + let meta = if jq.is_some() { + stripped_meta = strip_counts_after_filter(meta); + stripped_meta.as_ref() + } else { + meta + }; + let mut envelope = build_agent_envelope(&value, meta)?; + if jq.is_some() { + // Extend the inline note so agents learn --jq targets the payload. + append_jq_note(&mut envelope); + } let json = go_html_escape(&serde_json::to_string_pretty(&envelope)?); println!("{json}"); return Ok(()); } match format { - OutputFormat::Json => print_json(data), - OutputFormat::Yaml => print_yaml(data), - OutputFormat::Table => print_table(data), - OutputFormat::Csv => print_csv(data), - OutputFormat::Tsv => print_tsv(data), + OutputFormat::Json => print_json(&value), + OutputFormat::Yaml => print_yaml(&value), + OutputFormat::Table => print_table(&value), + OutputFormat::Csv => print_csv(&value), + OutputFormat::Tsv => print_tsv(&value), } } -/// Convenience: format and print using config settings (respects -o flag and agent mode). +/// Convenience: format and print using config settings (respects -o flag, agent mode, and --jq). pub fn output(cfg: &crate::config::Config, data: &T) -> Result<()> { - format_and_print(data, &cfg.output_format, cfg.agent_mode, None) + format_and_print( + data, + &cfg.output_format, + cfg.agent_mode, + None, + cfg.jq.as_deref(), + ) } -pub fn print_json(data: &T) -> Result<()> { - let sorted_data = sort_json_value(serde_json::to_value(data)?); +pub fn print_json(data: &serde_json::Value) -> Result<()> { + let sorted_data = sort_json_value(data.clone()); let json = go_html_escape(&serde_json::to_string_pretty(&sorted_data)?); println!("{json}"); Ok(()) } -fn print_yaml(data: &T) -> Result<()> { - let sorted_data = sort_json_value(serde_json::to_value(data)?); +fn print_yaml(data: &serde_json::Value) -> Result<()> { + let sorted_data = sort_json_value(data.clone()); let yaml = serde_norway::to_string(&sorted_data)?; print!("{yaml}"); Ok(()) @@ -156,10 +221,8 @@ fn flatten_row(value: &serde_json::Value) -> serde_json::Value { } } -fn print_table(data: &T) -> Result<()> { - // Convert to serde_json::Value to inspect structure - let value = serde_json::to_value(data)?; - let raw_rows = extract_rows(&value); +fn print_table(data: &serde_json::Value) -> Result<()> { + let raw_rows = extract_rows(data); let owned_rows: Vec = raw_rows.iter().map(|r| flatten_row(r)).collect(); let rows: Vec<&serde_json::Value> = owned_rows.iter().collect(); @@ -281,9 +344,8 @@ fn csv_cell(value: Option<&serde_json::Value>) -> String { } } -fn print_csv(data: &T) -> Result<()> { - let value = serde_json::to_value(data)?; - let raw_rows = extract_rows(&value); +fn print_csv(data: &serde_json::Value) -> Result<()> { + let raw_rows = extract_rows(data); if raw_rows.is_empty() { return Ok(()); @@ -340,9 +402,8 @@ fn tsv_escape(s: &str) -> String { s.replace('\t', "\\t") } -fn print_tsv(data: &T) -> Result<()> { - let value = serde_json::to_value(data)?; - let raw_rows = extract_rows(&value); +fn print_tsv(data: &serde_json::Value) -> Result<()> { + let raw_rows = extract_rows(data); if raw_rows.is_empty() { return Ok(()); @@ -814,21 +875,21 @@ mod tests { #[test] fn test_format_and_print_json() { let data = serde_json::json!({"name": "test"}); - let result = format_and_print(&data, &OutputFormat::Json, false, None); + let result = format_and_print(&data, &OutputFormat::Json, false, None, None); assert!(result.is_ok()); } #[test] fn test_format_and_print_yaml() { let data = serde_json::json!({"name": "test"}); - let result = format_and_print(&data, &OutputFormat::Yaml, false, None); + let result = format_and_print(&data, &OutputFormat::Yaml, false, None, None); assert!(result.is_ok()); } #[test] fn test_format_and_print_table() { let data = serde_json::json!([{"id": 1, "name": "test"}]); - let result = format_and_print(&data, &OutputFormat::Table, false, None); + let result = format_and_print(&data, &OutputFormat::Table, false, None, None); assert!(result.is_ok()); } @@ -841,7 +902,7 @@ mod tests { command: Some("test".into()), next_action: None, }; - let result = format_and_print(&data, &OutputFormat::Json, true, Some(&meta)); + let result = format_and_print(&data, &OutputFormat::Json, true, Some(&meta), None); assert!(result.is_ok()); } @@ -902,7 +963,7 @@ mod tests { #[test] fn test_format_and_print_agent_mode_no_meta() { let data = serde_json::json!({"name": "test"}); - let result = format_and_print(&data, &OutputFormat::Json, true, None); + let result = format_and_print(&data, &OutputFormat::Json, true, None, None); assert!(result.is_ok()); } @@ -910,7 +971,7 @@ mod tests { fn test_format_and_print_agent_mode_respects_yaml_flag() { // In agent mode, -o yaml should bypass the agent envelope and use YAML output. let data = serde_json::json!({"name": "test"}); - let result = format_and_print(&data, &OutputFormat::Yaml, true, None); + let result = format_and_print(&data, &OutputFormat::Yaml, true, None, None); assert!(result.is_ok()); } @@ -918,7 +979,7 @@ mod tests { fn test_format_and_print_agent_mode_respects_table_flag() { // In agent mode, -o table should bypass the agent envelope and use table output. let data = serde_json::json!([{"id": 1, "name": "test"}]); - let result = format_and_print(&data, &OutputFormat::Table, true, None); + let result = format_and_print(&data, &OutputFormat::Table, true, None, None); assert!(result.is_ok()); } @@ -1051,7 +1112,7 @@ mod tests { #[test] fn test_format_and_print_csv() { let data = serde_json::json!([{"id": 1, "name": "test"}]); - let result = format_and_print(&data, &OutputFormat::Csv, false, None); + let result = format_and_print(&data, &OutputFormat::Csv, false, None, None); assert!(result.is_ok()); } @@ -1068,6 +1129,7 @@ mod tests { auto_approve: false, agent_mode: false, read_only: false, + jq: None, }; let data = serde_json::json!({"hello": "world"}); assert!(output(&cfg, &data).is_ok()); @@ -1129,7 +1191,126 @@ mod tests { #[test] fn test_format_and_print_tsv() { let data = serde_json::json!([{"id": 1, "name": "test"}]); - let result = format_and_print(&data, &OutputFormat::Tsv, false, None); + let result = format_and_print(&data, &OutputFormat::Tsv, false, None, None); assert!(result.is_ok()); } + + // --- strip_counts_after_filter ------------------------------------------- + + #[test] + fn test_strip_counts_none_meta_returns_none() { + assert!(strip_counts_after_filter(None).is_none()); + } + + #[test] + fn test_strip_counts_drops_count_and_truncated() { + let meta = Metadata { + count: Some(10), + truncated: true, + command: Some("monitors list".into()), + next_action: Some("next".into()), + }; + let stripped = strip_counts_after_filter(Some(&meta)).unwrap(); + assert!(stripped.count.is_none(), "count should be dropped"); + assert!(!stripped.truncated, "truncated should be cleared"); + assert_eq!(stripped.command.as_deref(), Some("monitors list")); + assert_eq!(stripped.next_action.as_deref(), Some("next")); + } + + // --- append_jq_note ------------------------------------------------------ + + #[test] + fn test_append_jq_note_extends_note_field() { + let data = serde_json::json!({"id": 1}); + let mut envelope = build_agent_envelope(&data, None).unwrap(); + // Before: note contains only AGENT_ENVELOPE_NOTE. + let before = envelope["metadata"]["note"].as_str().unwrap().to_string(); + assert!(before.contains("agent mode"), "pre-condition: {before}"); + + append_jq_note(&mut envelope); + + let after = envelope["metadata"]["note"].as_str().unwrap(); + assert!( + after.contains(AGENT_ENVELOPE_NOTE), + "original note must be preserved: {after}" + ); + assert!( + after.contains(JQ_FILTER_NOTE), + "jq note must be appended: {after}" + ); + assert!( + after.contains(".data"), + "jq note must mention .data: {after}" + ); + } + + // --- integration: strip + append through the real builder ---------------- + + #[test] + fn test_jq_filter_path_drops_count_and_appends_note() { + let filtered = serde_json::json!({"id": 1, "name": "foo"}); + let meta = Metadata { + count: Some(10), + truncated: false, + command: Some("monitors list".into()), + next_action: None, + }; + + let stripped = strip_counts_after_filter(Some(&meta)); + let mut env = build_agent_envelope(&filtered, stripped.as_ref()).unwrap(); + append_jq_note(&mut env); + + assert!( + env["metadata"]["count"].is_null(), + "count must be omitted after filter: {}", + env["metadata"]["count"] + ); + assert!( + env["metadata"]["truncated"].is_null(), + "truncated must be omitted after filter" + ); + assert_eq!( + env["metadata"]["command"], + serde_json::json!("monitors list"), + "command must be preserved" + ); + let note = env["metadata"]["note"].as_str().unwrap(); + assert!( + note.contains(AGENT_ENVELOPE_NOTE), + "original note must survive: {note}" + ); + assert!( + note.contains(JQ_FILTER_NOTE), + "jq note must be appended: {note}" + ); + assert_eq!(env["status"], "success"); + } + + #[test] + fn test_no_jq_path_keeps_count_and_note_unchanged() { + // Regression: when --jq is NOT used, the envelope must be byte-for-byte + // identical to pre-change behavior: count stays, only AGENT_ENVELOPE_NOTE. + let data = serde_json::json!([{"id": 1}, {"id": 2}]); + let meta = Metadata { + count: Some(2), + truncated: false, + command: Some("monitors list".into()), + next_action: None, + }; + let env = build_agent_envelope(&data, Some(&meta)).unwrap(); + assert_eq!( + env["metadata"]["count"], + serde_json::json!(2), + "count must survive without --jq" + ); + let note = env["metadata"]["note"].as_str().unwrap(); + assert!( + !note.contains(JQ_FILTER_NOTE), + "jq note must NOT appear without --jq: {note}" + ); + assert!( + note.contains(AGENT_ENVELOPE_NOTE), + "original note must be present: {note}" + ); + } } diff --git a/src/lib.rs b/src/lib.rs index 481d585..c3246e9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,6 +18,8 @@ mod api; #[cfg(feature = "browser")] mod config; #[cfg(feature = "browser")] +mod filter; +#[cfg(feature = "browser")] mod formatter; #[cfg(feature = "browser")] mod version; diff --git a/src/main.rs b/src/main.rs index a56d064..920a8b3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,7 @@ mod commands; mod config; #[cfg(not(target_arch = "wasm32"))] mod extensions; +mod filter; mod formatter; #[cfg(not(target_arch = "wasm32"))] mod runbooks; @@ -61,6 +62,9 @@ pub(crate) struct Cli { /// Named org session (see 'pup auth login --org') #[arg(long, global = true)] org: Option, + /// Filter command output through a jq expression (applied before formatting) + #[arg(long, global = true)] + jq: Option, /// Trust a non-Datadog `--site`/`DD_SITE` host for this invocation (skip the /// trust prompt). For durable trust, use `trusted_sites` in config instead. #[arg(long, global = true)] @@ -11038,6 +11042,9 @@ async fn main_inner() -> anyhow::Result<()> { if cli.read_only { cfg.read_only = true; } + if cli.jq.is_some() { + cfg.jq = cli.jq; + } if cfg.read_only { let top = get_top_level_subcommand_name(&matches); let is_local_only = matches!( diff --git a/src/test_support.rs b/src/test_support.rs index 17a7778..7ab87b9 100644 --- a/src/test_support.rs +++ b/src/test_support.rs @@ -32,6 +32,7 @@ pub(crate) fn test_config(mock_url: &str) -> Config { auto_approve: false, agent_mode: false, read_only: false, + jq: None, } }