Skip to content

Commit f5e2b72

Browse files
feat!: explicit human views, composable field/column selection, --schema short-circuit
Assign human views to commands explicitly with CommandSpec::with_view (inline columns) or with_view_id (shared view); the implicit command-path/system lookup is removed and system is now pure backend attribution. --fields (defaulting to default_fields) selects JSON fields when there is no view, or view columns when there is one. --schema short-circuits for any leaf command and reports when no schema is registered instead of running the command. BREAKING CHANGE: human views are no longer inferred from the command path or system — assign them with CommandSpec::with_view or with_view_id or they will not apply. Human output now honors default_fields and --fields narrows a registered view's columns, so human tables that previously showed every column now show only the selected set. New public fields were added to CommandSpec (view_columns, view_id) and MiddlewareRequest (view_id). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent ba1711e commit f5e2b72

11 files changed

Lines changed: 726 additions & 75 deletions

File tree

AGENTS.md

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -161,8 +161,7 @@ For a full module, prefer this shape:
161161
```rust
162162
use clap::Arg;
163163
use cli_engine::{
164-
CommandSpec, GroupSpec, HumanViewDef, Module, RuntimeCommandSpec, RuntimeGroupSpec,
165-
TableColumn,
164+
CommandSpec, GroupSpec, Module, RuntimeCommandSpec, RuntimeGroupSpec, TableColumn,
166165
};
167166
use schemars::JsonSchema;
168167
use serde::Serialize;
@@ -180,14 +179,6 @@ pub fn module() -> Module {
180179
RuntimeGroupSpec::new(GroupSpec::new("project", "Manage projects"))
181180
.with_command(list_projects())
182181
})
183-
.with_view(HumanViewDef::new(
184-
"project:list",
185-
vec![
186-
TableColumn::new("id", "ID"),
187-
TableColumn::new("name", "Name"),
188-
TableColumn::new("status", "Status"),
189-
],
190-
))
191182
}
192183

193184
fn list_projects() -> RuntimeCommandSpec {
@@ -196,6 +187,11 @@ fn list_projects() -> RuntimeCommandSpec {
196187
.with_system("projects-api")
197188
.with_default_fields("id,name,status")
198189
.with_json_schema::<Project>()
190+
.with_view(vec![
191+
TableColumn::new("id", "ID"),
192+
TableColumn::new("name", "Name"),
193+
TableColumn::new("status", "Status"),
194+
])
199195
.with_arg(Arg::new("team").long("team").required(true)),
200196
async |_credential, args| {
201197
let team = args
@@ -240,7 +236,7 @@ Command checklist:
240236
backend attribution.
241237
- Register schemas with `.with_json_schema::<T>()` when a Rust response type exists.
242238
- Use manual `OutputSchema`, `OutputField`, `FieldInfo`, and `SchemaInfo` only when generated JSON Schema is not practical.
243-
- Register human views with `HumanViewDef::new` and `TableColumn::new` when a command needs column-oriented terminal output.
239+
- 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.
244240
- Keep stdout machine-friendly and stderr human-friendly for executable paths.
245241

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

docs/concepts.md

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -520,21 +520,43 @@ Human output is designed for readable terminal display:
520520
- Objects in fallback lines render as compact JSON.
521521
- JSON numbers use `serde_json` number text.
522522

523-
Column views should be registered by command path or schema id:
523+
Views can be assigned to commands. There are two ways to do it.
524+
525+
Assign an inline view directly to a command with `CommandSpec::with_view`:
524526

525527
```rust
526-
use cli_engine::{HumanViewDef, TableColumn};
528+
use cli_engine::{CommandSpec, TableColumn};
527529

528-
let view = HumanViewDef::new(
529-
"project:list",
530+
let spec = CommandSpec::new("list", "List projects").with_view(vec![
531+
TableColumn::new("id", "ID"),
532+
TableColumn::new("name", "Name"),
533+
TableColumn::new("status", "Status"),
534+
]);
535+
```
536+
537+
Or register a shared view once on the module (or CLI) and reference it by id from
538+
each command that should reuse it with `CommandSpec::with_view_id`:
539+
540+
```rust
541+
use cli_engine::{CommandSpec, HumanViewDef, TableColumn};
542+
543+
// Registered on the module/CLI with `.with_view(...)`:
544+
let shared = HumanViewDef::new(
545+
"projects-table",
530546
vec![
531547
TableColumn::new("id", "ID"),
532548
TableColumn::new("name", "Name"),
533549
TableColumn::new("status", "Status"),
534550
],
535551
);
552+
553+
// Referenced from any command that should use it:
554+
let spec = CommandSpec::new("get", "Get a project").with_view_id("projects-table");
536555
```
537556

557+
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
558+
ignores field selection.
559+
538560
## Guides
539561

540562
Guides are markdown documents registered with the CLI or with modules. They document workflows,

src/cli.rs

Lines changed: 54 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ use crate::{
3232
guide::guide_content,
3333
module::{Module, ModuleContext},
3434
output::{
35-
HumanViewDef, NextAction, SchemaRegistry, format_help_section,
35+
HumanViewDef, HumanViewRegistry, NextAction, SchemaRegistry, format_help_section,
3636
global_human_view_registry_snapshot, global_schema_registry_snapshot,
3737
},
3838
search::{SearchDocument, SearchIndex},
@@ -890,7 +890,12 @@ impl Cli {
890890
}
891891

892892
let mut prefix = Vec::new();
893-
register_runtime_group_schemas(&group, &mut prefix, &mut self.middleware.schema_registry);
893+
register_runtime_group_metadata(
894+
&group,
895+
&mut prefix,
896+
&mut self.middleware.schema_registry,
897+
&mut self.middleware.human_views,
898+
);
894899
let mut prefix = Vec::new();
895900
group.register_commands(&mut prefix, &mut self.commands);
896901
let mut prefix = Vec::new();
@@ -1381,6 +1386,15 @@ impl Cli {
13811386
let meta = self.resolve_meta(&command_path, command.spec.metadata());
13821387
let default_fields = command.spec.default_fields.clone().unwrap_or_default();
13831388
let system = command.spec.system.clone().unwrap_or_default();
1389+
// The human view this command declared: an explicit shared id wins;
1390+
// otherwise an inline `with_view` was registered under the command path
1391+
// at build time, so reference it by that path. `None` renders generic
1392+
// human output.
1393+
let view_id = command
1394+
.spec
1395+
.view_id
1396+
.clone()
1397+
.or_else(|| (!command.spec.view_columns.is_empty()).then(|| command_path.clone()));
13841398

13851399
if let Some(streaming_handler) = command.streaming_handler.clone() {
13861400
let result = run_with_timeout(
@@ -1395,6 +1409,7 @@ impl Cli {
13951409
user_args,
13961410
args,
13971411
default_fields: &default_fields,
1412+
view_id: view_id.as_deref(),
13981413
auth: command.spec.auth,
13991414
},
14001415
Arc::new(leaf.clone()),
@@ -1425,6 +1440,7 @@ impl Cli {
14251440
user_args,
14261441
args,
14271442
default_fields: &default_fields,
1443+
view_id: view_id.as_deref(),
14281444
auth: command.spec.auth,
14291445
},
14301446
async move |credential| {
@@ -1467,17 +1483,29 @@ impl Cli {
14671483
let value_flags = derive_value_flags(&self.root);
14681484
let command_path =
14691485
self.canonical_command_path(&extract_command_path(args, &bool_flags, &value_flags));
1470-
let schema = self.middleware.schema_registry.get_by_path(&command_path)?;
1486+
// `--schema` is an inspection flag and must not require the command's own
1487+
// arguments, so it short-circuits before clap validates them. Only fire
1488+
// for a real leaf command, though: unknown paths and groups fall through
1489+
// so clap and `unknown_group_command_message` can report them as usual.
1490+
let command = find_command_by_colon_path(&self.root, &command_path)?;
1491+
if command.get_subcommands().next().is_some() {
1492+
return None;
1493+
}
14711494
let output_format =
14721495
extract_output_format(args, &default_output_format(&self.config.app_id));
1473-
Some(self.render_schema(schema, &output_format))
1496+
// When no schema is registered, report that rather than running the
1497+
// command — matching the middleware's no-schema response so the public
1498+
// path and the lower layer agree even when required args are missing.
1499+
match self.middleware.schema_registry.get_by_path(&command_path) {
1500+
Some(schema) => Some(self.render_schema(schema, &output_format)),
1501+
None => Some(self.render_schema(
1502+
crate::output::no_schema_response(&command_path),
1503+
&output_format,
1504+
)),
1505+
}
14741506
}
14751507

1476-
fn render_schema(
1477-
&self,
1478-
schema: crate::output::SchemaInfo,
1479-
output_format: &str,
1480-
) -> CliRunOutput {
1508+
fn render_schema(&self, data: impl serde::Serialize, output_format: &str) -> CliRunOutput {
14811509
let format: crate::output::OutputFormat = match output_format.parse() {
14821510
Ok(format) => format,
14831511
Err(err) => {
@@ -1488,7 +1516,7 @@ impl Cli {
14881516
}
14891517
};
14901518
let envelope =
1491-
crate::Envelope::success(schema, self.config.app_id.clone()).prepare_for_render("");
1519+
crate::Envelope::success(data, self.config.app_id.clone()).prepare_for_render("");
14921520
match crate::output::render(format, &envelope) {
14931521
Ok(rendered) => CliRunOutput {
14941522
exit_code: 0,
@@ -2567,18 +2595,31 @@ fn make_executable(_path: &Path) -> std::io::Result<()> {
25672595
Ok(())
25682596
}
25692597

2570-
fn register_runtime_group_schemas(
2598+
fn register_runtime_group_metadata(
25712599
group: &RuntimeGroupSpec,
25722600
prefix: &mut Vec<String>,
25732601
schemas: &mut SchemaRegistry,
2602+
views: &mut HumanViewRegistry,
25742603
) {
25752604
prefix.push(group.group.name.clone());
25762605
for child_group in &group.groups {
2577-
register_runtime_group_schemas(child_group, prefix, schemas);
2606+
register_runtime_group_metadata(child_group, prefix, schemas, views);
25782607
}
25792608
for child in &group.commands {
25802609
prefix.push(child.spec.name.clone());
2581-
register_command_schema(&child.spec, &prefix.join(":"), schemas);
2610+
let command_path = prefix.join(":");
2611+
register_command_schema(&child.spec, &command_path, schemas);
2612+
// An inline `with_view` is registered under the command's own path; the
2613+
// dispatch references it by that path. A `with_view_id` takes precedence
2614+
// (dispatch uses it instead), so skip the inline registration when one is
2615+
// set — registering it would leave an unused entry. Shared views are
2616+
// registered separately by the module/CLI.
2617+
if child.spec.view_id.is_none() && !child.spec.view_columns.is_empty() {
2618+
views.register(HumanViewDef::new(
2619+
command_path,
2620+
child.spec.view_columns.clone(),
2621+
));
2622+
}
25822623
prefix.pop();
25832624
}
25842625
prefix.pop();

src/command.rs

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ use tokio::sync::mpsc;
77

88
use crate::{
99
AuthRequirement, CommandMeta, Credential, CredentialResolver, Middleware, OutputSchema, Result,
10-
SchemaInfo, Tier, middleware::ValueMap, output::NextAction,
10+
SchemaInfo, Tier,
11+
middleware::ValueMap,
12+
output::{NextAction, TableColumn},
1113
};
1214

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

233248
impl CommandSpec {
@@ -298,6 +313,32 @@ impl CommandSpec {
298313
self
299314
}
300315

316+
/// Assigns an inline human-output table view to this command.
317+
///
318+
/// The columns are registered under the command's own path, so human output
319+
/// renders this table directly. Field selection still applies: `--fields`
320+
/// (defaulting to [`default_fields`](CommandSpec::default_fields)) narrows
321+
/// which of these columns show. Use
322+
/// [`with_view_id`](CommandSpec::with_view_id) instead to point at a shared
323+
/// view registered with `with_view` on the module or CLI.
324+
#[must_use]
325+
pub fn with_view(mut self, columns: impl Into<Vec<TableColumn>>) -> Self {
326+
self.view_columns = columns.into();
327+
self
328+
}
329+
330+
/// Points this command at a shared human view by id.
331+
///
332+
/// The id must match a [`HumanViewDef`](crate::HumanViewDef) registered with
333+
/// `with_view` on the module or CLI, letting several commands share one
334+
/// table. Takes precedence over inline [`with_view`](CommandSpec::with_view)
335+
/// columns.
336+
#[must_use]
337+
pub fn with_view_id(mut self, id: impl Into<String>) -> Self {
338+
self.view_id = Some(id.into());
339+
self
340+
}
341+
301342
/// Selects the auth provider for this command.
302343
#[must_use]
303344
pub fn with_auth_provider(mut self, provider: impl Into<String>) -> Self {

src/lib.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,8 +147,8 @@ pub use output::{
147147
register_global_schema_fields, register_global_schema_info, render, render_data,
148148
render_data_format, render_detailed_error, render_detailed_error_format, render_error,
149149
render_error_format, render_format, render_human, render_human_with_registry,
150-
render_human_with_registry_for_schema, render_human_with_view, render_json, render_toon,
151-
write_render,
150+
render_human_with_registry_for_schema, render_human_with_registry_selected,
151+
render_human_with_view, render_json, render_toon, write_render,
152152
};
153153
pub use search::{SearchDocument, SearchResult};
154154
pub use tier::Tier;

0 commit comments

Comments
 (0)