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
7 changes: 2 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,11 +239,8 @@ Command checklist:
- Prefer `CommandSpec::from_args::<T>()` + `RuntimeCommandSpec::new_typed` when the command has many flags, needs clap validation attributes, or when porting existing derive-based commands. Use the builder path for simple commands with one or two flags.
- Most commands need more than `new_typed`'s `(CredentialResolver, T)` shape — if the handler needs the command path, middleware, or `--dry-run` via `CommandContext`, use `RuntimeCommandSpec::new_typed_with_context` (handler: `Fn(CommandContext, T) -> Fut`) instead of `new_with_context` + `context.typed_args::<T>()`; for streaming commands, use `RuntimeCommandSpec::new_typed_streaming` (handler: `Fn(CommandContext, T, StreamSender) -> Fut`). Both eagerly parse `T` before the handler runs, same guarantee `new_typed` gives the credential-only case.
- Use `CommandSpec::with_arg_group(ArgGroup::new(...).args([...]).required(true))` for "at least one of" or mutually-exclusive relationships between args, instead of a `required_unless_present_any`/`conflicts_with` chain. With `from_args::<T>()`, express the same thing declaratively via a struct-level `#[group(required = true, multiple = true)]` (or `multiple = false` for mutually exclusive) on the derive struct — but not on a struct that also has a `#[command(flatten)]` field; `clap_derive` empties that struct's implicit group's members in that case, so the constraint silently does nothing.
- `--limit`/`--offset` are not framework-global: a command only gets them by calling
`.with_pagination(PaginationConfig { default_limit, max_limit, ..Default::default() })`. A
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.
- `--limit`/`--offset` are not framework-global: a command only gets them by calling `.with_pagination(PaginationConfig::new(default_limit, max_limit))`. A command with no `.with_pagination(...)` call never registers those flags. `default_limit` applies when the user passes neither flag; `max_limit` (`0` = uncapped) rejects an explicit `--limit` above the cap. Offset pagination is purely client-side: the engine slices whatever array the handler returns using the parsed `--limit`/`--offset` itself, so it only fits a backend that either hands back its full collection or itself supports arbitrary-offset slicing.
- Use `.with_cursor(CursorConfig::new(default_limit, max_limit))` instead of `.with_pagination` when the backend is cursor-based. This registers `--limit`/`--continue` instead of `--limit`/`--offset`. A command picks exactly one of `.with_pagination`/`.with_cursor`. Unlike offset pagination, the engine cannot slice or measure a cursor itself — the handler reads the parsed values back off `ctx.middleware.cursor_limit`/`.continue_token` to drive its own backend call, and reports what it learned via `CommandResult::with_cursor(CursorContinuation::more(next_token).with_total(n).with_remaining(n))`, or `CursorContinuation::done()` (or no call at all) once iteration is exhausted.
- 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
27 changes: 20 additions & 7 deletions cli-engine/docs/concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -275,13 +275,17 @@ values into middleware through `CliConfig::apply_flags`.
`--limit`/`--offset` are not framework-global; a command only gets them by opting in:

```rust
CommandSpec::new("list", "List projects").with_pagination(PaginationConfig {
default_limit: 20,
max_limit: 100,
..Default::default()
})
CommandSpec::new("list", "List projects").with_pagination(PaginationConfig::new(20, 100))
```

`--limit`/`--continue` are the cursor-pagination counterpart, for a command backed by a server-maintained, forward-only cursor API — see [cursor pagination](#cursor-pagination):

```rust
CommandSpec::new("list", "List domains").with_cursor(CursorConfig::new(25, 500))
```

A command opts into exactly one of `with_pagination`/`with_cursor`, never both.

## Middleware

Command execution flows through a consistent middleware chain:
Expand Down Expand Up @@ -508,6 +512,16 @@ the user ran — including every flag they passed — with `--limit`/`--offset`
page, so both agent callers (`next_actions[]`) and human callers (the "Next steps:" footer) get a
literal follow-up command instead of having to compute the next offset themselves.

### cursor pagination

A command that opted into `--limit`/`--continue` cursor pagination via `CommandSpec::with_cursor` gets a top-level `cursor` field on the envelope instead of `pagination` — `limit`, `count`, `total`, `remaining`, `continue_from`, `has_more`, and `self_sufficient_limit` — whenever it returned array data. Unlike `pagination`, the engine cannot compute this itself: a cursor is opaque to everything except the handler that called the backend, so `count` is the only piece the engine derives itself (the returned array's length); `limit` defaults to the parsed `--limit`, but a handler can override it via `CursorContinuation::with_limit` to report the effective page size it actually resumed with (e.g. one decoded from `continue_from` itself) — `self_sufficient_limit` is `true` exactly when a next page exists *and* that override happened (`with_limit` alone isn't enough — calling it on a completed `CursorContinuation::done()`, with no `continue_from` at all, must not claim a nonexistent token is self-sufficient), meaning `continue_from` alone is enough to resume and a replay command can omit `--limit`; `total`/`remaining`/`continue_from` come from whatever the handler reported via `CommandResult::with_cursor(CursorContinuation::more(token).with_total(n).with_remaining(n))` — or `CursorContinuation::done()` (or no call at all) to report the end of iteration. `total`/`remaining` are `None` when the backend never reports them, which a pure opaque-cursor API is not obligated to do.

Human output merges this into the table's row-count footer: `(N of M rows)` when a total is known, `(N rows, M remaining)` when only a remaining count is known, or `(N rows so far; use --limit L --continue <token> for more)` when neither is known — `--limit L` is omitted from this hint exactly when `self_sufficient_limit` is `true`, matching the `next_actions` entry below. A cursor-paginated response that doesn't render as a table gets the standalone counterpart: `Showing N of M`, `Showing N (M remaining)`, or `Showing N items so far; use --limit L --continue <token> for more` (same `--limit` omission rule).

When `continue_from` is present (`has_more`), the engine appends a `next_actions` entry replaying the command with `--continue <token>` for the next page, plus `--limit` — unless the handler's `CursorContinuation::with_limit` marked the token itself as already self-sufficient about page size, in which case `--limit` is omitted from the suggested command.

A command registers `with_pagination` or `with_cursor`, never both.

### fix

Failed commands can attach recovery guidance as a top-level `fix` on the error envelope
Expand All @@ -529,8 +543,7 @@ it.
The output pipeline runs in this order:

1. **Filtering**: `--filter` evaluates a JMESPath predicate against each item in list data.
2. **Pagination**: `--limit` and `--offset` slice list data and attach the envelope's top-level
`pagination` field (see [pagination](#pagination) above).
2. **Pagination**: `--limit` and `--offset` slice list data and attach the envelope's top-level `pagination` field (see [pagination](#pagination) above). Inert for a `with_cursor` command.
3. **Expression**: `--expr` evaluates a JMESPath query against the whole current result.
4. **Field selection**: `--fields` selects comma-separated fields and nested dot paths.
5. **Formatting**: `--output` renders `json`, `human`, or `toon`.
Expand Down
27 changes: 18 additions & 9 deletions cli-engine/docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -292,11 +292,17 @@ Framework global flags populate middleware and apply consistently to every comma
Applications can add their own global flags with `CliConfig::with_register_flags` and copy parsed
values into middleware with `CliConfig::with_apply_flags`.

`--limit`/`--offset` are the one exception: they are not global at all. A command registers them
for itself with `CommandSpec::with_pagination(PaginationConfig { default_limit, max_limit, ..Default::default() })`;
a command that never calls this has neither flag, in `--help` or on its command line.
`default_limit` applies when the user passes neither flag; `max_limit` (when non-zero) rejects an
explicit `--limit` above the cap.
Pagination flags are the one exception: they are not global at all, and a command registers at
most one of two mutually exclusive styles for itself. `CommandSpec::with_pagination(PaginationConfig::new(default_limit, max_limit))`
registers `--limit`/`--offset`, purely client-side (the engine slices whatever array the handler
returns). `CommandSpec::with_cursor(CursorConfig::new(default_limit, max_limit))` registers
`--limit`/`--continue` instead, for a backend with its own server-maintained, forward-only cursor —
the engine never slices or measures a cursor itself; the handler reads the parsed values back off
`Middleware::cursor_limit`/`.continue_token` and reports what it learned via
`CommandResult::with_cursor`. A command that calls neither has none of these flags, in `--help` or
on its command line. `default_limit` applies when the user passes neither flag; `max_limit` (when
non-zero) rejects an explicit `--limit` above the cap. See [concepts.md](concepts.md#cursor-pagination)
for the full cursor contract.

## Middleware

Expand Down Expand Up @@ -365,10 +371,13 @@ Handlers return JSON-serializable data and a system id. Middleware wraps the res
- `fix` (optional recovery guidance on failed commands)

Metadata is omitted unless `--verbose` is requested. Selective metadata is supported with
comma-separated verbose fields. `pagination`, unlike `metadata`, is never gated by `--verbose` —
a caller relies on it to know whether more data exists at all. It's still conditional on
pagination actually running, though: a paginating command with `default_limit: 0` ("unlimited")
and neither flag passed produces no `pagination` field at all.
comma-separated verbose fields. `pagination`/`cursor`, unlike `metadata`, are never gated by
`--verbose` — a caller relies on them to know whether more data exists at all. `pagination` is
still conditional on pagination actually running, though: a paginating command with
`default_limit: 0` ("unlimited") and neither flag passed produces no `pagination` field at all.
`cursor` is present whenever a `with_cursor` command returned array data, regardless of `--limit`/
`--continue`, since the engine can't measure a cursor itself the way it slices an offset — see
[concepts.md](concepts.md#cursor-pagination).

The output pipeline runs in this order:

Expand Down
9 changes: 3 additions & 6 deletions cli-engine/docs/proposals/cursor-first-pagination.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,20 +84,17 @@ However, there is an important caveat. With Slicing- or Paging-based APIs that w
### `CommandSpec::with_cursor`

```rust
CommandSpec::new("list", "List things").with_cursor(CursorConfig {
default_limit: 25,
max_limit: 500,
})
CommandSpec::new("list", "List things").with_cursor(CursorConfig::new(25, 500))
```

Registers `--limit`/`--continue` the same way `with_pagination` registers
`--limit`/`--offset` — opt-in per command, absent from `--help` and rejected as unknown otherwise.

### Envelope changes

Today's `PaginationMeta { total, offset, limit, count, has_more }` assumes both `total` and `offset` are always known — true for a client-side slice, not guaranteed for a real cursor API that may never report a true count. The `.with_cursor` counterpart needs `total`/`offset` to become optional (present when an adapter can supply them, absent for a pure opaque cursor) and add `continue_from: Option<String>`.
Today's `PaginationMeta { total, offset, limit, count, has_more }` assumes both `total` and `offset` are always known — true for a client-side slice, not guaranteed for a real cursor API that may never report a true count. `offset` itself has no cursor counterpart at all — there is no "skip N" concept for an opaque, forward-only token. The shipped `.with_cursor` counterpart is `CursorMeta { limit, count, total: Option<i64>, remaining: Option<i64>, continue_from: Option<String>, has_more, self_sufficient_limit }`: `total`/`remaining` are optional (present only when an adapter's backend reports them), and `self_sufficient_limit` records whether the handler's `continue_from` token already carries its own effective page size — see [concepts.md](../concepts.md#cursor-pagination) for the full contract.

Human output changes correspondingly when a total is unknown: "Showing 25 items so far — run with `--continue <token>` for more" instead of "Showing 25 of 143 rows, offset 0, limit 25". When a total *is* available (some cursor backends do report one, and every client-side-slice command still knows its own total), the existing "N of M" phrasing still applies.
Human output changes correspondingly when a total is unknown: "N rows so far; use --limit L --continue <token> for more" instead of "Showing 25 of 143 rows, offset 0, limit 25" (the `--limit` clause is omitted exactly when `self_sufficient_limit` is set). When a total *is* available (some cursor backends do report one, and every client-side-slice command still knows its own total), the existing "N of M" phrasing still applies.

`next_actions` needs no new mechanism — it already replays every flag the user passed and appends an updated pagination flag; for a `.with_cursor` command it appends `--continue <token>`.

Expand Down
Loading
Loading