Skip to content
Merged
20 changes: 8 additions & 12 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,8 +161,7 @@ For a full module, prefer this shape:
```rust
use clap::Arg;
use cli_engine::{
CommandSpec, GroupSpec, HumanViewDef, Module, RuntimeCommandSpec, RuntimeGroupSpec,
TableColumn,
CommandSpec, GroupSpec, Module, RuntimeCommandSpec, RuntimeGroupSpec, TableColumn,
};
use schemars::JsonSchema;
use serde::Serialize;
Expand All @@ -180,14 +179,6 @@ pub fn module() -> Module {
RuntimeGroupSpec::new(GroupSpec::new("project", "Manage projects"))
.with_command(list_projects())
})
.with_view(HumanViewDef::new(
"project:list",
vec![
TableColumn::new("id", "ID"),
TableColumn::new("name", "Name"),
TableColumn::new("status", "Status"),
],
))
}

fn list_projects() -> RuntimeCommandSpec {
Expand All @@ -196,6 +187,11 @@ fn list_projects() -> RuntimeCommandSpec {
.with_system("projects-api")
.with_default_fields("id,name,status")
.with_json_schema::<Project>()
.with_view(vec![
TableColumn::new("id", "ID"),
TableColumn::new("name", "Name"),
TableColumn::new("status", "Status"),
])
.with_arg(Arg::new("team").long("team").required(true)),
async |_credential, args| {
let team = args
Expand Down Expand Up @@ -240,7 +236,7 @@ Command checklist:
backend attribution.
- Register schemas with `.with_json_schema::<T>()` when a Rust response type exists.
- Use manual `OutputSchema`, `OutputField`, `FieldInfo`, and `SchemaInfo` only when generated JSON Schema is not practical.
- Register human views with `HumanViewDef::new` and `TableColumn::new` when a command needs column-oriented terminal output.
- Assign a human view to a command with `.with_view(vec![TableColumn::new(...), ...])` for an inline table, or `.with_view_id("shared-id")` to reuse a `HumanViewDef` registered on the module/CLI.
- Keep stdout machine-friendly and stderr human-friendly for executable paths.

Handlers should not print directly. Return data or an error and let the framework render the output envelope.
Expand All @@ -255,7 +251,7 @@ For agentic programming tools generating a new CLI or module:
2. Create or update the module file first.
3. Define response structs with `Serialize` and `JsonSchema` for command output.
4. Add command specs and handlers with the builder API.
5. Register human views for list commands.
5. Assign human views to list commands with `.with_view(...)` (or `.with_view_id(...)`).
6. Add integration tests that call `Cli::run(...)` or the consumer binary and assert exit code, stdout shape, stderr shape, and key output fields.
7. Run the verification commands below.

Expand Down
30 changes: 26 additions & 4 deletions docs/concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -520,21 +520,43 @@ Human output is designed for readable terminal display:
- Objects in fallback lines render as compact JSON.
- JSON numbers use `serde_json` number text.

Column views should be registered by command path or schema id:
Views can be assigned to commands. There are two ways to do it.

Assign an inline view directly to a command with `CommandSpec::with_view`:

```rust
use cli_engine::{HumanViewDef, TableColumn};
use cli_engine::{CommandSpec, TableColumn};

let view = HumanViewDef::new(
"project:list",
let spec = CommandSpec::new("list", "List projects").with_view(vec![
TableColumn::new("id", "ID"),
TableColumn::new("name", "Name"),
TableColumn::new("status", "Status"),
]);
```

Or register a shared view once on the module (or CLI) and reference it by id from
each command that should reuse it with `CommandSpec::with_view_id`:

```rust
use cli_engine::{CommandSpec, HumanViewDef, TableColumn};

// Registered on the module/CLI with `.with_view(...)`:
let shared = HumanViewDef::new(
"projects-table",
vec![
TableColumn::new("id", "ID"),
TableColumn::new("name", "Name"),
TableColumn::new("status", "Status"),
],
);

// Referenced from any command that should use it:
let spec = CommandSpec::new("get", "Get a project").with_view_id("projects-table");
```

Field selection composes with views. `--fields` (defaulting to the command's `default_fields`) selects which JSON fields appear when there is no view, and which of a view's columns appear when there is one. So a command with a view of `id`/`name`/`status` columns and `default_fields = "id,name"` shows just those two columns by default; `--fields all` shows every column, and `--fields id,status` shows that pair. A custom view renderer receives the full payload and
ignores field selection.

## Guides

Guides are markdown documents registered with the CLI or with modules. They document workflows,
Expand Down
67 changes: 54 additions & 13 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ use crate::{
guide::guide_content,
module::{Module, ModuleContext},
output::{
HumanViewDef, NextAction, SchemaRegistry, format_help_section,
HumanViewDef, HumanViewRegistry, NextAction, SchemaRegistry, format_help_section,
global_human_view_registry_snapshot, global_schema_registry_snapshot,
},
search::{SearchDocument, SearchIndex},
Expand Down Expand Up @@ -890,7 +890,12 @@ impl Cli {
}

let mut prefix = Vec::new();
register_runtime_group_schemas(&group, &mut prefix, &mut self.middleware.schema_registry);
register_runtime_group_metadata(
&group,
&mut prefix,
&mut self.middleware.schema_registry,
&mut self.middleware.human_views,
);
let mut prefix = Vec::new();
group.register_commands(&mut prefix, &mut self.commands);
let mut prefix = Vec::new();
Expand Down Expand Up @@ -1381,6 +1386,15 @@ impl Cli {
let meta = self.resolve_meta(&command_path, command.spec.metadata());
let default_fields = command.spec.default_fields.clone().unwrap_or_default();
let system = command.spec.system.clone().unwrap_or_default();
// The human view this command declared: an explicit shared id wins;
// otherwise an inline `with_view` was registered under the command path
// at build time, so reference it by that path. `None` renders generic
// human output.
let view_id = command
.spec
.view_id
.clone()
.or_else(|| (!command.spec.view_columns.is_empty()).then(|| command_path.clone()));

if let Some(streaming_handler) = command.streaming_handler.clone() {
let result = run_with_timeout(
Expand All @@ -1395,6 +1409,7 @@ impl Cli {
user_args,
args,
default_fields: &default_fields,
view_id: view_id.as_deref(),
auth: command.spec.auth,
},
Arc::new(leaf.clone()),
Expand Down Expand Up @@ -1425,6 +1440,7 @@ impl Cli {
user_args,
args,
default_fields: &default_fields,
view_id: view_id.as_deref(),
auth: command.spec.auth,
},
async move |credential| {
Expand Down Expand Up @@ -1467,17 +1483,29 @@ impl Cli {
let value_flags = derive_value_flags(&self.root);
let command_path =
self.canonical_command_path(&extract_command_path(args, &bool_flags, &value_flags));
let schema = self.middleware.schema_registry.get_by_path(&command_path)?;
// `--schema` is an inspection flag and must not require the command's own
// arguments, so it short-circuits before clap validates them. Only fire
// for a real leaf command, though: unknown paths and groups fall through
// so clap and `unknown_group_command_message` can report them as usual.
let command = find_command_by_colon_path(&self.root, &command_path)?;
if command.get_subcommands().next().is_some() {
return None;
}
let output_format =
extract_output_format(args, &default_output_format(&self.config.app_id));
Some(self.render_schema(schema, &output_format))
// When no schema is registered, report that rather than running the
// command — matching the middleware's no-schema response so the public
// path and the lower layer agree even when required args are missing.
match self.middleware.schema_registry.get_by_path(&command_path) {
Some(schema) => Some(self.render_schema(schema, &output_format)),
None => Some(self.render_schema(
crate::output::no_schema_response(&command_path),
&output_format,
)),
}
}

fn render_schema(
&self,
schema: crate::output::SchemaInfo,
output_format: &str,
) -> CliRunOutput {
fn render_schema(&self, data: impl serde::Serialize, output_format: &str) -> CliRunOutput {
let format: crate::output::OutputFormat = match output_format.parse() {
Ok(format) => format,
Err(err) => {
Expand All @@ -1488,7 +1516,7 @@ impl Cli {
}
};
let envelope =
crate::Envelope::success(schema, self.config.app_id.clone()).prepare_for_render("");
crate::Envelope::success(data, self.config.app_id.clone()).prepare_for_render("");
match crate::output::render(format, &envelope) {
Ok(rendered) => CliRunOutput {
exit_code: 0,
Expand Down Expand Up @@ -2567,18 +2595,31 @@ fn make_executable(_path: &Path) -> std::io::Result<()> {
Ok(())
}

fn register_runtime_group_schemas(
fn register_runtime_group_metadata(
group: &RuntimeGroupSpec,
prefix: &mut Vec<String>,
schemas: &mut SchemaRegistry,
views: &mut HumanViewRegistry,
) {
prefix.push(group.group.name.clone());
for child_group in &group.groups {
register_runtime_group_schemas(child_group, prefix, schemas);
register_runtime_group_metadata(child_group, prefix, schemas, views);
}
for child in &group.commands {
prefix.push(child.spec.name.clone());
register_command_schema(&child.spec, &prefix.join(":"), schemas);
let command_path = prefix.join(":");
register_command_schema(&child.spec, &command_path, schemas);
// An inline `with_view` is registered under the command's own path; the
// dispatch references it by that path. A `with_view_id` takes precedence
// (dispatch uses it instead), so skip the inline registration when one is
// set — registering it would leave an unused entry. Shared views are
// registered separately by the module/CLI.
if child.spec.view_id.is_none() && !child.spec.view_columns.is_empty() {
views.register(HumanViewDef::new(
command_path,
child.spec.view_columns.clone(),
));
}
prefix.pop();
}
prefix.pop();
Expand Down
43 changes: 42 additions & 1 deletion src/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ use tokio::sync::mpsc;

use crate::{
AuthRequirement, CommandMeta, Credential, CredentialResolver, Middleware, OutputSchema, Result,
SchemaInfo, Tier, middleware::ValueMap, output::NextAction,
SchemaInfo, Tier,
middleware::ValueMap,
output::{NextAction, TableColumn},
};

/// Sender half for streaming command output.
Expand Down Expand Up @@ -228,6 +230,19 @@ pub struct CommandSpec {
pub args: Vec<Arg>,
/// Optional output schema published through `--schema` and help.
pub output_schema: Option<SchemaInfo>,
/// Inline human-output table columns assigned directly to this command.
///
/// Set with [`with_view`](CommandSpec::with_view). When present (and
/// [`view_id`](CommandSpec::view_id) is unset), the engine registers these
/// columns under the command's own path so human output renders them.
pub view_columns: Vec<TableColumn>,
/// Id of a shared human view this command should use.
///
/// Set with [`with_view_id`](CommandSpec::with_view_id). Names a
/// [`HumanViewDef`](crate::HumanViewDef) registered with `with_view` on the
/// module or CLI, so several commands can share one table. Takes precedence
/// over inline [`view_columns`](CommandSpec::view_columns).
pub view_id: Option<String>,
}

impl CommandSpec {
Expand Down Expand Up @@ -298,6 +313,32 @@ impl CommandSpec {
self
}

/// Assigns an inline human-output table view to this command.
///
/// The columns are registered under the command's own path, so human output
/// renders this table directly. Field selection still applies: `--fields`
/// (defaulting to [`default_fields`](CommandSpec::default_fields)) narrows
/// which of these columns show. Use
/// [`with_view_id`](CommandSpec::with_view_id) instead to point at a shared
/// view registered with `with_view` on the module or CLI.
#[must_use]
pub fn with_view(mut self, columns: impl Into<Vec<TableColumn>>) -> Self {
self.view_columns = columns.into();
self
}

/// Points this command at a shared human view by id.
///
/// The id must match a [`HumanViewDef`](crate::HumanViewDef) registered with
/// `with_view` on the module or CLI, letting several commands share one
/// table. Takes precedence over inline [`with_view`](CommandSpec::with_view)
/// columns.
#[must_use]
pub fn with_view_id(mut self, id: impl Into<String>) -> Self {
self.view_id = Some(id.into());
self
}

/// Selects the auth provider for this command.
#[must_use]
pub fn with_auth_provider(mut self, provider: impl Into<String>) -> Self {
Expand Down
4 changes: 2 additions & 2 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,8 @@ pub use output::{
register_global_schema_fields, register_global_schema_info, render, render_data,
render_data_format, render_detailed_error, render_detailed_error_format, render_error,
render_error_format, render_format, render_human, render_human_with_registry,
render_human_with_registry_for_schema, render_human_with_view, render_json, render_toon,
write_render,
render_human_with_registry_for_schema, render_human_with_registry_selected,
render_human_with_view, render_json, render_toon, write_render,
};
pub use search::{SearchDocument, SearchResult};
pub use tier::Tier;
Expand Down
Loading