diff --git a/AGENTS.md b/AGENTS.md index b52a349..00e5562 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -239,6 +239,7 @@ Command checklist: command with no `.with_pagination(...)` call never registers those flags — absent from its `--help`, rejected as unknown arguments if passed. `default_limit` applies when the user passes neither flag; `max_limit` (`0` = uncapped) rejects an explicit `--limit` above the cap. +- Use `.raw_output(true)` for a command whose only correct output is verbatim text (e.g. printing a schema/config blob to pipe to a file), not a JSON reconstruction of it. The handler's `CommandResult` data must be a JSON string; this removes the `--output`/`--fields`/`--filter`/`--expr`/pagination flags and is incompatible with streaming commands. ## Output And Schemas diff --git a/cli-engine/docs/concepts.md b/cli-engine/docs/concepts.md index 9897cf2..cf24f44 100644 --- a/cli-engine/docs/concepts.md +++ b/cli-engine/docs/concepts.md @@ -560,6 +560,21 @@ the top-level `fix` field (`CliCoreError::with_fix`, `DetailedError::error_fix`, system, or the top-level command path when no system is configured, so error envelopes preserve the same backend attribution as success envelopes. +### Raw output + +Some commands have exactly one correct output: verbatim text. For example, a command that prints a schema definition, a config file, or another blob meant to be piped straight to a file or another tool should not be wrapped in an output envelope or reformatted. `CommandSpec::raw_output(true)` opts a command into this: its successful result skips all five pipeline steps above, instead printing the handler's +`CommandResult` data followed by exactly one trailing newline. + +```rust +use cli_engine::{CommandResult, CommandSpec, RuntimeCommandSpec}; +use serde_json::json; + +let command = RuntimeCommandSpec::new( + CommandSpec::new("get", "Print the schema verbatim").raw_output(true), + async |_credential, _args| Ok(CommandResult::new(json!("type Query { ... }"))), +); +``` + ## Schemas Commands can publish output schemas for help text and agent comprehension. The preferred schema path diff --git a/cli-engine/src/cli.rs b/cli-engine/src/cli.rs index 03c4e1c..c1869f1 100644 --- a/cli-engine/src/cli.rs +++ b/cli-engine/src/cli.rs @@ -1926,6 +1926,7 @@ impl Cli { default_fields: &default_fields, view_id: view_id.as_deref(), auth: command.spec.auth, + raw_output: command.spec.raw_output, pagination_command, }, Arc::new(leaf.clone()), @@ -1958,6 +1959,7 @@ impl Cli { default_fields: &default_fields, view_id: view_id.as_deref(), auth: command.spec.auth, + raw_output: command.spec.raw_output, pagination_command, }, async move |credential| { @@ -3716,6 +3718,12 @@ fn command_clap_command_with_schema_help( command_path: &str, schemas: &SchemaRegistry, ) -> Command { + debug_assert!( + !(spec.raw_output && spec.pagination.is_some()), + "command {:?} sets both raw_output and with_pagination; a single verbatim string \ + has no pages, so the two are mutually exclusive", + spec.name + ); let mut command = spec.clap_command(); command = apply_dry_run_visibility(command, spec); command = apply_pagination_args(command, spec); @@ -3727,10 +3735,35 @@ fn command_clap_command_with_schema_help( schema.as_ref().map(|schema| schema.fields.as_slice()), &default_fields, ); - let Some(schema) = schema else { + command = apply_output_format_visibility(command, spec); + let filter_expr_fields = schema + .as_ref() + .map_or(&[][..], |schema| schema.fields.as_slice()); + apply_filter_and_expr_examples(command, spec, filter_expr_fields) +} + +/// Hides this command's inherited `--output` flag when it opted into +/// [`CommandSpec::raw_output`]. +fn apply_output_format_visibility(command: Command, spec: &CommandSpec) -> Command { + if !spec.raw_output { return command; - }; - apply_filter_and_expr_examples(command, &schema.fields) + } + use std::io::IsTerminal; + command.arg( + Arg::new("output") + .long("output") + .short('o') + .value_name("FORMAT") + .default_value(if std::io::stdout().is_terminal() { + "human" + } else { + "json" + }) + .conflicts_with_all(["json", "toon", "human"]) + .display_order(crate::flags::global_flag_order::OUTPUT) + .hide(true) + .help("Ignored — this command always prints raw text"), + ) } /// Hides this command's inherited `--dry-run` flag when the command isn't @@ -3803,6 +3836,16 @@ fn apply_fields_arg( schema_fields: Option<&[FieldInfo]>, default_fields: &[&str], ) -> Command { + if spec.raw_output { + return command.arg( + Arg::new("fields") + .long("fields") + .value_name("FIELDS") + .display_order(crate::flags::global_flag_order::FIELDS) + .hide(true) + .help("Ignored — this command always prints raw text"), + ); + } let default_value = spec .default_fields .as_deref() @@ -3844,7 +3887,30 @@ fn apply_fields_arg( /// demonstrate. Mirrors [`apply_fields_arg`]: a subcommand-local arg of the /// same name shadows the framework's global one, and must carry the same /// `global_flag_order` value as that global one for the same reason. -fn apply_filter_and_expr_examples(mut command: Command, fields: &[FieldInfo]) -> Command { +fn apply_filter_and_expr_examples( + mut command: Command, + spec: &CommandSpec, + fields: &[FieldInfo], +) -> Command { + if spec.raw_output { + return command + .arg( + Arg::new("filter") + .long("filter") + .value_name("EXPR") + .display_order(crate::flags::global_flag_order::FILTER) + .hide(true) + .help("Ignored — this command always prints raw text"), + ) + .arg( + Arg::new("expr") + .long("expr") + .value_name("EXPR") + .display_order(crate::flags::global_flag_order::EXPR) + .hide(true) + .help("Ignored — this command always prints raw text"), + ); + } if fields.is_empty() { return command; } diff --git a/cli-engine/src/command.rs b/cli-engine/src/command.rs index 18d7a13..55ff658 100644 --- a/cli-engine/src/command.rs +++ b/cli-engine/src/command.rs @@ -348,6 +348,8 @@ pub struct CommandSpec { /// development-time safety net, not the actual guarantee — only pair this /// field with one of the four context-aware constructors above. pub handles_dry_run: bool, + /// Forces this command's successful output to print verbatim to stdout. + pub raw_output: bool, /// Provider-specific auth metadata. pub auth_metadata: BTreeMap, /// Command-specific `clap` arguments. @@ -720,6 +722,13 @@ impl CommandSpec { self } + /// Forces this command's successful output to print verbatim to stdout. + #[must_use] + pub fn raw_output(mut self, raw_output: bool) -> Self { + self.raw_output = raw_output; + self + } + /// Builds middleware metadata from the spec. #[must_use] pub fn metadata(&self) -> CommandMeta { @@ -1001,6 +1010,13 @@ impl RuntimeCommandSpec { F: Fn(CommandContext, StreamSender) -> Fut + Send + Sync + 'static, Fut: Future> + Send + 'static, { + debug_assert!( + !spec.raw_output, + "command {:?} sets raw_output but RuntimeCommandSpec::new_streaming writes \ + chunked NDJSON events, which does not fit a single-verbatim-string contract; \ + raw_output is only supported on non-streaming commands", + spec.name + ); let streaming: StreamingCommandHandler = Arc::new(move |context, sender| { let future = handler(context, sender); Box::pin(future) @@ -1128,6 +1144,13 @@ impl RuntimeCommandSpec { F: Fn(CommandContext, T, StreamSender) -> Fut + Send + Sync + 'static, Fut: Future> + Send + 'static, { + debug_assert!( + !spec.raw_output, + "command {:?} sets raw_output but RuntimeCommandSpec::new_typed_streaming writes \ + chunked NDJSON events, which does not fit a single-verbatim-string contract; \ + raw_output is only supported on non-streaming commands", + spec.name + ); let handler = Arc::new(handler); let streaming: StreamingCommandHandler = Arc::new(move |context, sender| { let parsed = T::from_arg_matches(context.raw_matches.as_ref()); diff --git a/cli-engine/src/middleware.rs b/cli-engine/src/middleware.rs index c2c0990..8c4257f 100644 --- a/cli-engine/src/middleware.rs +++ b/cli-engine/src/middleware.rs @@ -573,6 +573,10 @@ pub struct MiddlewareRequest<'request> { pub view_id: Option<&'request str>, /// Authentication requirement enforced by the engine for this command. pub auth: AuthRequirement, + /// Mirrors [`CommandSpec::raw_output`](crate::CommandSpec::raw_output): + /// when `true`, a successful string result renders verbatim, bypassing + /// the format/pipeline machinery entirely. + pub raw_output: bool, /// The invoked command replayed as `--flag value` text — command path /// plus every flag the user explicitly passed, using clap's own /// long-flag names — with `--limit`/`--offset` deliberately omitted. @@ -615,6 +619,7 @@ impl Middleware { default_fields, view_id, auth, + raw_output, pagination_command, } = request; let no_auth = auth.is_none(); @@ -738,6 +743,7 @@ impl Middleware { &args, identity, None, + false, ); } @@ -844,6 +850,7 @@ impl Middleware { &args, identity, pagination_command.as_deref(), + raw_output && !is_dry_run, ) } @@ -871,6 +878,7 @@ impl Middleware { default_fields, view_id: None, auth: AuthRequirement::None, + raw_output: false, pagination_command: None, }, async move |_resolver| command().await, @@ -969,6 +977,7 @@ impl Middleware { effective_args, identity, None, + false, ) .map(Some); } @@ -987,6 +996,7 @@ impl Middleware { effective_args: &ValueMap, identity: &str, pagination_command: Option<&str>, + raw_output: bool, ) -> Result { if !is_valid_output_format(&self.output_format) { let err = CliCoreError::InvalidOutputFormat(self.output_format.clone()); @@ -999,6 +1009,38 @@ impl Middleware { identity, ); } + if raw_output { + match &envelope.data { + Some(Value::String(text)) => { + // Guarantee exactly one trailing newline without doubling + // one the handler already included (e.g. text read from + // a file that already ends in "\n"). + let body = text.strip_suffix('\n').unwrap_or(text); + let rendered = format!("{body}\n"); + envelope.with_context( + command_path, + &self.env, + identity, + start.elapsed(), + Some(Value::Object(user_args.clone())), + Some(Value::Object(effective_args.clone())), + ); + let prepared = envelope.prepare_for_render(&self.verbose); + return Ok(MiddlewareOutput { + envelope: prepared, + rendered, + exit_code: 0, + }); + } + other => { + debug_assert!( + false, + "command {command_path:?} set raw_output but its handler returned \ + non-string data ({other:?}); rendering normally instead" + ); + } + } + } let output_format = self.output_format.parse::()?; // The effective field selection: an explicit `--fields` wins, otherwise // the command's `default_fields` is the default. The same selection is diff --git a/cli-engine/src/output/human.rs b/cli-engine/src/output/human.rs index 285a9b8..243b151 100644 --- a/cli-engine/src/output/human.rs +++ b/cli-engine/src/output/human.rs @@ -1161,6 +1161,16 @@ mod tests { use super::*; use serde_json::json; + #[test] + fn format_plain_value_round_trips_a_bare_string_verbatim() { + // No quoting/escaping — the exact convention `raw_output` bypass + // relies on to render a `CommandResult` string byte-for-byte. + assert_eq!( + format_plain_value(&Value::String("some\nverbatim\ntext".to_owned())), + "some\nverbatim\ntext" + ); + } + #[test] fn human_output_appends_next_steps_footer() { let envelope = Envelope::success(json!({ "domain": "example.com" }), "domain") diff --git a/cli-engine/tests/foundation.rs b/cli-engine/tests/foundation.rs index 6e87f25..8a8821e 100644 --- a/cli-engine/tests/foundation.rs +++ b/cli-engine/tests/foundation.rs @@ -63,6 +63,7 @@ fn middleware_request<'request>( default_fields, view_id: None, auth: auth_requirement(no_auth), + raw_output: false, pagination_command: None, } } @@ -89,6 +90,7 @@ fn middleware_request_with_view<'request>( default_fields, view_id: Some(view_id), auth: auth_requirement(no_auth), + raw_output: false, pagination_command: None, } } @@ -122,6 +124,7 @@ fn middleware_request_with_system<'request>( default_fields, view_id: None, auth: auth_requirement(no_auth), + raw_output: false, pagination_command: None, } } @@ -3831,6 +3834,56 @@ fn runtime_command_spec_new_typed_panics_when_paired_with_handles_dry_run() { ); } +/// A single verbatim string has no pages, so pairing `raw_output` with +/// `with_pagination` is a contract violation caught at registration time +/// (inside `Cli::add_command`'s clap-tree build), not left as a silent +/// no-op. +#[test] +#[should_panic(expected = "mutually exclusive")] +fn raw_output_paired_with_pagination_panics_on_registration() { + let mut cli = Cli::new(CliConfig { + name: "my-cli".to_owned(), + short: "Developer tooling".to_owned(), + app_id: "my-cli".to_owned(), + ..CliConfig::default() + }); + cli.add_command(RuntimeCommandSpec::new( + CommandSpec::new("bad", "Bad") + .no_auth(true) + .raw_output(true) + .with_pagination(PaginationConfig::default()), + async |_credential, _args| Ok(CommandResult::new(json!("text"))), + )); +} + +/// Streaming writes chunked NDJSON events, which doesn't fit a +/// single-verbatim-string contract. +#[test] +#[should_panic(expected = "does not fit a single-verbatim-string contract")] +fn runtime_command_spec_new_streaming_panics_when_paired_with_raw_output() { + let _unused = RuntimeCommandSpec::new_streaming( + CommandSpec::new("bad", "Bad") + .no_auth(true) + .raw_output(true), + async |_context, _sender| Ok(()), + ); +} + +/// Same footgun as above, for the typed-args streaming constructor. +#[test] +#[should_panic(expected = "does not fit a single-verbatim-string contract")] +fn runtime_command_spec_new_typed_streaming_panics_when_paired_with_raw_output() { + #[derive(Debug, Clone, clap::Args)] + struct EmptyArgs {} + + let _unused = RuntimeCommandSpec::new_typed_streaming::( + CommandSpec::new("bad", "Bad") + .no_auth(true) + .raw_output(true), + async |_context, _args: EmptyArgs, _sender| Ok(()), + ); +} + #[test] fn command_spec_with_scopes_round_trips_through_metadata() { let spec = CommandSpec::new("get", "Get").with_scopes(&["commerce.business:read", "x:y"]); @@ -10669,6 +10722,7 @@ async fn optional_skips_auth_when_handler_ignores_credential() { default_fields: "", view_id: None, auth: cli_engine::AuthRequirement::Optional, + raw_output: false, pagination_command: None, }, async |_resolver| Ok(CommandResult::new(json!({"ok": true}))), @@ -10707,6 +10761,7 @@ async fn optional_swallowed_auth_failure_then_command_error_is_not_auth_error() default_fields: "", view_id: None, auth: cli_engine::AuthRequirement::Optional, + raw_output: false, pagination_command: None, }, async |resolver: CredentialResolver| { @@ -10758,6 +10813,7 @@ async fn optional_handler_propagated_auth_failure_is_classified_auth_error() { default_fields: "", view_id: None, auth: cli_engine::AuthRequirement::Optional, + raw_output: false, pagination_command: None, }, async |resolver: CredentialResolver| { diff --git a/cli-engine/tests/raw_output.rs b/cli-engine/tests/raw_output.rs new file mode 100644 index 0000000..e6d149b --- /dev/null +++ b/cli-engine/tests/raw_output.rs @@ -0,0 +1,145 @@ +//! End-to-end coverage for `CommandSpec::raw_output`: a successful string +//! result must print verbatim regardless of `--output`/`--json`/`--human`/ +//! `--toon`, the TTY/env/config default, or the `--fields`/`--filter`/ +//! `--expr` pipeline — and those now-meaningless flags are hidden from the +//! command's own `--help` (but still parse harmlessly if passed anyway). + +use cli_engine::{Cli, CliConfig, CommandResult, CommandSpec, RuntimeCommandSpec}; +use serde_json::json; + +const VERBATIM: &str = "some\nverbatim\ntext"; + +fn build_cli() -> Cli { + let mut cli = Cli::new(CliConfig { + name: "my-cli".to_owned(), + short: "Raw output test CLI".to_owned(), + app_id: "my-cli".to_owned(), + ..CliConfig::default() + }); + cli.add_command(RuntimeCommandSpec::new( + CommandSpec::new("dump", "Print verbatim text") + .no_auth(true) + .raw_output(true), + async |_credential, _args| Ok(CommandResult::new(json!(VERBATIM))), + )); + cli.add_command(RuntimeCommandSpec::new( + CommandSpec::new( + "dump-pretrailed", + "Print verbatim text that already ends in a newline", + ) + .no_auth(true) + .raw_output(true), + async |_credential, _args| Ok(CommandResult::new(json!(format!("{VERBATIM}\n")))), + )); + cli.add_command(RuntimeCommandSpec::new_with_context( + CommandSpec::new("dump-preview", "Print verbatim text, previewably") + .no_auth(true) + .mutates(true) + .handles_dry_run(true) + .raw_output(true), + async |ctx| { + if ctx.dry_run() { + Ok(CommandResult::new(json!({"would": "print", "text": VERBATIM})).with_dry_run()) + } else { + Ok(CommandResult::new(json!(VERBATIM))) + } + }, + )); + cli +} + +fn expected() -> String { + format!("{VERBATIM}\n") +} + +#[tokio::test] +async fn raw_output_ignores_output_json_flag() { + let out = build_cli() + .run(["my-cli", "dump", "--output", "json"]) + .await; + assert_eq!(out.exit_code, 0, "{}", out.rendered); + assert_eq!(out.rendered, expected()); +} + +#[tokio::test] +async fn raw_output_ignores_output_toon_flag() { + let out = build_cli() + .run(["my-cli", "dump", "--output", "toon"]) + .await; + assert_eq!(out.exit_code, 0, "{}", out.rendered); + assert_eq!(out.rendered, expected()); +} + +#[tokio::test] +async fn raw_output_ignores_human_flag() { + let out = build_cli().run(["my-cli", "dump", "--human"]).await; + assert_eq!(out.exit_code, 0, "{}", out.rendered); + assert_eq!(out.rendered, expected()); +} + +#[tokio::test] +async fn raw_output_ignores_no_flag_default() { + // No `--output`/`--json`/`--human`/`--toon` at all: TTY/env/config + // resolution never gets a chance to matter. + let out = build_cli().run(["my-cli", "dump"]).await; + assert_eq!(out.exit_code, 0, "{}", out.rendered); + assert_eq!(out.rendered, expected()); +} + +#[tokio::test] +async fn raw_output_does_not_double_a_trailing_newline_the_handler_already_included() { + // Regression: the render guarantees exactly one trailing newline, so a + // handler string that already ends in "\n" (e.g. read from a file) must + // not come out as "...\n\n". + let out = build_cli().run(["my-cli", "dump-pretrailed"]).await; + assert_eq!(out.exit_code, 0, "{}", out.rendered); + assert_eq!(out.rendered, expected()); +} + +#[tokio::test] +async fn raw_output_still_rejects_invalid_output_value() { + let out = build_cli() + .run(["my-cli", "dump", "--output", "garbage"]) + .await; + assert_ne!(out.exit_code, 0, "{}", out.rendered); +} + +#[tokio::test] +async fn raw_output_ignores_fields_filter_expr_flags() { + let out = build_cli() + .run([ + "my-cli", "dump", "--fields", "x", "--filter", "true", "--expr", "@", + ]) + .await; + assert_eq!(out.exit_code, 0, "{}", out.rendered); + assert_eq!(out.rendered, expected()); +} + +#[tokio::test] +async fn raw_output_hides_output_fields_filter_expr_from_help() { + let help = build_cli().run(["my-cli", "dump", "--help"]).await; + assert_eq!(help.exit_code, 0, "{}", help.rendered); + assert!(!help.rendered.contains("--output"), "{}", help.rendered); + assert!(!help.rendered.contains("--fields"), "{}", help.rendered); + assert!(!help.rendered.contains("--filter"), "{}", help.rendered); + assert!(!help.rendered.contains("--expr"), "{}", help.rendered); +} + +#[tokio::test] +async fn raw_output_command_with_dry_run_preview_uses_normal_rendering() { + // A `handles_dry_run` preview is diagnostic, not the command's real + // output, so it renders through the normal envelope path even though + // the command also opted into `raw_output`. + let out = build_cli() + .run(["my-cli", "dump-preview", "--dry-run", "--output", "json"]) + .await; + assert_eq!(out.exit_code, 0, "{}", out.rendered); + assert_ne!(out.rendered, expected()); + let rendered: serde_json::Value = serde_json::from_str(&out.rendered).expect("valid json"); + assert_eq!(rendered["data"]["would"], "print"); + + // A real (non-dry-run) invocation still renders raw. + let out = build_cli().run(["my-cli", "dump-preview"]).await; + assert_eq!(out.exit_code, 0, "{}", out.rendered); + assert_eq!(out.rendered, expected()); +}