diff --git a/apps/docs/content/docs/cli/audit-logs.mdx b/apps/docs/content/docs/cli/audit-logs.mdx index 05d0e9e3acc..a03c7a7c136 100644 --- a/apps/docs/content/docs/cli/audit-logs.mdx +++ b/apps/docs/content/docs/cli/audit-logs.mdx @@ -59,6 +59,7 @@ List Audit Logs (OAuth login or personal API key required) | `--include-departed` | No | Include actions by users who have left the organization. | | `--no-include-departed` | No | Send --include-departed as false. | | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | | `--organization ` | No | Organization ID; defaults to your only organization, and is required when your account belongs to more than one (OAuth login or personal API key required). | | `--actor-email ` | No | Filter by actor email address. | | `--all-workspaces` | No | Do not filter to the configured workspace (OAuth login or personal API key required for account-wide access). | diff --git a/apps/docs/content/docs/cli/billing.mdx b/apps/docs/content/docs/cli/billing.mdx index 7460d6e630b..7e9d7cf6efa 100644 --- a/apps/docs/content/docs/cli/billing.mdx +++ b/apps/docs/content/docs/cli/billing.mdx @@ -44,6 +44,7 @@ List credit usage events (an OAuth login or personal API key reports only your e | `--start-date ` | No | Custom period start (ISO 8601). | | `--end-date ` | No | Custom period end (ISO 8601). | | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | | `--all-workspaces` | No | Do not filter to the configured workspace (OAuth login or personal API key required for account-wide access). | diff --git a/apps/docs/content/docs/cli/blocks.mdx b/apps/docs/content/docs/cli/blocks.mdx index 9affcb3485d..96166c9fee2 100644 --- a/apps/docs/content/docs/cli/blocks.mdx +++ b/apps/docs/content/docs/cli/blocks.mdx @@ -41,6 +41,6 @@ sim blocks list [options] | `--source ` | No | Restrict to built-in blocks or this workspace's deployed custom blocks. Accepted values: `builtin`, `custom`. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `id`, `name`, `category`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | diff --git a/apps/docs/content/docs/cli/chat-deployments.mdx b/apps/docs/content/docs/cli/chat-deployments.mdx index 840e10a0a6c..59ab2d52d74 100644 --- a/apps/docs/content/docs/cli/chat-deployments.mdx +++ b/apps/docs/content/docs/cli/chat-deployments.mdx @@ -24,6 +24,6 @@ sim chat-deployments list [options] | `--no-is-active` | No | Send --is-active as false. | | `--sort-by ` | No | Field used to sort the result. Accepted values: `identifier`, `createdAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | diff --git a/apps/docs/content/docs/cli/credentials.mdx b/apps/docs/content/docs/cli/credentials.mdx index cbab5289619..8fdf6b767b2 100644 --- a/apps/docs/content/docs/cli/credentials.mdx +++ b/apps/docs/content/docs/cli/credentials.mdx @@ -70,7 +70,7 @@ sim credentials list [options] | `--search ` | No | Case-insensitive substring match against the credential display name. | | `--sort-by ` | No | Field used to sort the result. Accepted values: `displayName`, `createdAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | diff --git a/apps/docs/content/docs/cli/custom-tools.mdx b/apps/docs/content/docs/cli/custom-tools.mdx index 097d4fb3fed..5c83e67c805 100644 --- a/apps/docs/content/docs/cli/custom-tools.mdx +++ b/apps/docs/content/docs/cli/custom-tools.mdx @@ -84,7 +84,7 @@ sim custom-tools list [options] | `--search ` | No | Case-insensitive substring match against the tool title. | | `--sort-by ` | No | Field used to sort the result. Accepted values: `title`, `createdAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | diff --git a/apps/docs/content/docs/cli/files.mdx b/apps/docs/content/docs/cli/files.mdx index cad15d5cf51..e952bf299b6 100644 --- a/apps/docs/content/docs/cli/files.mdx +++ b/apps/docs/content/docs/cli/files.mdx @@ -292,7 +292,7 @@ sim files list [options] | `--search ` | No | Case-insensitive substring match against the file name. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `size`, `uploadedAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | @@ -536,7 +536,7 @@ sim files ls [path] [options] | Option | Required | Description | | --- | --- | --- | | `--search ` | No | Filter folders and resources by name. | -| `--limit ` | No | Maximum combined items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum combined items to return (0 for everything). Defaults to `0`. | diff --git a/apps/docs/content/docs/cli/knowledge.mdx b/apps/docs/content/docs/cli/knowledge.mdx index 257f6fa765e..1dcd516b7fe 100644 --- a/apps/docs/content/docs/cli/knowledge.mdx +++ b/apps/docs/content/docs/cli/knowledge.mdx @@ -388,6 +388,7 @@ List Chunks (OAuth login or personal API key required) | `--sort-by ` | No | Field used to sort the result. Accepted values: `chunkIndex`, `tokenCount`, `enabled`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | @@ -525,6 +526,7 @@ sim knowledge documents list [options] | `--enabled-filter ` | No | Filter by whether documents are enabled for search. Accepted values: `all`, `enabled`, `disabled`. | | `--sort-by ` | No | Field used to sort the result. Sorting by `filename` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `filename`, `fileSize`, `tokenCount`, `chunkCount`, `uploadedAt`, `processingStatus`, `enabled`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | | `--tag-filters ` | No | A JSON-encoded array of at most 10 tag filters, using the same display-name shape as knowledge search: `[{"tagName":"category","operator":"eq","value":"billing"}]`. Every filter must hold, including two that name the same tag. A name that is not defined in this knowledge base is rejected, never ignored. | @@ -740,6 +742,7 @@ List Knowledge Connector Documents (OAuth login or personal API key required) | `--include-excluded` | No | Include documents explicitly excluded by a user. | | `--no-include-excluded` | No | Send --include-excluded as false. | | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | @@ -799,7 +802,7 @@ List Knowledge Connectors (OAuth login or personal API key required) | --- | --- | --- | | `--sort-by ` | No | Field used to sort the result. Accepted values: `connectorType`, `createdAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | @@ -1006,7 +1009,7 @@ sim knowledge list [options] | `--search ` | No | Case-insensitive substring match against the resource name. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | @@ -1119,7 +1122,7 @@ sim knowledge ls [path] [options] | Option | Required | Description | | --- | --- | --- | | `--search ` | No | Filter folders and resources by name. | -| `--limit ` | No | Maximum combined items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum combined items to return (0 for everything). Defaults to `0`. | diff --git a/apps/docs/content/docs/cli/logs.mdx b/apps/docs/content/docs/cli/logs.mdx index 1a330a5d077..281dfb3bce5 100644 --- a/apps/docs/content/docs/cli/logs.mdx +++ b/apps/docs/content/docs/cli/logs.mdx @@ -83,6 +83,7 @@ sim logs list [options] | `--include-trace-spans` | No | Include trace spans in JSON or YAML output (implies full detail). | | `--include-final-output` | No | Include final output in JSON or YAML output (implies full detail). | | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | | `--status ` | No | Comma-separated execution statuses to include, from `pending` \| `running` \| `paused` \| `redacting` \| `completed` \| `failed` \| `cancelled`. An empty entry is rejected. ANDed with `level`, which reports severity rather than lifecycle. | | `--workflow-name ` | No | Case-insensitive substring match against the run's workflow name. Runs whose workflow has been deleted match nothing, because the name is no longer joinable. | | `--include-job-runs` | No | Include Chat and Sim-agent jobs alongside workflow runs. Jobs use `kind: "job"` and have no workflow or cost ledger. Workflow, folder, model, or status filters exclude jobs. This option is valid only when sorting by `startedAt`. | diff --git a/apps/docs/content/docs/cli/mcp-servers.mdx b/apps/docs/content/docs/cli/mcp-servers.mdx index ee8ab322e07..bb9559b297b 100644 --- a/apps/docs/content/docs/cli/mcp-servers.mdx +++ b/apps/docs/content/docs/cli/mcp-servers.mdx @@ -93,7 +93,7 @@ sim mcp-servers list [options] | `--search ` | No | Case-insensitive substring match against the server name. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | diff --git a/apps/docs/content/docs/cli/output.mdx b/apps/docs/content/docs/cli/output.mdx index b85aa9cb431..9c4630a5586 100644 --- a/apps/docs/content/docs/cli/output.mdx +++ b/apps/docs/content/docs/cli/output.mdx @@ -27,6 +27,15 @@ SIM_OUTPUT=yaml sim logs list > logs.yaml `json` and `yaml` emit the API's raw values, not the table's formatting — a duration stays `1500`, not `"1.5s"`. +Paginated lists include the rows under `data` and the continuation cursor under +`nextCursor`. A `null` cursor means no pages remain: + +```json +{ "data": [{ "runId": "9c4f0b7e-2d81-4a35-b6e9-70f1c8a2d543" }], "nextCursor": null } +``` + +Read list rows with `jq '.data[]'` and check for more pages with `jq '.nextCursor'`. + `table` formats for reading: timestamps without milliseconds, sizes as `4.2 MB`, booleans as `yes`/`no`, costs as `$0.0142`. Long cells are clipped to keep rows on one line; switch to `json` for the full value. diff --git a/apps/docs/content/docs/cli/reference.mdx b/apps/docs/content/docs/cli/reference.mdx index 7a9eba06685..61f299f4b36 100644 --- a/apps/docs/content/docs/cli/reference.mdx +++ b/apps/docs/content/docs/cli/reference.mdx @@ -225,6 +225,7 @@ sim audit-logs list [options] | `--include-departed` | No | Include actions by users who have left the organization. | | `--no-include-departed` | No | Send --include-departed as false. | | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | | `--organization ` | No | Organization ID; defaults to your only organization, and is required when your account belongs to more than one (OAuth login or personal API key required). | | `--actor-email ` | No | Filter by actor email address. | | `--all-workspaces` | No | Do not filter to the configured workspace (OAuth login or personal API key required for account-wide access). | @@ -270,6 +271,7 @@ sim billing logs [options] | `--start-date ` | No | Custom period start (ISO 8601). | | `--end-date ` | No | Custom period end (ISO 8601). | | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | | `--all-workspaces` | No | Do not filter to the configured workspace (OAuth login or personal API key required for account-wide access). | @@ -314,7 +316,7 @@ sim blocks list [options] | `--source ` | No | Restrict to built-in blocks or this workspace's deployed custom blocks. Accepted values: `builtin`, `custom`. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `id`, `name`, `category`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | @@ -339,7 +341,7 @@ sim chat-deployments list [options] | `--no-is-active` | No | Send --is-active as false. | | `--sort-by ` | No | Field used to sort the result. Accepted values: `identifier`, `createdAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | @@ -432,7 +434,7 @@ sim credentials list [options] | `--search ` | No | Case-insensitive substring match against the credential display name. | | `--sort-by ` | No | Field used to sort the result. Accepted values: `displayName`, `createdAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | @@ -643,7 +645,7 @@ sim custom-tools list [options] | `--search ` | No | Case-insensitive substring match against the tool title. | | `--sort-by ` | No | Field used to sort the result. Accepted values: `title`, `createdAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | @@ -988,7 +990,7 @@ sim files list [options] | `--search ` | No | Case-insensitive substring match against the file name. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `size`, `uploadedAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | @@ -1252,7 +1254,7 @@ sim files ls [path] [options] | Option | Required | Description | | --- | --- | --- | | `--search ` | No | Filter folders and resources by name. | -| `--limit ` | No | Maximum combined items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum combined items to return (0 for everything). Defaults to `0`. | @@ -1659,6 +1661,7 @@ sim knowledge chunks list [options] | `--sort-by ` | No | Field used to sort the result. Accepted values: `chunkIndex`, `tokenCount`, `enabled`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | @@ -1802,6 +1805,7 @@ sim knowledge documents list [options] | `--enabled-filter ` | No | Filter by whether documents are enabled for search. Accepted values: `all`, `enabled`, `disabled`. | | `--sort-by ` | No | Field used to sort the result. Sorting by `filename` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `filename`, `fileSize`, `tokenCount`, `chunkCount`, `uploadedAt`, `processingStatus`, `enabled`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | | `--tag-filters ` | No | A JSON-encoded array of at most 10 tag filters, using the same display-name shape as knowledge search: `[{"tagName":"category","operator":"eq","value":"billing"}]`. Every filter must hold, including two that name the same tag. A name that is not defined in this knowledge base is rejected, never ignored. | @@ -2021,6 +2025,7 @@ sim knowledge connectors documents list [options | `--include-excluded` | No | Include documents explicitly excluded by a user. | | `--no-include-excluded` | No | Send --include-excluded as false. | | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | @@ -2080,7 +2085,7 @@ sim knowledge connectors list [options] | --- | --- | --- | | `--sort-by ` | No | Field used to sort the result. Accepted values: `connectorType`, `createdAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | @@ -2301,7 +2306,7 @@ sim knowledge list [options] | `--search ` | No | Case-insensitive substring match against the resource name. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | @@ -2424,7 +2429,7 @@ sim knowledge ls [path] [options] | Option | Required | Description | | --- | --- | --- | | `--search ` | No | Filter folders and resources by name. | -| `--limit ` | No | Maximum combined items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum combined items to return (0 for everything). Defaults to `0`. | @@ -2530,6 +2535,7 @@ sim logs list [options] | `--include-trace-spans` | No | Include trace spans in JSON or YAML output (implies full detail). | | `--include-final-output` | No | Include final output in JSON or YAML output (implies full detail). | | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | | `--status ` | No | Comma-separated execution statuses to include, from `pending` \| `running` \| `paused` \| `redacting` \| `completed` \| `failed` \| `cancelled`. An empty entry is rejected. ANDed with `level`, which reports severity rather than lifecycle. | | `--workflow-name ` | No | Case-insensitive substring match against the run's workflow name. Runs whose workflow has been deleted match nothing, because the name is no longer joinable. | | `--include-job-runs` | No | Include Chat and Sim-agent jobs alongside workflow runs. Jobs use `kind: "job"` and have no workflow or cost ledger. Workflow, folder, model, or status filters exclude jobs. This option is valid only when sorting by `startedAt`. | @@ -2661,7 +2667,7 @@ sim mcp-servers list [options] | `--search ` | No | Case-insensitive substring match against the server name. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | @@ -2832,7 +2838,7 @@ sim sandboxes list [options] | `--search ` | No | Case-insensitive substring match against the sandbox name. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | @@ -2919,7 +2925,7 @@ sim secrets list [options] | `--search ` | No | Case-insensitive substring match against the secret name. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | @@ -3079,7 +3085,7 @@ sim skills editors list [options] | --- | --- | --- | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `email`, `name`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | @@ -3129,7 +3135,7 @@ sim skills list [options] | `--search ` | No | Case-insensitive substring match against the skill name. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | @@ -3562,6 +3568,7 @@ sim tables rows list [options] | Option | Required | Description | | --- | --- | --- | | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | | `--include-run-state` | No | Include per-workflow-group run state on every returned row. Off by default: run state is a separate sidecar read and its `blockErrors` are unbounded, so a full page carries it only when asked. Caps `limit` at 200. | | `--no-include-run-state` | No | Send --include-run-state as false. | @@ -3594,6 +3601,7 @@ sim tables rows query [options] | `--filter ` | No | Condition: {"field":"status","op":"eq","value":"active"}. Groups: {"all":[{"field":"status","op":"eq","value":"active"}]} or {"any":[{"field":"status","op":"eq","value":"active"}]}; group entries may also be nested groups. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | | `--sort ` | No | Ordered sort keys: [{"field":"createdAt","direction":"desc"}] (direction: asc or desc) (JSON, or @path / @- to read a file or stdin). | | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | | `--include-run-state` | No | Include per-workflow-group run state on every returned row. Off by default: run state is a separate sidecar read and its `blockErrors` are unbounded, so a full page carries it only when asked. Incompatible with `limit: 0`, and caps `limit` at 200. | | `--no-include-run-state` | No | Send --include-run-state as false. | @@ -4342,7 +4350,7 @@ sim tables list [options] | `--search ` | No | Case-insensitive substring match against the resource name. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | @@ -4524,7 +4532,7 @@ sim tables ls [path] [options] | Option | Required | Description | | --- | --- | --- | | `--search ` | No | Filter folders and resources by name. | -| `--limit ` | No | Maximum combined items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum combined items to return (0 for everything). Defaults to `0`. | @@ -4615,7 +4623,7 @@ sim tools list [options] | `--oauth-provider ` | No | Restrict to tools that authenticate against this OAuth service. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `id`, `name`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | @@ -4783,7 +4791,7 @@ sim workflow-mcp-servers list [options] | --- | --- | --- | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | @@ -4996,6 +5004,7 @@ sim workflows runs list [options] | `--start-date ` | No | Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | | `--end-date ` | No | Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | | `--order ` | No | Sort direction by run start time. This list is sortable only by run start time, so it takes `order` in place of `sortBy`/`sortOrder`, which it rejects. Accepted values: `asc`, `desc`. | @@ -5564,6 +5573,7 @@ sim workflows versions list [options] | Option | Required | Description | | --- | --- | --- | | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | @@ -5636,7 +5646,7 @@ sim workflows list [options] | `--folder ` | No | Folder path as shown in the app; the leading / is optional. | | `--deployed-only` | No | Return only workflows with an active deployment when true. | | `--no-deployed-only` | No | Send --deployed-only as false. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | | `--search ` | No | Case-insensitive substring match against the resource name. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `position`, `name`, `createdAt`, `updatedAt`, `runCount`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | @@ -5840,7 +5850,7 @@ sim workflows ls [path] [options] | Option | Required | Description | | --- | --- | --- | | `--search ` | No | Filter folders and resources by name. | -| `--limit ` | No | Maximum combined items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum combined items to return (0 for everything). Defaults to `0`. | @@ -5888,7 +5898,7 @@ sim workspaces members [options] | Option | Required | Description | | --- | --- | --- | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | @@ -5908,6 +5918,6 @@ sim workspaces list [options] | --- | --- | --- | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | diff --git a/apps/docs/content/docs/cli/sandboxes.mdx b/apps/docs/content/docs/cli/sandboxes.mdx index fc2a3c125a3..4ebd42422e1 100644 --- a/apps/docs/content/docs/cli/sandboxes.mdx +++ b/apps/docs/content/docs/cli/sandboxes.mdx @@ -90,7 +90,7 @@ sim sandboxes list [options] | `--search ` | No | Case-insensitive substring match against the sandbox name. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | diff --git a/apps/docs/content/docs/cli/scripting.mdx b/apps/docs/content/docs/cli/scripting.mdx index af4dc93cce9..990380699e8 100644 --- a/apps/docs/content/docs/cli/scripting.mdx +++ b/apps/docs/content/docs/cli/scripting.mdx @@ -69,13 +69,49 @@ sim tables rows query tbl_9f3c1a05d4b7426e8c2f0917ab35de64 --sort '[{"field":"cr ## Pagination -List commands page automatically up to `--limit`, which defaults to `100`. Pass -`--limit 0` to fetch everything: +Resource lists and directory `ls` follow every page by default. Pass `--limit N` +to cap the returned items: + +```bash +sim workflows list --output json +sim files ls /Reports --limit 50 +``` + +Table rows (including queries), logs, audit/billing events, workflow runs and +versions, and knowledge documents/chunks retain a default limit of `100`. +Connector document lists also use this cap. Use `--limit 0` to fetch every page +of a large dataset: ```bash sim logs list --limit 0 --output json > all-logs.json ``` +Pages are fetched sequentially, accumulated in memory, and printed as one +result. A large table can consume substantial memory; use filters or an explicit +limit when you only need a subset. Each API request fetches at most 100 items. + +Paginated JSON and YAML results have the shape `{ data: [...], nextCursor }`. +`nextCursor` is the cursor after the last returned row, or `null` when all pages +have been fetched. Scripts that read list rows should use `.data[]`: + +```bash +sim logs list --output json | jq -r '.data[].runId' +``` + +The capped dataset commands accept `--cursor` to resume from the previous +result's `nextCursor`. Keep the same resource, filters, and sort order, and stop +when the cursor is `null`: + +```bash +sim tables rows list tbl_9f3c1a05d4b7426e8c2f0917ab35de64 --limit 100 --output json +sim tables rows list tbl_9f3c1a05d4b7426e8c2f0917ab35de64 --limit 100 --cursor "$nextCursor" --output json +``` + +`--limit` applies to each invocation starting at its cursor. `--limit 0` with +`--cursor` fetches every remaining page. Table-row queries, logs (including +billing and audit logs), workflow run/version histories, and knowledge +document/chunk lists all support this continuation. + ## Destructive commands Deletions require an explicit selector **and** `--yes`. There is no "delete diff --git a/apps/docs/content/docs/cli/secrets.mdx b/apps/docs/content/docs/cli/secrets.mdx index 6cd7d80aa29..67ce8e273eb 100644 --- a/apps/docs/content/docs/cli/secrets.mdx +++ b/apps/docs/content/docs/cli/secrets.mdx @@ -56,7 +56,7 @@ List Secrets (OAuth login or personal API key required) | `--search ` | No | Case-insensitive substring match against the secret name. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | diff --git a/apps/docs/content/docs/cli/skills.mdx b/apps/docs/content/docs/cli/skills.mdx index b11c57d4030..76bdf2da396 100644 --- a/apps/docs/content/docs/cli/skills.mdx +++ b/apps/docs/content/docs/cli/skills.mdx @@ -125,7 +125,7 @@ sim skills editors list [options] | --- | --- | --- | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `email`, `name`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | @@ -173,7 +173,7 @@ sim skills list [options] | `--search ` | No | Case-insensitive substring match against the skill name. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | diff --git a/apps/docs/content/docs/cli/tables.mdx b/apps/docs/content/docs/cli/tables.mdx index f7d5b11166a..b1290e4af50 100644 --- a/apps/docs/content/docs/cli/tables.mdx +++ b/apps/docs/content/docs/cli/tables.mdx @@ -376,6 +376,7 @@ sim tables rows list [options] | Option | Required | Description | | --- | --- | --- | | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | | `--include-run-state` | No | Include per-workflow-group run state on every returned row. Off by default: run state is a separate sidecar read and its `blockErrors` are unbounded, so a full page carries it only when asked. Caps `limit` at 200. | | `--no-include-run-state` | No | Send --include-run-state as false. | @@ -406,6 +407,7 @@ sim tables rows query [options] | `--filter ` | No | Condition: {"field":"status","op":"eq","value":"active"}. Groups: {"all":[{"field":"status","op":"eq","value":"active"}]} or {"any":[{"field":"status","op":"eq","value":"active"}]}; group entries may also be nested groups. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | | `--sort ` | No | Ordered sort keys: [{"field":"createdAt","direction":"desc"}] (direction: asc or desc) (JSON, or @path / @- to read a file or stdin). | | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | | `--include-run-state` | No | Include per-workflow-group run state on every returned row. Off by default: run state is a separate sidecar read and its `blockErrors` are unbounded, so a full page carries it only when asked. Incompatible with `limit: 0`, and caps `limit` at 200. | | `--no-include-run-state` | No | Send --include-run-state as false. | @@ -1092,7 +1094,7 @@ sim tables list [options] | `--search ` | No | Case-insensitive substring match against the resource name. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | @@ -1260,7 +1262,7 @@ sim tables ls [path] [options] | Option | Required | Description | | --- | --- | --- | | `--search ` | No | Filter folders and resources by name. | -| `--limit ` | No | Maximum combined items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum combined items to return (0 for everything). Defaults to `0`. | diff --git a/apps/docs/content/docs/cli/tools.mdx b/apps/docs/content/docs/cli/tools.mdx index 2248ec6c063..93f264a8186 100644 --- a/apps/docs/content/docs/cli/tools.mdx +++ b/apps/docs/content/docs/cli/tools.mdx @@ -70,6 +70,6 @@ sim tools list [options] | `--oauth-provider ` | No | Restrict to tools that authenticate against this OAuth service. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `id`, `name`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | diff --git a/apps/docs/content/docs/cli/workflow-mcp-servers.mdx b/apps/docs/content/docs/cli/workflow-mcp-servers.mdx index 56fdb984f24..909fdf4a5db 100644 --- a/apps/docs/content/docs/cli/workflow-mcp-servers.mdx +++ b/apps/docs/content/docs/cli/workflow-mcp-servers.mdx @@ -169,7 +169,7 @@ List Workflow MCP Servers (OAuth login or personal API key required) | --- | --- | --- | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | diff --git a/apps/docs/content/docs/cli/workflows.mdx b/apps/docs/content/docs/cli/workflows.mdx index 9d44a7a7d8b..b1340cc251c 100644 --- a/apps/docs/content/docs/cli/workflows.mdx +++ b/apps/docs/content/docs/cli/workflows.mdx @@ -177,6 +177,7 @@ sim workflows runs list [options] | `--start-date ` | No | Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | | `--end-date ` | No | Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | | `--order ` | No | Sort direction by run start time. This list is sortable only by run start time, so it takes `order` in place of `sortBy`/`sortOrder`, which it rejects. Accepted values: `asc`, `desc`. | @@ -715,6 +716,7 @@ sim workflows versions list [options] | Option | Required | Description | | --- | --- | --- | | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | @@ -781,7 +783,7 @@ sim workflows list [options] | `--folder ` | No | Folder path as shown in the app; the leading / is optional. | | `--deployed-only` | No | Return only workflows with an active deployment when true. | | `--no-deployed-only` | No | Send --deployed-only as false. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | | `--search ` | No | Case-insensitive substring match against the resource name. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `position`, `name`, `createdAt`, `updatedAt`, `runCount`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | @@ -975,7 +977,7 @@ sim workflows ls [path] [options] | Option | Required | Description | | --- | --- | --- | | `--search ` | No | Filter folders and resources by name. | -| `--limit ` | No | Maximum combined items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum combined items to return (0 for everything). Defaults to `0`. | diff --git a/apps/docs/content/docs/cli/workspaces.mdx b/apps/docs/content/docs/cli/workspaces.mdx index 7876176f96b..d92c8eb3d26 100644 --- a/apps/docs/content/docs/cli/workspaces.mdx +++ b/apps/docs/content/docs/cli/workspaces.mdx @@ -27,7 +27,7 @@ sim workspaces members [options] | Option | Required | Description | | --- | --- | --- | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | @@ -45,6 +45,6 @@ sim workspaces list [options] | --- | --- | --- | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md index 89f6b938b62..54b3a234b88 100644 --- a/packages/sim-cli/README.md +++ b/packages/sim-cli/README.md @@ -220,11 +220,26 @@ will consume the result, and `text` for tab-separated shell output: ```bash sim workflows list --output json -sim logs list --output json | jq -r '.[].runId' +sim logs list --output json | jq -r '.data[].runId' SIM_OUTPUT=yaml sim tables get sim configure --set-output json ``` +Paginated lists return `{ "data": [...], "nextCursor": "..." }` in JSON and YAML. +`nextCursor` is `null` when no pages remain. Resource lists and directory `ls` +fetch every page by default; use `--limit N` to cap them. Table rows (including +queries), logs, audit/billing events, workflow runs/versions, and knowledge +documents/chunks keep a default limit of 100. Use `--limit 0` to fetch every page +of those datasets, or pass the returned `nextCursor` to `--cursor` to continue +with another bounded result. Keep the same resource, filters, and sort order +when resuming; stop when `nextCursor` is `null`. Results accumulate in memory +before printing, so large datasets need an explicit limit or filter. + +```bash +sim tables rows list --limit 100 --output json +sim tables rows list --limit 100 --cursor "$nextCursor" --output json +``` + JSON-valued options accept inline JSON, a file prefixed with `@`, or stdin with `@-`: diff --git a/packages/sim-cli/src/commands/protocol/resource-directory.test.ts b/packages/sim-cli/src/commands/protocol/resource-directory.test.ts index b0d0332bb94..44b60580e1e 100644 --- a/packages/sim-cli/src/commands/protocol/resource-directory.test.ts +++ b/packages/sim-cli/src/commands/protocol/resource-directory.test.ts @@ -216,12 +216,7 @@ describe('resource directory', () => { expect(entries.find((entry) => entry.kind === 'table')?.ref).toBe('tbl_1') }) - /** - * `files list` announces "showing the first N" off the surviving cursor and - * `ls` printed the same capped answer with nothing on stderr, so one command - * presented an incomplete listing as complete and its neighbour did not. - */ - it('says the combined listing was capped, as the contract-driven list does', async () => { + it('honors the combined listing limit without a pagination notice', async () => { mockRequest.mockImplementation( async (path: string, options: { query: { cursor?: string } }) => { if (path === '/api/v2/files/folders') return { data: [] } @@ -242,13 +237,17 @@ describe('resource directory', () => { written.push(String(chunk)) return true }) - vi.spyOn(console, 'log').mockImplementation(() => {}) + const logged: string[] = [] + vi.spyOn(console, 'log').mockImplementation((line: string) => logged.push(line)) await program().parseAsync(['node', 'sim', 'files', 'ls', '--limit', '5']) - expect(written.join('')).toContain('showing the first 5') + expect(JSON.parse(logged.join('\n'))).toHaveLength(5) + expect(written.join('')).toBe('') written.length = 0 - await program().parseAsync(['node', 'sim', 'files', 'ls', '--limit', '0']) + logged.length = 0 + await program().parseAsync(['node', 'sim', 'files', 'ls']) + expect(JSON.parse(logged.join('\n'))).toHaveLength(300) expect(written.join('')).not.toContain('showing the first') }) diff --git a/packages/sim-cli/src/commands/protocol/resource-directory.ts b/packages/sim-cli/src/commands/protocol/resource-directory.ts index f334ec29e9f..6c02d8e5a2f 100644 --- a/packages/sim-cli/src/commands/protocol/resource-directory.ts +++ b/packages/sim-cli/src/commands/protocol/resource-directory.ts @@ -12,11 +12,11 @@ import { V2_OPERATIONS, type V2OperationName, } from '../../generated/v2-api' -import { requestPages, SimApiError, type SimClient, type V2Page } from '../../http/client' +import { requestAllPages, SimApiError, type SimClient, type V2Page } from '../../http/client' import { type Column, printList, text, timestamp } from '../../output/render' -import { DEFAULT_LIMIT } from '../../runtime/options' +import { DEFAULT_PAGE_SIZE } from '../../runtime/options' import { encodeFolderPath } from '../../runtime/request' -import { decodeFolderPath, renderResult, writeCursorTruncation } from '../../runtime/result' +import { decodeFolderPath, renderResult } from '../../runtime/result' type FolderListOperation = | 'listFileFolders' @@ -103,19 +103,19 @@ async function listResources( folderPath: string, search: string | undefined, limit: number -): Promise<{ items: DirectoryResource[]; truncated: boolean }> { +): Promise { const query = { workspaceId, folderPath, search, sortBy: 'name', sortOrder: 'asc' } const path = operationPath(config.resources) const paginated = 'cursor' in V2_OPERATIONS[config.resources].query if (!paginated) { const page = await client.request>(path, { query }) - return { items: page.data.slice(0, limit), truncated: page.data.length > limit } + return page.data.slice(0, limit) } - return requestPages(client, path, { + return requestAllPages(client, path, { query, - pageSize: DEFAULT_LIMIT, + pageSize: DEFAULT_PAGE_SIZE, limit, }) } @@ -170,9 +170,7 @@ export function attachResourceDirectoryCommands( .description(`List ${config.kind} resources and child folders together`) .option('--search ', 'Filter folders and resources by name') .addOption( - new Option('--limit ', 'Maximum combined items to return (0 for everything)').default( - String(DEFAULT_LIMIT) - ) + new Option('--limit ', 'Maximum combined items to return (0 for everything)').default('0') ) .action(async (path: string | undefined, options: ListOptions, command: Command) => { const rawLimit = Number(options.limit) @@ -191,12 +189,8 @@ export function attachResourceDirectoryCommands( listFolders(client, config.folders, workspaceId, folderPath, options.search), listResources(client, config, workspaceId, folderPath, options.search, limit), ]) - const entries = entriesFor(config, folders, resources.items) + const entries = entriesFor(config, folders, resources) const shown = entries.slice(0, limit) - // Said here for the same reason the contract-driven `list` says it: the - // combined listing is capped after the merge, so a full page of folders - // can clip the resources even when the resource walk itself finished. - writeCursorTruncation(shown.length, resources.truncated || entries.length > limit) printList(profile.output, shown, COLUMNS) }) diff --git a/packages/sim-cli/src/contract/commands.test.ts b/packages/sim-cli/src/contract/commands.test.ts index a76f0df294f..6c2120133a2 100644 --- a/packages/sim-cli/src/contract/commands.test.ts +++ b/packages/sim-cli/src/contract/commands.test.ts @@ -479,7 +479,11 @@ describe('list columns', () => { }) try { - renderPage('table', [dispatch], CLI_CONTRACT.listTableDispatches ?? {}) + renderPage( + 'table', + { data: [dispatch], nextCursor: null }, + CLI_CONTRACT.listTableDispatches ?? {} + ) } finally { log.mockRestore() } diff --git a/packages/sim-cli/src/http/client.test.ts b/packages/sim-cli/src/http/client.test.ts index 24fcf8d552e..cce5dade884 100644 --- a/packages/sim-cli/src/http/client.test.ts +++ b/packages/sim-cli/src/http/client.test.ts @@ -81,6 +81,23 @@ describe('cursor pagination', () => { }) }) + it.each([{ cursors: ['c1', 'c1'] }, { cursors: ['c1', 'c2', 'c1'] }])( + 'rejects cursor cycles $cursors before making another request', + async ({ cursors }) => { + const request = vi.fn().mockRejectedValue(new Error('Pagination did not stop at the cycle')) + for (const nextCursor of cursors) { + request.mockResolvedValueOnce({ data: ['item'], nextCursor }) + } + + await expect( + requestAllPages({ request } as Pick, '/api/v2/items', { + pageSize: 100, + }) + ).rejects.toThrow('repeated pagination cursor') + expect(request).toHaveBeenCalledTimes(cursors.length) + } + ) + it('reports progress on stderr once a second page is coming, then clears the line', async () => { const request = vi .fn() diff --git a/packages/sim-cli/src/http/client.ts b/packages/sim-cli/src/http/client.ts index d710dda5311..88692524ef2 100644 --- a/packages/sim-cli/src/http/client.ts +++ b/packages/sim-cli/src/http/client.ts @@ -763,33 +763,27 @@ export function pageProgress(): PageProgress { } } +/** Rejects cursor cycles before a pager repeats requests or returns an unusable continuation. */ +export function assertCursorAdvances(cursor: string | null, seenCursors: Set): void { + if (cursor === null) return + if (seenCursors.has(cursor)) { + throw new SimApiError('The API returned a repeated pagination cursor; cannot continue.', 0) + } + seenCursors.add(cursor) +} + /** Follows a standard v2 cursor envelope without duplicating pagination loops. */ export async function requestAllPages( client: Pick, path: string, options: RequestAllPagesOptions ): Promise { - return (await requestPages(client, path, options)).items -} - -/** - * The same walk, also stating whether it stopped short. - * - * A caller that prints the rows itself has to say so — `files list` announces - * "showing the first N" off the surviving cursor and `files ls` did not, so the - * same capped answer looked complete on one command and incomplete on its - * neighbour. - */ -export async function requestPages( - client: Pick, - path: string, - options: RequestAllPagesOptions -): Promise<{ items: T[]; truncated: boolean }> { const { query, pageSize, limit: requestedLimit, ...requestOptions } = options const limit = requestedLimit ?? Number.POSITIVE_INFINITY - if (limit <= 0) return { items: [], truncated: false } + if (limit <= 0) return [] const items: T[] = [] + const seenCursors = new Set() const progress = pageProgress() let cursor: string | null = null // `finally`, because a page that throws part-way through would otherwise skip @@ -805,6 +799,7 @@ export async function requestPages( cursor, }, }) + assertCursorAdvances(page.nextCursor, seenCursors) items.push(...page.data) cursor = page.nextCursor @@ -814,7 +809,7 @@ export async function requestPages( progress.finish() } - return { items: items.slice(0, limit), truncated: cursor !== null || items.length > limit } + return items.slice(0, limit) } /** diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts index a4517f56845..810998289ae 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -1324,6 +1324,94 @@ describe('contract-selected list rendering', () => { }) describe('pagination slot', () => { + it.each([ + { argv: ['files', 'list'], cursors: ['c1', 'c1'] }, + { argv: ['files', 'list'], cursors: ['c1', 'c2', 'c1'] }, + { argv: ['tables', 'rows', 'query', 'tbl_1', '--limit', '0'], cursors: ['c1', 'c1'] }, + { argv: ['logs', 'list', '--cursor', 'c1'], cursors: ['c1'] }, + { argv: ['tables', 'rows', 'query', 'tbl_1', '--cursor', 'c1'], cursors: ['c1'] }, + ])( + 'rejects cursor cycles in $argv without printing partial results', + async ({ argv, cursors }) => { + mockRequest.mockReset() + mockRequest.mockRejectedValue(new Error('Pagination did not stop at the cycle')) + for (const nextCursor of cursors) { + mockRequest.mockResolvedValueOnce({ data: [{ id: 'r1' }], nextCursor }) + } + const printed = vi.spyOn(console, 'log').mockImplementation(() => {}) + printed.mockClear() + + await expect(program().parseAsync(['node', 'sim', ...argv])).rejects.toThrow( + 'repeated pagination cursor' + ) + expect(mockRequest).toHaveBeenCalledTimes(cursors.length) + expect(printed).not.toHaveBeenCalled() + } + ) + + it.each([ + ['files', 'list'], + ['tables', 'list'], + ['workflows', 'list'], + ['knowledge', 'list'], + ['tools', 'list'], + ])('fetches the complete %s %s inventory by default', async (...argv) => { + mockRequest.mockReset() + const first = Array.from({ length: 100 }, (_, index) => ({ id: `r${index}` })) + const second = Array.from({ length: 50 }, (_, index) => ({ id: `r${100 + index}` })) + mockRequest + .mockResolvedValueOnce({ data: first, nextCursor: 'c1' }) + .mockResolvedValueOnce({ data: second, nextCursor: null }) + const printed: string[] = [] + vi.spyOn(console, 'log').mockImplementation((line: string) => printed.push(line)) + + await program().parseAsync(['node', 'sim', ...argv]) + + expect(mockRequest).toHaveBeenCalledTimes(2) + expect(mockRequest.mock.calls[1][1].query).toMatchObject({ cursor: 'c1', limit: 100 }) + expect(JSON.parse(printed.join('\n'))).toEqual({ + data: [...first, ...second], + nextCursor: null, + }) + }) + + it.each([ + ['tables', 'rows', 'list', 'tbl_1'], + ['tables', 'rows', 'query', 'tbl_1'], + ['logs', 'list'], + ['knowledge', 'documents', 'list', 'kb_1'], + ['knowledge', 'chunks', 'list', 'kb_1', 'doc_1'], + ['knowledge', 'connectors', 'documents', 'list', 'kb_1', 'connector_1'], + ['workflows', 'runs', 'list', '--workflow', 'wf_1'], + ['workflows', 'versions', 'list', 'wf_1'], + ['billing', 'logs'], + ['audit-logs', 'list', '--organization', 'org_1'], + ])('caps large dataset command %j at 100 items by default', async (...argv) => { + mockRequest.mockReset() + const rows = Array.from({ length: 100 }, (_, index) => ({ id: `r${index}` })) + mockRequest + .mockResolvedValueOnce({ data: rows, nextCursor: 'c1' }) + .mockRejectedValue(new Error('The default limit must stop before another page')) + const printed: string[] = [] + vi.spyOn(console, 'log').mockImplementation((line: string) => printed.push(line)) + + await program().parseAsync(['node', 'sim', ...argv]) + + expect(mockRequest).toHaveBeenCalledTimes(1) + expect(JSON.parse(printed.join('\n'))).toEqual({ data: rows, nextCursor: 'c1' }) + + mockRequest.mockReset() + mockRequest.mockResolvedValueOnce({ data: [{ id: 'r100' }], nextCursor: null }) + printed.length = 0 + + await program().parseAsync(['node', 'sim', ...argv, '--cursor', 'c1']) + + expect(mockRequest).toHaveBeenCalledTimes(1) + const options = mockRequest.mock.calls[0][1] + expect({ ...options.query, ...options.body }).toMatchObject({ cursor: 'c1', limit: 100 }) + expect(JSON.parse(printed.join('\n'))).toEqual({ data: [{ id: 'r100' }], nextCursor: null }) + }) + it('pages a body-cursor operation and renders its rows', async () => { // `queryRows` is a POST whose cursor is in the body, not the query. Reading // only the query made it take the single-request path and print nothing. @@ -1343,7 +1431,7 @@ describe('pagination slot', () => { expect(mockRequest.mock.calls[1][1].body).toMatchObject({ cursor: 'c1' }) expect(mockRequest.mock.calls[1][1].query).not.toHaveProperty('cursor') // And the rows actually render rather than printing an empty record. - expect(JSON.parse(lines[0])).toEqual([{ id: 'r1' }, { id: 'r2' }]) + expect(JSON.parse(lines[0])).toEqual({ data: [{ id: 'r1' }, { id: 'r2' }], nextCursor: null }) }) it('keeps a query-cursor operation on the query slot', async () => { @@ -1401,10 +1489,14 @@ describe('pagination slot', () => { it('reads a limit the way the caller wrote it', async () => { mockRequest.mockReset() - mockRequest.mockResolvedValue({ - data: Array.from({ length: 20 }, (_row, index) => ({ id: `f_${index}` })), - nextCursor: null, - }) + mockRequest.mockImplementation( + async (_path: string, options: { query: { limit: number } }) => ({ + data: Array.from({ length: Math.min(20, options.query.limit) }, (_row, index) => ({ + id: `f_${index}`, + })), + nextCursor: options.query.limit < 20 ? 'c1' : null, + }) + ) const printed: string[] = [] vi.spyOn(console, 'log').mockImplementation((line: string) => { printed.push(line) @@ -1412,12 +1504,14 @@ describe('pagination slot', () => { // `parseInt(…, 10)` stopped at the `x` and read 0, which meant everything. await program().parseAsync(['node', 'sim', 'files', 'list', '--limit', '0x10']) - expect(JSON.parse(printed[0])).toHaveLength(16) + expect(JSON.parse(printed[0]).data).toHaveLength(16) + expect(JSON.parse(printed[0]).nextCursor).toBe('c1') printed.length = 0 // And stopped at the `e`, reading a single row where 1000 was asked for. await program().parseAsync(['node', 'sim', 'files', 'list', '--limit', '1e3']) - expect(JSON.parse(printed[0])).toHaveLength(20) + expect(JSON.parse(printed[0]).data).toHaveLength(20) + expect(JSON.parse(printed[0]).nextCursor).toBeNull() }) it('uses a valid per-page size for unlimited and large totals', async () => { @@ -1431,6 +1525,110 @@ describe('pagination slot', () => { expect(mockRequest.mock.calls[0][1].query.limit).toBe(100) } }) + + it.each([ + { slot: 'query' as const, argv: ['logs', 'list'] }, + { slot: 'body' as const, argv: ['tables', 'rows', 'query', 'tbl_1'] }, + ])('returns an accurate cursor after a partial final $slot page', async ({ slot, argv }) => { + mockRequest.mockReset() + mockRequest.mockImplementation( + async ( + _path: string, + options: { + query: { limit: number; cursor?: string } + body?: { limit: number; cursor?: string } + } + ) => { + const page = options[slot] + if (!page) throw new Error(`Missing ${slot} pagination`) + const offset = Number(page.cursor ?? 0) + const count = Math.min(page.limit, 400 - offset) + return { + data: Array.from({ length: count }, (_, index) => ({ id: `r${offset + index}` })), + nextCursor: offset + count < 400 ? String(offset + count) : null, + } + } + ) + const printed: string[] = [] + vi.spyOn(console, 'log').mockImplementation((line: string) => printed.push(line)) + + await program().parseAsync(['node', 'sim', ...argv, '--limit', '250']) + + expect(mockRequest.mock.calls.map(([, options]) => options[slot].limit)).toEqual([100, 100, 50]) + const result = JSON.parse(printed.join('\n')) + expect(result.data).toHaveLength(250) + expect(result.data[249]).toEqual({ id: 'r249' }) + expect(result.nextCursor).toBe('250') + + printed.length = 0 + await program().parseAsync(['node', 'sim', ...argv, '--limit', '0']) + + const complete = JSON.parse(printed.join('\n')) + expect(complete.data).toHaveLength(400) + expect(complete.nextCursor).toBeNull() + + printed.length = 0 + const resumeCall = mockRequest.mock.calls.length + await program().parseAsync(['node', 'sim', ...argv, '--cursor', result.nextCursor]) + + expect(mockRequest.mock.calls[resumeCall][1][slot]).toMatchObject({ cursor: '250', limit: 100 }) + const resumed = JSON.parse(printed.join('\n')) + expect(resumed.data).toHaveLength(100) + expect(resumed.data[0]).toEqual({ id: 'r250' }) + expect(resumed.data[99]).toEqual({ id: 'r349' }) + expect(resumed.nextCursor).toBe('350') + + printed.length = 0 + await program().parseAsync([ + 'node', + 'sim', + ...argv, + '--cursor', + resumed.nextCursor, + '--limit', + '0', + ]) + + const remaining = JSON.parse(printed.join('\n')) + expect(remaining.data).toHaveLength(50) + expect(remaining.data[0]).toEqual({ id: 'r350' }) + expect(remaining.nextCursor).toBeNull() + }) + + it.each([ + ['logs', 'list'], + ['tables', 'rows', 'query', 'tbl_1'], + ])('rejects blank cursors before requesting %j', async (...argv) => { + for (const cursor of ['', ' ', '\t']) { + mockRequest.mockReset() + + await expect( + program().parseAsync(['node', 'sim', ...argv, '--cursor', cursor]) + ).rejects.toThrow('--cursor') + expect(mockRequest).not.toHaveBeenCalled() + } + }) + + it('does not expose manual cursors on resource inventories', async () => { + mockRequest.mockReset() + + await expect( + program().parseAsync(['node', 'sim', 'files', 'list', '--cursor', 'c1']) + ).rejects.toThrow("unknown option '--cursor'") + expect(mockRequest).not.toHaveBeenCalled() + }) + + it('rejects an oversized page instead of emitting a cursor that skips rows', async () => { + mockRequest.mockReset() + mockRequest.mockResolvedValue({ data: [{ id: 'a' }, { id: 'b' }], nextCursor: 'c2' }) + const stdout = vi.spyOn(console, 'log').mockImplementation(() => {}) + stdout.mockClear() + + await expect( + program().parseAsync(['node', 'sim', 'files', 'list', '--limit', '1']) + ).rejects.toThrow('nextCursor would skip unreturned items') + expect(stdout).not.toHaveBeenCalled() + }) }) describe('rows whose content sits in a wrapper', () => { @@ -2228,7 +2426,7 @@ describe('the billing ledger a key can see', () => { describe('a list that is not the whole answer', () => { /** Captures stderr for one invocation, in one output format. */ async function noteFor( - format: 'table' | 'text' | 'json', + format: 'table' | 'text' | 'json' | 'yaml', argv: string[], response: unknown ): Promise { @@ -2249,19 +2447,14 @@ describe('a list that is not the whole answer', () => { return errors.join('') } - /** - * `sim tools list` answered 100 rows of 4708 with exit 0 and an empty - * stderr, in every format — a clipped inventory that read as the inventory. - */ - it('says so, once, on stderr, in every format', async () => { - for (const format of ['table', 'text', 'json'] as const) { + it('does not announce remaining pages in any format', async () => { + for (const format of ['table', 'text', 'json', 'yaml'] as const) { const note = await noteFor(format, ['tools', 'list', '--limit', '2'], { data: [{ id: 'a' }, { id: 'b' }], nextCursor: 'c1', }) - expect(note).toContain('more results exist') - expect(note).toContain('--limit 0') + expect(note).toBe('') } }) @@ -2276,8 +2469,8 @@ describe('a list that is not the whole answer', () => { /** * The server clips an inventory itself and says so on the envelope, which the - * CLI dropped: `--output json` prints `data` alone, so a reconciling caller - * could not tell a clipped list from a complete one. + * CLI reports separately from data and nextCursor so a reconciling caller + * can distinguish a clipped inventory from a complete one. */ it('carries a truncation the server stated on the envelope', async () => { const paged = await noteFor('json', ['workflow-mcp-servers', 'list'], { @@ -2329,7 +2522,7 @@ describe('a list that is not the whole answer', () => { expect(errors.join('')).toContain('tool names truncated') }) - it('leaves the rows on stdout exactly as they were', async () => { + it('includes the remaining cursor alongside the rows', async () => { const lines: string[] = [] mockRequest.mockReset() mockRequest.mockResolvedValue({ data: [{ id: 'a' }, { id: 'b' }], nextCursor: 'c1' }) @@ -2340,6 +2533,9 @@ describe('a list that is not the whole answer', () => { await program().parseAsync(['node', 'sim', 'tools', 'list', '--limit', '2']) - expect(JSON.parse(lines.join('\n'))).toEqual([{ id: 'a' }, { id: 'b' }]) + expect(JSON.parse(lines.join('\n'))).toEqual({ + data: [{ id: 'a' }, { id: 'b' }], + nextCursor: 'c1', + }) }) }) diff --git a/packages/sim-cli/src/runtime/execute.ts b/packages/sim-cli/src/runtime/execute.ts index db3f2a864eb..0cb78213ec5 100644 --- a/packages/sim-cli/src/runtime/execute.ts +++ b/packages/sim-cli/src/runtime/execute.ts @@ -2,10 +2,10 @@ import type { Command } from 'commander' import { clientFrom } from '../context' import type { CommandSpec } from '../contract/types' import type { V2OperationName } from '../generated/v2-api' -import { pageProgress, SimApiError, type V2Page } from '../http/client' +import { assertCursorAdvances, pageProgress, SimApiError, type V2Page } from '../http/client' import { safeOneLine } from '../output/render' import { camel } from './derive' -import { DEFAULT_LIMIT } from './options' +import { DEFAULT_PAGE_SIZE, defaultListLimit } from './options' import { warnRenamedFlag } from './renamed' import { buildRequest, @@ -222,8 +222,8 @@ const EXCLUSIVE_CAP_FIELDS: Readonly< * this, `--limit ''` would go from today's error to an unbounded walk of a * shared workspace. */ -function readPagedLimit(raw: unknown): number { - const text = String(raw ?? DEFAULT_LIMIT).trim() +function readPagedLimit(raw: unknown, operation: V2OperationName): number { + const text = String(raw ?? defaultListLimit(operation)).trim() const value = text === '' ? Number.NaN : Number(text) if (!Number.isInteger(value) || value < 0) { throw new SimApiError('--limit must be a whole number of 0 or more (0 for everything)', 0) @@ -384,7 +384,7 @@ export async function executeOperation( * generic integer refusal — losing the `0 for everything` this pager depends * on the caller knowing. */ - const pagedLimit = paging ? readPagedLimit(requestFlags.limit) : 0 + const pagedLimit = paging ? readPagedLimit(requestFlags.limit, operation) : 0 const request = buildRequest( operation, positional, @@ -393,12 +393,18 @@ export async function executeOperation( ) if (paging) { + const initialCursor = request[paging]?.cursor + if ( + initialCursor !== undefined && + (typeof initialCursor !== 'string' || initialCursor.trim() === '') + ) { + throw new SimApiError('--cursor must be a non-empty string', 0) + } const limit = pagedLimit === 0 ? Number.POSITIVE_INFINITY : pagedLimit - const pageSize = Math.min(Number.isFinite(limit) ? limit : DEFAULT_LIMIT, DEFAULT_LIMIT) - const pageLimit = 'limit' in (operationSpec[paging] ?? {}) ? { limit: pageSize } : {} const rows: unknown[] = [] + const seenCursors = new Set(initialCursor ? [initialCursor] : []) const progress = pageProgress() - let cursor: string | null = null + let cursor: string | null = initialCursor ?? null /** The first page's envelope: where a fact about the whole query is stated. */ let envelope: unknown @@ -406,6 +412,8 @@ export async function executeOperation( // would otherwise leave the progress text on the line the error prints onto. try { do { + const pageSize = Math.min(DEFAULT_PAGE_SIZE, limit - rows.length) + const pageLimit = 'limit' in (operationSpec[paging] ?? {}) ? { limit: pageSize } : {} const page: V2Page = await client.request(request.path, { method: operationSpec.method, headers: request.headers, @@ -415,6 +423,13 @@ export async function executeOperation( ? { ...(request.body ?? {}), ...pageLimit, ...(cursor ? { cursor } : {}) } : request.body, }) + if (page.data.length > pageSize) { + throw new SimApiError( + `The API returned ${page.data.length} items for a page limit of ${pageSize}; nextCursor would skip unreturned items.`, + 0 + ) + } + assertCursorAdvances(page.nextCursor, seenCursors) envelope = foldPageEnvelope(envelope, page) rows.push(...page.data) cursor = page.nextCursor @@ -423,16 +438,7 @@ export async function executeOperation( } finally { progress.finish() } - // A cursor still in hand means the walk stopped at `--limit`, not at the - // end of the list — the one fact that separates a clipped answer from a - // complete one, and it was dropped with the loop variable. - renderPage( - profile.output, - Number.isFinite(limit) ? rows.slice(0, limit) : rows, - commandSpec, - envelope, - { truncated: Boolean(cursor) } - ) + renderPage(profile.output, { data: rows, nextCursor: cursor }, commandSpec, envelope) return } diff --git a/packages/sim-cli/src/runtime/options.ts b/packages/sim-cli/src/runtime/options.ts index 831adf47b4f..c4dc2017431 100644 --- a/packages/sim-cli/src/runtime/options.ts +++ b/packages/sim-cli/src/runtime/options.ts @@ -12,7 +12,34 @@ import { } from './request' import type { OperationSpec } from './types' -export const DEFAULT_LIMIT = 100 +export const DEFAULT_PAGE_SIZE = 100 + +/** Resource inventories fetch all pages; data and history commands retain a bounded default. */ +const COMPLETE_LIST_OPERATIONS: ReadonlySet = new Set([ + 'listBlocks', + 'listChatDeployments', + 'listCredentials', + 'listCustomTools', + 'listFiles', + 'listKnowledgeBases', + 'listKnowledgeConnectors', + 'listMcpServers', + 'listSandboxes', + 'listSecrets', + 'listSkillEditors', + 'listSkills', + 'listTables', + 'listTools', + 'listWorkflowMcpServers', + 'listWorkflows', + 'listWorkspaceMembers', + 'listWorkspaces', +]) + +/** Zero fetches every page. Unclassified operations keep the bounded default. */ +export function defaultListLimit(operation: V2OperationName): number { + return COMPLETE_LIST_OPERATIONS.has(operation) ? 0 : 100 +} /** * Help text for one flag, best source first. @@ -95,7 +122,14 @@ function addFieldOption( paginates: boolean, capsAFilter: boolean ): void { - if (field === PROFILE_INJECTED_FIELD || field === 'cursor') return + if (field === PROFILE_INJECTED_FIELD) return + + if (field === 'cursor') { + if (paginates && defaultListLimit(operation) > 0) { + command.option('--cursor ', 'Continue from nextCursor returned by a previous result') + } + return + } const flag = flagSpecFor(operation, field) if (flag.omit) return @@ -115,7 +149,7 @@ function addFieldOption( command.option( '--limit ', 'Maximum items to return (0 for everything)', - String(DEFAULT_LIMIT) + String(defaultListLimit(operation)) ) return } diff --git a/packages/sim-cli/src/runtime/request.ts b/packages/sim-cli/src/runtime/request.ts index 7fcb32b1e00..4725f429638 100644 --- a/packages/sim-cli/src/runtime/request.ts +++ b/packages/sim-cli/src/runtime/request.ts @@ -37,7 +37,7 @@ export function isProfileWorkspacePath(commandSpec: CommandSpec, param: string): * lives here rather than beside its reader in `execute.ts` because `options.ts` * has to ask the same question while it builds the flag, and importing * `execute.ts` from `options.ts` would close a module cycle — `execute.ts` - * already reads `DEFAULT_LIMIT` from `options.ts`. + * already reads `DEFAULT_PAGE_SIZE` from `options.ts`. */ export function cursorSlot( operationSpec: Pick diff --git a/packages/sim-cli/src/runtime/result.test.ts b/packages/sim-cli/src/runtime/result.test.ts index 2a721a8b880..cde9032575b 100644 --- a/packages/sim-cli/src/runtime/result.test.ts +++ b/packages/sim-cli/src/runtime/result.test.ts @@ -70,7 +70,7 @@ describe('inferred cells pick a format from the key shape', () => { }) it('de-camelCases inferred table headers', () => { - renderPage('table', [row], {}) + renderPage('table', { data: [row], nextCursor: null }, {}) expect(tableLines()[0].split(/\s{2,}/)).toEqual([ 'CREATED AT', 'DURATION', @@ -103,7 +103,7 @@ describe('inferred cells pick a format from the key shape', () => { const spec: CommandSpec = { columns: [{ header: 'size', format: 'auto' }], } - renderPage('text', [{ size: 3000000 }], spec) + renderPage('text', { data: [{ size: 3000000 }], nextCursor: null }, spec) expect(logged[0]).toBe('3000000') }) }) @@ -116,12 +116,12 @@ describe('cells the user named, not the API', () => { const spec: CommandSpec = { expand: 'data' } it('leaves a user column named like an API field alone', () => { - renderPage('text', rows, spec) + renderPage('text', { data: rows, nextCursor: null }, spec) expect(logged[0]).toBe('row_1\t3\t5\t30\ttrue') }) it('heads each one with the name the user has to type back into --filter', () => { - renderPage('table', rows, spec) + renderPage('table', { data: rows, nextCursor: null }, spec) expect(tableLines()[0].split(/\s{2,}/)).toEqual([ 'ID', 'SCORE', @@ -226,24 +226,24 @@ describe('folder paths are shown by name, but piped in wire form', () => { const spec = CLI_CONTRACT.listTableFolders as CommandSpec it('decodes the path in the table, which held it next to the decoded name', () => { - renderPage('table', folders, spec) + renderPage('table', { data: folders, nextCursor: null }, spec) const [, row] = tableLines() expect(row).toContain('/cli-test-a/nested one') expect(row).not.toContain('%20') }) it('decodes the path in text, the format shell plumbing reads', () => { - renderPage('text', folders, spec) + renderPage('text', { data: folders, nextCursor: null }, spec) expect(logged[0].split('\t')[0]).toBe('/cli-test-a/nested one') }) it('keeps the wire form in json, so a path fed back still resolves', () => { - renderPage('json', folders, spec) - expect(JSON.parse(logged[0])[0].path).toBe('/cli-test-a/nested%20one') + renderPage('json', { data: folders, nextCursor: null }, spec) + expect(JSON.parse(logged[0]).data[0].path).toBe('/cli-test-a/nested%20one') }) it('keeps the wire form in yaml for the same reason', () => { - renderPage('yaml', folders, spec) + renderPage('yaml', { data: folders, nextCursor: null }, spec) expect(logged[0]).toContain('/cli-test-a/nested%20one') }) @@ -287,10 +287,10 @@ describe('folder paths are shown by name, but piped in wire form', () => { ] it('keeps it distinguishable from a genuinely nested folder in the table', () => { - renderPage('table', slashNamed, spec) + renderPage('table', { data: slashNamed, nextCursor: null }, spec) const [, slashRow] = tableLines() logged = [] - renderPage('table', nested, spec) + renderPage('table', { data: nested, nextCursor: null }, spec) const [, nestedRow] = tableLines() expect(slashRow).toContain('%2F') @@ -298,7 +298,7 @@ describe('folder paths are shown by name, but piped in wire form', () => { }) it('prints the wire form in text, which is what a script pipes back', () => { - renderPage('text', slashNamed, spec) + renderPage('text', { data: slashNamed, nextCursor: null }, spec) expect(logged[0].split('\t')[0]).toBe('/cli-test-a%2Fenc') }) @@ -308,22 +308,39 @@ describe('folder paths are shown by name, but piped in wire form', () => { }) it('shows an undecodable path as it arrived rather than dropping it', () => { - renderPage('text', [{ path: '/100%zz', name: 'x', parentPath: '/', updatedAt: null }], spec) + renderPage( + 'text', + { + data: [{ path: '/100%zz', name: 'x', parentPath: '/', updatedAt: null }], + nextCursor: null, + }, + spec + ) expect(logged[0].split('\t')[0]).toBe('/100%zz') }) }) -describe('a truncation note', () => { - /** The note is stderr in every format; stdout stays exactly the rows. */ - it('leaves the machine formats a bare array', () => { +describe('paginated JSON output', () => { + it('includes the cursor without a pagination notice', () => { const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + const page = { data: [{ id: 'a' }], nextCursor: 'c1' } - renderPage('json', [{ id: 'a' }], {}, { truncated: true }, { truncated: true }) + renderPage('json', page, {}) - expect(JSON.parse(logged.join('\n'))).toEqual([{ id: 'a' }]) - expect(stderr.mock.calls.map(([chunk]) => String(chunk)).join('')).toContain( - 'more results exist' - ) + expect(JSON.parse(logged.join('\n'))).toEqual(page) + expect(stderr).not.toHaveBeenCalled() + }) + + it('includes a null cursor for an empty result', () => { + renderPage('json', { data: [], nextCursor: null }, {}) + + expect(JSON.parse(logged.join('\n'))).toEqual({ data: [], nextCursor: null }) + }) + + it('includes the same pagination fields in YAML', () => { + renderPage('yaml', { data: [{ id: 'a' }], nextCursor: 'c1' }, {}) + + expect(logged.join('\n')).toBe('data:\n - id: a\nnextCursor: c1') }) }) @@ -356,7 +373,12 @@ describe('a truncation the response states inside its payload', () => { it('names the tool names, not the servers, on the server list', () => { const read = captureStderr() - renderPage('json', [{ id: 'srv_1' }], {}, { data: [], toolNamesTruncated: true }) + renderPage( + 'json', + { data: [{ id: 'srv_1' }], nextCursor: null }, + {}, + { data: [], toolNamesTruncated: true } + ) expect(read()).toContain('the server clipped the tool names it returned') }) @@ -438,10 +460,10 @@ describe('a truncation the response states inside its payload', () => { const read = captureStderr() const rows = [{ id: 'a', truncated: true }] - renderPage('json', rows, {}, { data: rows }) + renderPage('json', { data: rows, nextCursor: null }, {}, { data: rows }) expect(read()).toBe('') - expect(JSON.parse(logged.join('\n'))).toEqual(rows) + expect(JSON.parse(logged.join('\n'))).toEqual({ data: rows, nextCursor: null }) }) /** @@ -457,10 +479,10 @@ describe('a truncation the response states inside its payload', () => { expect(read()).toBe('') }) - it('leaves yaml a bare payload, with the note on stderr', () => { + it('keeps the server truncation note on stderr in YAML', () => { const read = captureStderr() - renderPage('yaml', [{ id: 'a' }], {}, { data: [], truncated: true }) + renderPage('yaml', { data: [{ id: 'a' }], nextCursor: null }, {}, { data: [], truncated: true }) expect(logged.join('\n')).not.toContain('clipped') expect(read()).toContain('the server clipped this result') diff --git a/packages/sim-cli/src/runtime/result.ts b/packages/sim-cli/src/runtime/result.ts index 4754931e91b..145999f75ea 100644 --- a/packages/sim-cli/src/runtime/result.ts +++ b/packages/sim-cli/src/runtime/result.ts @@ -283,29 +283,26 @@ function unwrapResource(data: unknown): unknown { export function renderPage( format: OutputFormat, - rows: unknown[], + page: { data: unknown[]; nextCursor: string | null }, spec: CommandSpec, - envelope?: unknown, - options: { truncated?: boolean } = {} + envelope?: unknown ): void { writePageNote(spec, envelope) writeEnvelopeTruncation(envelope) - writeCursorTruncation(rows.length, options.truncated === true) printList( format, - rows, - spec.columns ? columnsFrom(spec.columns) : inferColumns(rows, spec.expand) + page.data, + spec.columns ? columnsFrom(spec.columns) : inferColumns(page.data, spec.expand), + page ) } /** * States a page-envelope fact once, above the rows, on stderr. * - * stdout is the rows and stays byte-for-byte what it was: `--output text` is - * positional and tab-separated, and a script cutting fields from it must not - * have to skip a header it did not ask for. Every format gets the note, the - * machine ones included — a paginated command walks the pages itself and prints - * the accumulated rows, so the envelope reaches no output format on stdout. + * `--output text` is positional and tab-separated, so this note cannot occupy + * a row. JSON and YAML expose the rows and continuation cursor; other envelope + * facts such as billing scope are reported here for every format. */ function writePageNote(spec: CommandSpec, envelope: unknown): void { if (!spec.pageNote) return @@ -318,8 +315,8 @@ function writePageNote(spec: CommandSpec, envelope: unknown): void { * Response fields that state the server itself clipped what it returned. * * Matched by shape rather than listed per command, so a flag added to a route - * envelope is surfaced the day it lands: the CLI accumulates the rows and - * prints those, so an envelope field reaches no output format on its own. + * envelope is surfaced the day it lands. Structured list output carries data + * and nextCursor; truncation flags are reported separately. */ const TRUNCATION_FLAG = /^truncated$|^[A-Za-z0-9]+Truncated$/ @@ -409,10 +406,9 @@ function clippedSubject(flag: string): string { /** * States a server-side clip once, on stderr, in every format. * - * The API answers a clipped inventory with a flag on the envelope, and the CLI - * printed only `data` — so `--output json` could not tell a complete list from - * one the server cut short. stdout stays byte-for-byte the rows, for the reason - * `writePageNote` gives. + * The API answers a clipped inventory with a flag on the envelope. It is + * separate from pagination: a null nextCursor does not establish whether the + * server clipped fields within the returned rows. */ function writeEnvelopeTruncation(envelope: unknown): void { for (const flag of responseTruncationFlags(envelope)) { @@ -424,22 +420,6 @@ function writeEnvelopeTruncation(envelope: unknown): void { } } -/** - * States that the walk stopped at `--limit` while more pages remained. - * - * `sim tools list` answered 100 of 4708 rows with an exit code of 0 and nothing - * on stderr, in every format — indistinguishable from a complete inventory. - * Said whenever a cursor survives, including when the caller set a small limit: - * the answer is incomplete either way, and the caller who capped it is the one - * most likely to reuse the result as if it were whole. - */ -export function writeCursorTruncation(count: number, truncated: boolean): void { - if (!truncated) return - process.stderr.write( - chalk.dim(`showing the first ${count}; more results exist — re-run with --limit 0 for all\n`) - ) -} - /** Renders one non-paginated operation result according to its CLI contract. */ export function renderResult( operation: V2OperationName,