Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
15 changes: 15 additions & 0 deletions cli-engine/docs/concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
74 changes: 70 additions & 4 deletions cli-engine/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand Down Expand Up @@ -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| {
Expand Down Expand Up @@ -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);
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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;
}
Expand Down
23 changes: 23 additions & 0 deletions cli-engine/src/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String>,
/// Command-specific `clap` arguments.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -1001,6 +1010,13 @@ impl RuntimeCommandSpec {
F: Fn(CommandContext, StreamSender) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Result<()>> + 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)
Expand Down Expand Up @@ -1128,6 +1144,13 @@ impl RuntimeCommandSpec {
F: Fn(CommandContext, T, StreamSender) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Result<()>> + 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());
Expand Down
42 changes: 42 additions & 0 deletions cli-engine/src/middleware.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -615,6 +619,7 @@ impl Middleware {
default_fields,
view_id,
auth,
raw_output,
pagination_command,
} = request;
let no_auth = auth.is_none();
Expand Down Expand Up @@ -738,6 +743,7 @@ impl Middleware {
&args,
identity,
None,
false,
);
}

Expand Down Expand Up @@ -844,6 +850,7 @@ impl Middleware {
&args,
identity,
pagination_command.as_deref(),
raw_output && !is_dry_run,
)
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -969,6 +977,7 @@ impl Middleware {
effective_args,
identity,
None,
false,
)
.map(Some);
}
Expand All @@ -987,6 +996,7 @@ impl Middleware {
effective_args: &ValueMap,
identity: &str,
pagination_command: Option<&str>,
raw_output: bool,
) -> Result<MiddlewareOutput> {
if !is_valid_output_format(&self.output_format) {
let err = CliCoreError::InvalidOutputFormat(self.output_format.clone());
Expand All @@ -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::<OutputFormat>()?;
// The effective field selection: an explicit `--fields` wins, otherwise
// the command's `default_fields` is the default. The same selection is
Expand Down
10 changes: 10 additions & 0 deletions cli-engine/src/output/human.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading