diff --git a/apps/docs/content/docs/en/cli/billing.mdx b/apps/docs/content/docs/en/cli/billing.mdx index b97e19c8156..979d99ca7fb 100644 --- a/apps/docs/content/docs/en/cli/billing.mdx +++ b/apps/docs/content/docs/en/cli/billing.mdx @@ -31,7 +31,7 @@ Show billing status and current-period credit usage (credits and storage require sim billing logs [options] ``` -List credit usage events (a personal API key reports only your own events; a workspace API key reports every member's) +List credit usage events (a personal API key reports only your own events; a workspace API key reports every member's in aggregate, unattributed) **Options** diff --git a/apps/docs/content/docs/en/cli/credentials.mdx b/apps/docs/content/docs/en/cli/credentials.mdx index ad63a7ba7a3..aec2144c459 100644 --- a/apps/docs/content/docs/en/cli/credentials.mdx +++ b/apps/docs/content/docs/en/cli/credentials.mdx @@ -33,7 +33,7 @@ Disconnect Credential (personal API key required) | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | diff --git a/apps/docs/content/docs/en/cli/custom-tools.mdx b/apps/docs/content/docs/en/cli/custom-tools.mdx index 3f5af1e743f..097d4fb3fed 100644 --- a/apps/docs/content/docs/en/cli/custom-tools.mdx +++ b/apps/docs/content/docs/en/cli/custom-tools.mdx @@ -49,7 +49,7 @@ sim custom-tools delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | diff --git a/apps/docs/content/docs/en/cli/files.mdx b/apps/docs/content/docs/en/cli/files.mdx index 40758bc2999..3d04694e7b8 100644 --- a/apps/docs/content/docs/en/cli/files.mdx +++ b/apps/docs/content/docs/en/cli/files.mdx @@ -22,7 +22,7 @@ sim files batch-delete [options] | Option | Required | Description | | --- | --- | --- | | `--file-ids ` | Yes | File identifiers to update. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -85,7 +85,7 @@ sim files folders delete [options] | Option | Required | Description | | --- | --- | --- | | `--recursive` | No | Delete the folder and its descendants. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -168,7 +168,7 @@ sim files delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -377,7 +377,7 @@ sim files unzip [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | diff --git a/apps/docs/content/docs/en/cli/knowledge.mdx b/apps/docs/content/docs/en/cli/knowledge.mdx index a760b5067b1..5d697bb22b1 100644 --- a/apps/docs/content/docs/en/cli/knowledge.mdx +++ b/apps/docs/content/docs/en/cli/knowledge.mdx @@ -120,7 +120,7 @@ Delete Tag (personal API key required) | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -150,7 +150,7 @@ Remove tag definitions no document still uses (personal API key required) | --- | --- | --- | | `--unused` | No | Whether to remove only the tag definitions no document in the knowledge base still carries a value for. Defaults to true. Pass --no-unused to delete every definition on the knowledge base, which also clears its slot on every document and chunk and is not recoverable. | | `--no-unused` | No | Send --unused as false. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -273,7 +273,7 @@ Enable, disable, or delete many chunks at once (personal API key required) | --- | --- | --- | | `--operation ` | Yes | What to do with the selected chunks. Accepted values: `enable`, `disable`, `delete`. | | `--chunk ` | Yes | Chunks to operate on, by identifier. An id naming no chunk in the document is reported in errors and does not fail the request. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -334,7 +334,7 @@ Delete Chunk (personal API key required) | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -477,7 +477,7 @@ sim knowledge documents delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -606,8 +606,8 @@ sim knowledge documents upload [options] | --- | --- | --- | | `--name ` | No | Store it under a different name. | | `--tag ` | No | Document tags, in tag1 through tag7 order. | -| `--recipe ` | No | Document processing recipe. | -| `--lang ` | No | Document language code. | +| `--recipe ` | No | Document processing recipe. Accepted values: `default`, `plain`, `markdown`, `code`. | +| `--lang ` | No | Document language tag: hyphen-separated letter and digit subtags, for example en or en-US. | @@ -689,7 +689,7 @@ Delete Knowledge Connector (personal API key required) | --- | --- | --- | | `--delete-documents` | No | Also permanently delete documents produced by this connector. | | `--no-delete-documents` | No | Send --delete-documents as false. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -903,11 +903,11 @@ sim knowledge folders delete [options] | Option | Required | Description | | --- | --- | --- | | `--recursive` | No | Delete the folder and its descendants. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | -## List folders +## List knowledge folders ```bash sim knowledge folders list [options] @@ -969,7 +969,7 @@ sim knowledge delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | diff --git a/apps/docs/content/docs/en/cli/logs.mdx b/apps/docs/content/docs/en/cli/logs.mdx index 1b277367c9c..df85aaa0bf1 100644 --- a/apps/docs/content/docs/en/cli/logs.mdx +++ b/apps/docs/content/docs/en/cli/logs.mdx @@ -35,7 +35,7 @@ sim logs get [options] -## Summarize run counts, failures, and cost over a window +## Summarize run counts, failures and latency over a window ```bash sim logs stats [options] diff --git a/apps/docs/content/docs/en/cli/mcp-servers.mdx b/apps/docs/content/docs/en/cli/mcp-servers.mdx index 1eac319876c..db0f65a115a 100644 --- a/apps/docs/content/docs/en/cli/mcp-servers.mdx +++ b/apps/docs/content/docs/en/cli/mcp-servers.mdx @@ -58,7 +58,7 @@ sim mcp-servers delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | diff --git a/apps/docs/content/docs/en/cli/reference.mdx b/apps/docs/content/docs/en/cli/reference.mdx index f93c5009dc6..f90021ebee2 100644 --- a/apps/docs/content/docs/en/cli/reference.mdx +++ b/apps/docs/content/docs/en/cli/reference.mdx @@ -251,7 +251,7 @@ sim billing status [options] ### sim billing logs -List credit usage events (a personal API key reports only your own events; a workspace API key reports every member's) +List credit usage events (a personal API key reports only your own events; a workspace API key reports every member's in aggregate, unattributed) ```bash sim billing logs [options] @@ -389,7 +389,7 @@ sim credentials delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -602,7 +602,7 @@ sim custom-tools delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -694,7 +694,7 @@ sim files batch-delete [options] | Option | Required | Description | | --- | --- | --- | | `--file-ids ` | Yes | File identifiers to update. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -763,13 +763,13 @@ sim files folders delete [options] | Option | Required | Description | | --- | --- | --- | | `--recursive` | No | Delete the folder and its descendants. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | ### sim files folders list -List Folders +List folders ```bash sim files folders list [options] @@ -854,7 +854,7 @@ sim files delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -1079,7 +1079,7 @@ sim files unzip [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -1332,7 +1332,7 @@ sim knowledge tags delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -1362,7 +1362,7 @@ sim knowledge tags cleanup [options] | --- | --- | --- | | `--unused` | No | Whether to remove only the tag definitions no document in the knowledge base still carries a value for. Defaults to true. Pass --no-unused to delete every definition on the knowledge base, which also clears its slot on every document and chunk and is not recoverable. | | `--no-unused` | No | Send --unused as false. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -1487,7 +1487,7 @@ sim knowledge chunks batch-update [options] | --- | --- | --- | | `--operation ` | Yes | What to do with the selected chunks. Accepted values: `enable`, `disable`, `delete`. | | `--chunk ` | Yes | Chunks to operate on, by identifier. An id naming no chunk in the document is reported in errors and does not fail the request. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -1548,7 +1548,7 @@ sim knowledge chunks delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -1693,7 +1693,7 @@ sim knowledge documents delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -1828,8 +1828,8 @@ sim knowledge documents upload [options] | --- | --- | --- | | `--name ` | No | Store it under a different name. | | `--tag ` | No | Document tags, in tag1 through tag7 order. | -| `--recipe ` | No | Document processing recipe. | -| `--lang ` | No | Document language code. | +| `--recipe ` | No | Document processing recipe. Accepted values: `default`, `plain`, `markdown`, `code`. | +| `--lang ` | No | Document language tag: hyphen-separated letter and digit subtags, for example en or en-US. | @@ -1913,7 +1913,7 @@ sim knowledge connectors delete [options] | --- | --- | --- | | `--delete-documents` | No | Also permanently delete documents produced by this connector. | | `--no-delete-documents` | No | Send --delete-documents as false. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -2131,13 +2131,13 @@ sim knowledge folders delete [options] | Option | Required | Description | | --- | --- | --- | | `--recursive` | No | Delete the folder and its descendants. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | ### sim knowledge folders list -List Folders +List knowledge folders ```bash sim knowledge folders list [options] @@ -2203,7 +2203,7 @@ sim knowledge delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -2423,7 +2423,7 @@ sim logs get [options] ### sim logs stats -Summarize run counts, failures, and cost over a window +Summarize run counts, failures and latency over a window ```bash sim logs stats [options] @@ -2565,7 +2565,7 @@ sim mcp-servers delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -2715,7 +2715,7 @@ sim secrets delete [options] | Option | Required | Description | | --- | --- | --- | | `--scope ` | Yes | Whether the secret belongs to the workspace or to the caller. A personal secret belongs to the caller across every workspace, not to one workspace. Accepted values: `workspace`, `personal`. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -2821,7 +2821,7 @@ sim skills delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -2926,7 +2926,7 @@ sim skills editors delete [options] | Option | Required | Description | | --- | --- | --- | | `--email ` | Yes | Email address of a current workspace member. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -3038,7 +3038,7 @@ sim tables columns delete [options] | Option | Required | Description | | --- | --- | --- | | `--column-name ` | Yes | Name of the column to delete. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -3127,7 +3127,7 @@ sim tables groups delete [options] | Option | Required | Description | | --- | --- | --- | | `--group-id ` | Yes | Workflow group to delete. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -3204,7 +3204,7 @@ sim tables batch-delete [options] | --- | --- | --- | | `--table-ids ` | No | Tables to archive, by identifier. (JSON, or @path / @- to read a file or stdin). | | `--folder ` | No | Folder path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -3290,7 +3290,7 @@ sim tables rows delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -3321,7 +3321,7 @@ sim tables rows batch-delete [options] | `--filter ` | No | Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. 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). | | `--limit ` | No | Maximum matching rows to delete. (caps a --filter match only; omit it to act on every match, and note 0 is not accepted). | | `--row ` | No | Explicit row identifiers to delete. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -3522,7 +3522,7 @@ sim tables rows batch-update [options] | `--filter ` | Yes | Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. 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). | | `--data ` | Yes | Row-data patch applied to every matching row. (JSON, or @path / @- to read a file or stdin). | | `--limit ` | No | Maximum matching rows to update. (caps a --filter match only; omit it to act on every match, and note 0 is not accepted). | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -3580,7 +3580,7 @@ sim tables dispatches cancel [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -3763,7 +3763,7 @@ sim tables imports cancel [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -3813,7 +3813,7 @@ sim tables cancel-runs [options] | `--row-id ` | No | Row whose runs should be canceled for row scope. | | `--filter ` | No | Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. 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). | | `--exclude-row-ids ` | No | Rows excluded from an all-scope cancellation. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -3881,13 +3881,13 @@ sim tables folders delete [options] | Option | Required | Description | | --- | --- | --- | | `--recursive` | No | Delete the folder and its descendants. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | ### sim tables folders list -List Folders +List table folders ```bash sim tables folders list [options] @@ -4001,7 +4001,7 @@ sim tables views delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -4099,7 +4099,7 @@ sim tables delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -4455,7 +4455,7 @@ sim workflow-mcp-servers delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -4533,7 +4533,7 @@ sim workflow-mcp-servers tools delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -4655,12 +4655,12 @@ sim workflows operations apply [options] | --- | --- | --- | | `--dry-run` | No | Validate and lint without persisting. The response is identical to the committed write of the same body, so a caller can inspect `lint` and then re-send the request for real. Nothing is written, no audit entry is recorded, and collaborators are not notified. | | `--no-dry-run` | No | Send --dry-run as false. | -| `--operations ` | Yes | Edits to apply, in a single batch, keyed by operation_type: [{"operation_type":"add","block_id":"my-fn","params":{"type":"function","name":"My Fn","inputs":{"code":"return {ok:true}"}}},{"operation_type":"edit","block_id":"<uuid>","params":{"name":"Renamed","connections":{"success":"my-fn"}}},{"operation_type":"delete","block_id":"<uuid>"}]. Also insert_into_subflow and extract_from_subflow, whose params carry {"subflowId":"<loop-id>"} (JSON, or @path / @- to read a file or stdin). | +| `--operations ` | Yes | Edits to apply, in a single batch, keyed by operation_type: [{"operation_type":"add","block_id":"my-fn","params":{"type":"function","name":"My Fn","inputs":{"code":"return {ok:true}"}}},{"operation_type":"edit","block_id":"<uuid>","params":{"name":"Renamed","connections":{"success":"my-fn"}}},{"operation_type":"delete","block_id":"<uuid>"}]. Also extract_from_subflow, whose params carry {"subflowId":"<loop-id>"}, and insert_into_subflow, which creates a block and so takes an add’s params plus that subflowId (JSON, or @path / @- to read a file or stdin). | | `--atomic` | No | Fail the whole batch when any operation is declined or any block input would be dropped. The default applies what it can and reports the rest in `skipped` and `inputValidationErrors`; `true` writes nothing and answers `409` instead. | | `--no-atomic` | No | Send --atomic as false. | | `--layout ` | No | Whether to reposition blocks the batch touched. `targeted` (default) nudges only the affected subgraph; `none` leaves every position exactly as supplied. Accepted values: `targeted`, `none`. | | `--set-block-enabled ` | No | Blocks to enable or disable, applied after --operations: [{"block_id":"<uuid>","enabled":false}]. Disabling a loop or parallel cascades to its unlocked descendants; enabling a block whose container is disabled is declined (JSON, or @path / @- to read a file or stdin). | -| `-y, --yes` | No | Confirm this destructive operation (required unless --dry-run). | +| `-y, --yes` | No | Confirm this operation (required unless --dry-run). | @@ -4689,7 +4689,7 @@ sim workflows variables update [options] | Option | Required | Description | | --- | --- | --- | | `--operations ` | Yes | Variable changes to apply in order, keyed by operation: [{"operation":"add","name":"my_var","type":"string","value":"hello"},{"operation":"edit","name":"my_var","value":"updated"},{"operation":"delete","name":"my_var"}] (JSON, or @path / @- to read a file or stdin). | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -4900,13 +4900,13 @@ sim workflows folders delete [options] | Option | Required | Description | | --- | --- | --- | | `--recursive` | No | Delete the folder and its descendants. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | ### sim workflows folders list -List Workflow Folders +List workflow folders ```bash sim workflows folders list [options] @@ -4972,7 +4972,7 @@ sim workflows delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -5000,7 +5000,7 @@ sim workflows chat unpublish [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -5058,7 +5058,7 @@ sim workflows chat publish [options] | `--no-include-thinking` | No | Send --include-thinking as false. | | `--include-tool-calls` | No | Allow visitors to receive tool lifecycle events. | | `--no-include-tool-calls` | No | Send --include-tool-calls as false. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -5294,7 +5294,7 @@ sim workflows state replace [options] | `--loops ` | No | Ignored on write: loop containers are recomputed from `blocks`. (JSON, or @path / @- to read a file or stdin). | | `--parallels ` | No | Ignored on write: parallel containers are recomputed from `blocks`. (JSON, or @path / @- to read a file or stdin). | | `--variables ` | No | Replacement variable set. Omit to leave the stored variables untouched. (JSON, or @path / @- to read a file or stdin). | -| `-y, --yes` | No | Confirm this destructive operation (required unless --dry-run). | +| `-y, --yes` | No | Confirm this operation (required unless --dry-run). | @@ -5483,7 +5483,7 @@ sim workflows revert create [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -5512,7 +5512,7 @@ sim workflows rollback [options] | Option | Required | Description | | --- | --- | --- | | `--to-version ` | No | Deployment version to reactivate. Omit to select the previous active version. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -5540,7 +5540,7 @@ sim workflows undeploy [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | diff --git a/apps/docs/content/docs/en/cli/secrets.mdx b/apps/docs/content/docs/en/cli/secrets.mdx index cf14dda7d5e..f9bbbdb2cdb 100644 --- a/apps/docs/content/docs/en/cli/secrets.mdx +++ b/apps/docs/content/docs/en/cli/secrets.mdx @@ -34,7 +34,7 @@ Delete Secret (personal API key required) | Option | Required | Description | | --- | --- | --- | | `--scope ` | Yes | Whether the secret belongs to the workspace or to the caller. A personal secret belongs to the caller across every workspace, not to one workspace. Accepted values: `workspace`, `personal`. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | diff --git a/apps/docs/content/docs/en/cli/skills.mdx b/apps/docs/content/docs/en/cli/skills.mdx index a9f969b869b..77d928a5ead 100644 --- a/apps/docs/content/docs/en/cli/skills.mdx +++ b/apps/docs/content/docs/en/cli/skills.mdx @@ -53,7 +53,7 @@ Delete Skill (personal API key required) | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -154,7 +154,7 @@ Revoke Skill Editor (personal API key required) | Option | Required | Description | | --- | --- | --- | | `--email ` | Yes | Email address of a current workspace member. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | diff --git a/apps/docs/content/docs/en/cli/tables.mdx b/apps/docs/content/docs/en/cli/tables.mdx index 1739a5ef1e8..f7d5b11166a 100644 --- a/apps/docs/content/docs/en/cli/tables.mdx +++ b/apps/docs/content/docs/en/cli/tables.mdx @@ -58,7 +58,7 @@ sim tables columns delete [options] | Option | Required | Description | | --- | --- | --- | | `--column-name ` | Yes | Name of the column to delete. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -141,7 +141,7 @@ sim tables groups delete [options] | Option | Required | Description | | --- | --- | --- | | `--group-id ` | Yes | Workflow group to delete. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -212,7 +212,7 @@ sim tables batch-delete [options] | --- | --- | --- | | `--table-ids ` | No | Tables to archive, by identifier. (JSON, or @path / @- to read a file or stdin). | | `--folder ` | No | Folder path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -292,7 +292,7 @@ sim tables rows delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -321,7 +321,7 @@ sim tables rows batch-delete [options] | `--filter ` | No | Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. 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). | | `--limit ` | No | Maximum matching rows to delete. (caps a --filter match only; omit it to act on every match, and note 0 is not accepted). | | `--row ` | No | Explicit row identifiers to delete. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -508,7 +508,7 @@ sim tables rows batch-update [options] | `--filter ` | Yes | Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. 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). | | `--data ` | Yes | Row-data patch applied to every matching row. (JSON, or @path / @- to read a file or stdin). | | `--limit ` | No | Maximum matching rows to update. (caps a --filter match only; omit it to act on every match, and note 0 is not accepted). | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -562,7 +562,7 @@ sim tables dispatches cancel [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -729,7 +729,7 @@ sim tables imports cancel [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -775,7 +775,7 @@ sim tables cancel-runs [options] | `--row-id ` | No | Row whose runs should be canceled for row scope. | | `--filter ` | No | Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. 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). | | `--exclude-row-ids ` | No | Rows excluded from an all-scope cancellation. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -837,11 +837,11 @@ sim tables folders delete [options] | Option | Required | Description | | --- | --- | --- | | `--recursive` | No | Delete the folder and its descendants. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | -## List folders +## List table folders ```bash sim tables folders list [options] @@ -947,7 +947,7 @@ sim tables views delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -1037,7 +1037,7 @@ sim tables delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | diff --git a/apps/docs/content/docs/en/cli/workflow-mcp-servers.mdx b/apps/docs/content/docs/en/cli/workflow-mcp-servers.mdx index af1ff6add1b..ca7ca03a730 100644 --- a/apps/docs/content/docs/en/cli/workflow-mcp-servers.mdx +++ b/apps/docs/content/docs/en/cli/workflow-mcp-servers.mdx @@ -53,7 +53,7 @@ Delete Workflow MCP Server (personal API key required) | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -131,7 +131,7 @@ Unpublish Workflow MCP Tool (personal API key required) | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | diff --git a/apps/docs/content/docs/en/cli/workflows.mdx b/apps/docs/content/docs/en/cli/workflows.mdx index 3a82b1878ef..248b9d18776 100644 --- a/apps/docs/content/docs/en/cli/workflows.mdx +++ b/apps/docs/content/docs/en/cli/workflows.mdx @@ -54,12 +54,12 @@ Apply Workflow Operations (personal API key required) | --- | --- | --- | | `--dry-run` | No | Validate and lint without persisting. The response is identical to the committed write of the same body, so a caller can inspect `lint` and then re-send the request for real. Nothing is written, no audit entry is recorded, and collaborators are not notified. | | `--no-dry-run` | No | Send --dry-run as false. | -| `--operations ` | Yes | Edits to apply, in a single batch, keyed by operation_type: [{"operation_type":"add","block_id":"my-fn","params":{"type":"function","name":"My Fn","inputs":{"code":"return {ok:true}"}}},{"operation_type":"edit","block_id":"<uuid>","params":{"name":"Renamed","connections":{"success":"my-fn"}}},{"operation_type":"delete","block_id":"<uuid>"}]. Also insert_into_subflow and extract_from_subflow, whose params carry {"subflowId":"<loop-id>"} (JSON, or @path / @- to read a file or stdin). | +| `--operations ` | Yes | Edits to apply, in a single batch, keyed by operation_type: [{"operation_type":"add","block_id":"my-fn","params":{"type":"function","name":"My Fn","inputs":{"code":"return {ok:true}"}}},{"operation_type":"edit","block_id":"<uuid>","params":{"name":"Renamed","connections":{"success":"my-fn"}}},{"operation_type":"delete","block_id":"<uuid>"}]. Also extract_from_subflow, whose params carry {"subflowId":"<loop-id>"}, and insert_into_subflow, which creates a block and so takes an add’s params plus that subflowId (JSON, or @path / @- to read a file or stdin). | | `--atomic` | No | Fail the whole batch when any operation is declined or any block input would be dropped. The default applies what it can and reports the rest in `skipped` and `inputValidationErrors`; `true` writes nothing and answers `409` instead. | | `--no-atomic` | No | Send --atomic as false. | | `--layout ` | No | Whether to reposition blocks the batch touched. `targeted` (default) nudges only the affected subgraph; `none` leaves every position exactly as supplied. Accepted values: `targeted`, `none`. | | `--set-block-enabled ` | No | Blocks to enable or disable, applied after --operations: [{"block_id":"<uuid>","enabled":false}]. Disabling a loop or parallel cascades to its unlocked descendants; enabling a block whose container is disabled is declined (JSON, or @path / @- to read a file or stdin). | -| `-y, --yes` | No | Confirm this destructive operation (required unless --dry-run). | +| `-y, --yes` | No | Confirm this operation (required unless --dry-run). | @@ -86,7 +86,7 @@ sim workflows variables update [options] | Option | Required | Description | | --- | --- | --- | | `--operations ` | Yes | Variable changes to apply in order, keyed by operation: [{"operation":"add","name":"my_var","type":"string","value":"hello"},{"operation":"edit","name":"my_var","value":"updated"},{"operation":"delete","name":"my_var"}] (JSON, or @path / @- to read a file or stdin). | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -285,7 +285,7 @@ sim workflows folders delete [options] | Option | Required | Description | | --- | --- | --- | | `--recursive` | No | Delete the folder and its descendants. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -351,7 +351,7 @@ sim workflows delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -379,7 +379,7 @@ Take a workflow’s chat deployment offline (personal API key required) | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -437,7 +437,7 @@ Publish or replace a workflow’s chat deployment (personal API key required) | `--no-include-thinking` | No | Send --include-thinking as false. | | `--include-tool-calls` | No | Allow visitors to receive tool lifecycle events. | | `--no-include-tool-calls` | No | Send --include-tool-calls as false. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -661,7 +661,7 @@ Replace Workflow State (personal API key required) | `--loops ` | No | Ignored on write: loop containers are recomputed from `blocks`. (JSON, or @path / @- to read a file or stdin). | | `--parallels ` | No | Ignored on write: parallel containers are recomputed from `blocks`. (JSON, or @path / @- to read a file or stdin). | | `--variables ` | No | Replacement variable set. Omit to leave the stored variables untouched. (JSON, or @path / @- to read a file or stdin). | -| `-y, --yes` | No | Confirm this destructive operation (required unless --dry-run). | +| `-y, --yes` | No | Confirm this operation (required unless --dry-run). | @@ -836,7 +836,7 @@ Revert Workflow To Version (personal API key required) | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -865,7 +865,7 @@ Rollback Workflow (personal API key required) | Option | Required | Description | | --- | --- | --- | | `--to-version ` | No | Deployment version to reactivate. Omit to select the previous active version. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -893,7 +893,7 @@ Take a workflow out of deployment (personal API key required) | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | diff --git a/packages/sim-cli/src/commands/configure.test.ts b/packages/sim-cli/src/commands/configure.test.ts index 094b535a4bb..529d7baa2b5 100644 --- a/packages/sim-cli/src/commands/configure.test.ts +++ b/packages/sim-cli/src/commands/configure.test.ts @@ -173,6 +173,17 @@ describe('configure and the root globals', () => { expect(readConfigProfile('default')).toEqual({}) }) + /** + * The refusal prints a command for the caller to run, so an unredacted value + * carrying U+2028 rendered as a second line that reads like a suggestion of + * its own. + */ + it('redacts a control character out of the command it suggests', async () => { + await expect(run('--endpoint', 'https://a.example\u2028sim login --api-key x')).rejects.toThrow( + 'sim configure --set-endpoint https://a.example sim login --api-key x' + ) + }) + it('refuses -w and --output the same way', async () => { await expect(run('-w', 'ws_9')).rejects.toThrow('sim configure --set-workspace ws_9') await expect(run('--output', 'json')).rejects.toThrow('sim configure --set-output json') diff --git a/packages/sim-cli/src/commands/configure.ts b/packages/sim-cli/src/commands/configure.ts index 82304a19685..de3c881a75b 100644 --- a/packages/sim-cli/src/commands/configure.ts +++ b/packages/sim-cli/src/commands/configure.ts @@ -70,7 +70,7 @@ export function configureCommand(): Command { const value = globals[option] if (value === undefined) continue throw new SimApiError( - `${flag} applies to a single command and is not stored. To save it, run: sim configure ${setFlag} ${value}`, + `${flag} applies to a single command and is not stored. To save it, run: sim configure ${setFlag} ${redact(value)}`, 0 ) } diff --git a/packages/sim-cli/src/commands/protocol/chat.test.ts b/packages/sim-cli/src/commands/protocol/chat.test.ts index 0cba17c8c09..2b58c2f255b 100644 --- a/packages/sim-cli/src/commands/protocol/chat.test.ts +++ b/packages/sim-cli/src/commands/protocol/chat.test.ts @@ -100,6 +100,9 @@ function written(spy: WriteSpy): string { return spy.mock.calls.map((call) => String(call[0])).join('') } +/** A conversation id in the shape the route accepts and the command prints. */ +const CONVERSATION_ID = '3f2a1c4e-0000-4000-8000-000000000000' + const FINAL = { type: 'final', data: { content: 'Hello there', conversationId: 'conv-1', model: 'sim' }, @@ -127,17 +130,35 @@ describe('sim chat', () => { expect(written(stderr)).toContain('conversation: conv-1') }) + /** + * The route's own refusals name `message` and `conversationId`, and this + * command builds its request by hand so nothing retypes them into what the + * caller typed. A blank `-c` was worse than misnamed: it is falsy, so it was + * dropped from the body and silently started a NEW conversation. + */ + it('refuses a blank message and a blank -c before the request', async () => { + await expect(run(' ')).rejects.toThrow(' cannot be empty') + await expect(run('-c', '', 'hello')).rejects.toThrow('-c/--conversation cannot be empty') + await expect(run('-c', ' ', 'hello')).rejects.toThrow('-c/--conversation cannot be empty') + expect(requestRaw).not.toHaveBeenCalled() + }) + + /** + * A conversation id as the command prints it. The shape is the route's rule + * to enforce — the CLI refuses only a blank `-c`, which is falsy and would + * otherwise be dropped from the body and start a new conversation. + */ it('passes -c through as the conversation to continue', async () => { requestRaw.mockResolvedValue(ndjson([FINAL])) - await run('-c', 'conv-1', 'And which run on a schedule?') + await run('-c', CONVERSATION_ID, 'And which run on a schedule?') expect(requestRaw).toHaveBeenCalledWith('/api/v2/chat', { method: 'POST', body: { workspaceId: 'ws_local', message: 'And which run on a schedule?', - conversationId: 'conv-1', + conversationId: CONVERSATION_ID, }, headers: { accept: 'application/x-ndjson' }, }) diff --git a/packages/sim-cli/src/commands/protocol/chat.ts b/packages/sim-cli/src/commands/protocol/chat.ts index 58878c3fc2f..a27139031fc 100644 --- a/packages/sim-cli/src/commands/protocol/chat.ts +++ b/packages/sim-cli/src/commands/protocol/chat.ts @@ -142,6 +142,7 @@ function ignoreBrokenPipe(stream: NodeJS.WriteStream): () => void { * proxies from idling the connection out. The generated `chat` operation is * hidden in the CLI contract in favour of this command. */ + export function attachChat(program: Command): void { program .command('chat') @@ -164,6 +165,24 @@ Examples: ` ) .action(async (message: string, options: ChatOptions, command: Command) => { + /** + * Refused here so the refusal names what the caller typed: the route + * answers in its own field names, and this command builds its request by + * hand, so nothing retypes them into `` and `-c/--conversation`. + * A blank `-c` was worse than misnamed — it is falsy, so it was dropped + * from the body and silently started a NEW conversation instead of + * continuing one. + */ + if (message.trim() === '') { + throw new SimApiError(' cannot be empty', 0) + } + if (options.conversation !== undefined && options.conversation.trim() === '') { + throw new SimApiError( + '-c/--conversation cannot be empty — pass the conversation id printed on stderr after each turn', + 0 + ) + } + const { client, profile } = clientFrom(command) const workspaceId = client.requireWorkspace() diff --git a/packages/sim-cli/src/commands/protocol/knowledge-document-upload.test.ts b/packages/sim-cli/src/commands/protocol/knowledge-document-upload.test.ts index 3204182a2f5..46dd1e39848 100644 --- a/packages/sim-cli/src/commands/protocol/knowledge-document-upload.test.ts +++ b/packages/sim-cli/src/commands/protocol/knowledge-document-upload.test.ts @@ -213,6 +213,41 @@ describe('knowledge documents upload', () => { expect(mockRequest).not.toHaveBeenCalled() }) + /** + * The route enforces both, and neither said so: the help read "Document + * processing recipe" / "Document language code" and the CLI uploaded the file + * before the server refused the value. Every other constrained flag in this + * CLI uses commander `choices`. + */ + it('states what --recipe and --lang accept, and refuses a recipe before uploading', async () => { + const path = join(dir, 'notes.txt') + writeFileSync(path, 'hello') + + const help = program() + .commands.find((command) => command.name() === 'knowledge') + ?.commands.find((command) => command.name() === 'documents') + ?.commands.find((command) => command.name() === 'upload') + ?.helpInformation() + .replace(/\s+/g, ' ') + expect(help).toContain('choices: "default", "plain", "markdown", "code"') + expect(help).toContain('hyphen-separated letter and digit subtags') + + await expect( + program().parseAsync([ + 'node', + 'sim', + 'kb', + 'documents', + 'upload', + 'kb_1', + path, + '--recipe', + 'super-chunker-9000', + ]) + ).rejects.toThrow(/super-chunker-9000/) + expect(mockRequest).not.toHaveBeenCalled() + }) + it('requires the knowledge-base argument before reading the file', async () => { const path = join(dir, 'notes.txt') writeFileSync(path, 'hello') diff --git a/packages/sim-cli/src/commands/protocol/knowledge-document-upload.ts b/packages/sim-cli/src/commands/protocol/knowledge-document-upload.ts index d9f02e9e85e..02a8af0a1d4 100644 --- a/packages/sim-cli/src/commands/protocol/knowledge-document-upload.ts +++ b/packages/sim-cli/src/commands/protocol/knowledge-document-upload.ts @@ -1,7 +1,8 @@ -import type { Command } from 'commander' +import { type Command, Option } from 'commander' import { clientFrom } from '../../context' import type { CompleteKnowledgeDocumentUploadResponse, + CreateKnowledgeDocumentUploadBody, CreateKnowledgeDocumentUploadResponse, } from '../../generated/v2-api' import { SimApiError } from '../../http/client' @@ -35,6 +36,29 @@ function uploadMetadata(options: KnowledgeDocumentUploadOptions): Record['recipe'] +> +const UPLOAD_RECIPES = [ + 'default', + 'plain', + 'markdown', + 'code', +] as const satisfies readonly UploadRecipe[] + +/** + * The route enforces a shape, not BCP-47 conformance, so the help says the + * shape and nothing more; a full parser is the route's own deliberate + * non-goal and reimplementing one here would refuse tags the server accepts. + */ +const LANGUAGE_TAG_HELP = + 'Document language tag: hyphen-separated letter and digit subtags, for example en or en-US' + export function attachKnowledgeDocumentUpload(documents: Command): void { documents .command('upload') @@ -44,8 +68,8 @@ export function attachKnowledgeDocumentUpload(documents: Command): void { .description('Upload a document to a knowledge base') .option('--name ', 'Store it under a different name') .option('--tag ', 'Document tags, in tag1 through tag7 order') - .option('--recipe ', 'Document processing recipe') - .option('--lang ', 'Document language code') + .addOption(new Option('--recipe ', 'Document processing recipe').choices(UPLOAD_RECIPES)) + .option('--lang ', LANGUAGE_TAG_HELP) .action( async ( knowledgeBaseId: string, diff --git a/packages/sim-cli/src/commands/protocol/logs-follow.test.ts b/packages/sim-cli/src/commands/protocol/logs-follow.test.ts index 871fef9373d..b727c0cd95e 100644 --- a/packages/sim-cli/src/commands/protocol/logs-follow.test.ts +++ b/packages/sim-cli/src/commands/protocol/logs-follow.test.ts @@ -454,7 +454,7 @@ describe('sim logs follow', () => { it('rejects a backlog count that is not a whole number of runs', async () => { respondWith([]) - await expect(follow('-n', '-1')).rejects.toThrow('--lines must be a non-negative integer') + await expect(follow('-n', '-1')).rejects.toThrow('--lines must be a whole number of 0 or more') expect(mockRequest).not.toHaveBeenCalled() }) @@ -517,4 +517,27 @@ describe('sim logs follow', () => { expect(ms).toBeGreaterThan(0) } }) + /** + * Root help states that a `wf_` prefix marks a FILE id and "never names a + * workflow", so the example told the reader to pass a file id to + * `--workflow`. Workflow ids are bare UUIDs. + */ + it('does not illustrate --workflow with a file-id prefix', () => { + const root = new Command('sim').exitOverride() + const logs = new Command('logs').exitOverride() + root.addCommand(logs) + attachLogsFollow(logs) + // `helpInformation()` omits `addHelpText('after')`, which is where the + // examples live, so the help is captured as the command would print it. + let help = '' + logs.commands[0].configureOutput({ + writeOut: (text) => { + help += text + }, + }) + logs.commands[0].outputHelp() + + expect(help).not.toContain('wf_') + expect(help).toMatch(/--workflow [0-9a-f]{8}-[0-9a-f]{4}-/) + }) }) diff --git a/packages/sim-cli/src/commands/protocol/logs-follow.ts b/packages/sim-cli/src/commands/protocol/logs-follow.ts index 330cc86011b..1fddeadcc2e 100644 --- a/packages/sim-cli/src/commands/protocol/logs-follow.ts +++ b/packages/sim-cli/src/commands/protocol/logs-follow.ts @@ -467,7 +467,7 @@ function isTransient(error: unknown): boolean { function nonNegativeInteger(raw: string, flag: string): number { const value = Number(raw) if (!Number.isSafeInteger(value) || value < 0) { - throw new SimApiError(`${flag} must be a non-negative integer`, 0) + throw new SimApiError(`${flag} must be a whole number of 0 or more`, 0) } return value } @@ -531,7 +531,7 @@ follow. Examples: $ sim logs follow --level error - $ sim logs follow --workflow wf_123 -n 0 + $ sim logs follow --workflow 00000000-0000-4000-8000-000000000000 -n 0 $ sim --output json logs follow | jq -r '.runId' ` ) 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 eb1bb94f0d2..b0d0332bb94 100644 --- a/packages/sim-cli/src/commands/protocol/resource-directory.test.ts +++ b/packages/sim-cli/src/commands/protocol/resource-directory.test.ts @@ -216,6 +216,42 @@ 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 () => { + mockRequest.mockImplementation( + async (path: string, options: { query: { cursor?: string } }) => { + if (path === '/api/v2/files/folders') return { data: [] } + const cursor = Number(options.query.cursor ?? 0) + return { + data: Array.from({ length: 100 }, (_, index) => ({ + id: `file_${cursor}_${index}`, + name: `file-${cursor}-${index}`, + folderPath: '/', + updatedAt: '2026-01-01T00:00:00.000Z', + })), + nextCursor: cursor < 2 ? String(cursor + 1) : null, + } + } + ) + const written: string[] = [] + vi.spyOn(process.stderr, 'write').mockImplementation((chunk: string | Uint8Array) => { + written.push(String(chunk)) + return true + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program().parseAsync(['node', 'sim', 'files', 'ls', '--limit', '5']) + expect(written.join('')).toContain('showing the first 5') + + written.length = 0 + await program().parseAsync(['node', 'sim', 'files', 'ls', '--limit', '0']) + expect(written.join('')).not.toContain('showing the first') + }) + it('rejects extra directory arguments instead of silently ignoring them', async () => { await expect( program().parseAsync(['node', 'sim', 'file', 'ls', 'Reports', 'ignored']) diff --git a/packages/sim-cli/src/commands/protocol/resource-directory.ts b/packages/sim-cli/src/commands/protocol/resource-directory.ts index 8fef45070e8..f334ec29e9f 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 { requestAllPages, SimApiError, type SimClient, type V2Page } from '../../http/client' +import { requestPages, SimApiError, type SimClient, type V2Page } from '../../http/client' import { type Column, printList, text, timestamp } from '../../output/render' import { DEFAULT_LIMIT } from '../../runtime/options' import { encodeFolderPath } from '../../runtime/request' -import { decodeFolderPath, renderResult } from '../../runtime/result' +import { decodeFolderPath, renderResult, writeCursorTruncation } from '../../runtime/result' type FolderListOperation = | 'listFileFolders' @@ -103,17 +103,17 @@ async function listResources( folderPath: string, search: string | undefined, limit: number -): Promise { +): Promise<{ items: DirectoryResource[]; truncated: boolean }> { 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 page.data.slice(0, limit) + return { items: page.data.slice(0, limit), truncated: page.data.length > limit } } - return requestAllPages(client, path, { + return requestPages(client, path, { query, pageSize: DEFAULT_LIMIT, limit, @@ -177,7 +177,7 @@ export function attachResourceDirectoryCommands( .action(async (path: string | undefined, options: ListOptions, command: Command) => { const rawLimit = Number(options.limit) if (!Number.isSafeInteger(rawLimit) || rawLimit < 0) { - throw new SimApiError('--limit must be a non-negative integer', 0) + throw new SimApiError('--limit must be a whole number of 0 or more (0 for everything)', 0) } const limit = rawLimit === 0 ? Number.POSITIVE_INFINITY : rawLimit @@ -191,8 +191,13 @@ 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) - printList(profile.output, entries.slice(0, limit), COLUMNS) + const entries = entriesFor(config, folders, resources.items) + 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) }) group diff --git a/packages/sim-cli/src/contract/commands.test.ts b/packages/sim-cli/src/contract/commands.test.ts index 73486d2f0b1..b2a61123eff 100644 --- a/packages/sim-cli/src/contract/commands.test.ts +++ b/packages/sim-cli/src/contract/commands.test.ts @@ -419,6 +419,19 @@ describe('list columns', () => { expect(columns[columns.length - 1]).toBe(unredacted) }) + /** + * A custom tool has both a `title` and a `schema.function.name`, and they are + * different fields — a column headed `name` showing the title named the other + * one, while `--search` and `--sort-by title` both speak of the title. + */ + it('heads the custom-tool column with the field it actually shows', () => { + const columns = CLI_CONTRACT.listCustomTools?.columns ?? [] + const titled = columns.find((column) => (column.path ?? column.header) === 'title') + + expect(titled?.header).toBe('title') + expect(columns.some((column) => column.header === 'name')).toBe(false) + }) + it('keeps the workflow-MCP listings scannable', () => { const servers = CLI_CONTRACT.listWorkflowMcpServers?.columns ?? [] const tools = CLI_CONTRACT.listWorkflowMcpTools?.columns ?? [] @@ -725,9 +738,7 @@ describe('the import cancel refuses through commander, not just in the contract' }) it('offers --yes in the help of the one it now gates, and not its sibling', () => { - expect(flatHelp('tables', 'imports', 'cancel')).toContain( - 'Confirm this destructive operation (required)' - ) + expect(flatHelp('tables', 'imports', 'cancel')).toContain('Confirm this operation (required)') expect(flatHelp('tables', 'exports', 'cancel')).not.toContain('--yes') }) }) diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index c423c397926..46868afb00f 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -22,7 +22,7 @@ const DISPATCH_ROW_LIMIT_HELP = * shape guessable, the same way `TABLE_FILTER_HELP` does for the predicate. */ const WORKFLOW_OPERATIONS_HELP = - 'Edits to apply, in a single batch, keyed by operation_type: [{"operation_type":"add","block_id":"my-fn","params":{"type":"function","name":"My Fn","inputs":{"code":"return {ok:true}"}}},{"operation_type":"edit","block_id":"","params":{"name":"Renamed","connections":{"success":"my-fn"}}},{"operation_type":"delete","block_id":""}]. Also insert_into_subflow and extract_from_subflow, whose params carry {"subflowId":""}' + 'Edits to apply, in a single batch, keyed by operation_type: [{"operation_type":"add","block_id":"my-fn","params":{"type":"function","name":"My Fn","inputs":{"code":"return {ok:true}"}}},{"operation_type":"edit","block_id":"","params":{"name":"Renamed","connections":{"success":"my-fn"}}},{"operation_type":"delete","block_id":""}]. Also extract_from_subflow, whose params carry {"subflowId":""}, and insert_into_subflow, which creates a block and so takes an add’s params plus that subflowId' const WORKFLOW_SET_BLOCK_ENABLED_HELP = 'Blocks to enable or disable, applied after --operations: [{"block_id":"","enabled":false}]. Disabling a loop or parallel cascades to its unlocked descendants; enabling a block whose container is disabled is declined' const WORKFLOW_VARIABLE_OPERATIONS_HELP = @@ -156,7 +156,7 @@ export const CLI_CONTRACT: CliContract = { // `billing status` says its own caveat. The trailing parenthetical is what // keeps the generated docs heading unchanged. describe: - "List credit usage events (a personal API key reports only your own events; a workspace API key reports every member's)", + "List credit usage events (a personal API key reports only your own events; a workspace API key reports every member's in aggregate, unattributed)", flags: { source: { describe: 'Filter by usage source; sim-chat combines Copilot and workspace chat' }, period: { describe: 'Billing period' }, @@ -423,7 +423,7 @@ export const CLI_CONTRACT: CliContract = { }, getLogStats: { command: 'logs stats', - describe: 'Summarize run counts, failures, and cost over a window', + describe: 'Summarize run counts, failures and latency over a window', flags: LOG_LIST_FILTER_FLAGS, // Undeclared, the summary fell through to the generic key dump: the whole // `workflows` series printed as one truncated line of raw JSON, the window @@ -829,7 +829,11 @@ export const CLI_CONTRACT: CliContract = { listCustomTools: { columns: [ { header: 'id' }, - { header: 'name', path: 'title' }, + // `title` and `schema.function.name` are both real and different fields + // on this resource — the flags say `--search` matches the title and + // `--sort-by title` orders by it — so a column headed `name` showing the + // title named the other one. + { header: 'title', path: 'title' }, { header: 'description', path: 'schema.function.description' }, { header: 'updated', path: 'updatedAt', format: 'timestamp' }, ], @@ -1132,7 +1136,15 @@ export const CLI_CONTRACT: CliContract = { }, // ─── Resource-scoped, path-addressed folders ────────────────────────────── + /** + * None of the four folder lists paginates: the route declares no `cursor` and + * answers with the whole set. That is deliberate — a folder tree is bounded + * where it loads — but the terminal said nothing about it, and a caller + * reading `--limit` on every other `list` had no way to tell whether the + * answer was the full set or the first page of one. + */ listFileFolders: { + describe: 'List folders', aliases: ['ls'], flags: { parentPath: { ...FOLDER_PATH_INPUT, name: 'parent', describe: 'Direct parent folder path' }, @@ -1140,6 +1152,7 @@ export const CLI_CONTRACT: CliContract = { columns: FOLDER_LIST_COLUMNS, }, listKnowledgeFolders: { + describe: 'List knowledge folders', aliases: ['ls'], flags: { parentPath: { ...FOLDER_PATH_INPUT, name: 'parent', describe: 'Direct parent folder path' }, @@ -1147,6 +1160,7 @@ export const CLI_CONTRACT: CliContract = { columns: FOLDER_LIST_COLUMNS, }, listTableFolders: { + describe: 'List table folders', aliases: ['ls'], flags: { parentPath: { ...FOLDER_PATH_INPUT, name: 'parent', describe: 'Direct parent folder path' }, @@ -1154,6 +1168,7 @@ export const CLI_CONTRACT: CliContract = { columns: FOLDER_LIST_COLUMNS, }, listWorkflowFolders: { + describe: 'List workflow folders', aliases: ['ls'], flags: { parentPath: { ...FOLDER_PATH_INPUT, name: 'parent', describe: 'Direct parent folder path' }, diff --git a/packages/sim-cli/src/http/client.ts b/packages/sim-cli/src/http/client.ts index 6268dd379ad..1cbd55885fc 100644 --- a/packages/sim-cli/src/http/client.ts +++ b/packages/sim-cli/src/http/client.ts @@ -359,6 +359,19 @@ function traceRequest(method: string, url: string, status: number | string, star ) } +/** + * Drops a label the message already opens with. + * + * Some routes name the field inside the message as well as in `path`, and the + * two are printed one after the other: `sortBy: only \"startedAt\" can order job + * runs` under `path: ['sortBy']` came out as `--sort-by: --sort-by: only …` + * once the wire name had been retyped as the flag. + */ +function withoutLeadingLabel(message: string, label: string): string { + const prefix = `${label}: ` + return message.startsWith(prefix) ? message.slice(prefix.length) : message +} + /** Formats nested validation issues as readable, path-aware lines. */ export function formatApiErrorDetails(details: unknown): string[] { const issues: DetailIssue[] = [] @@ -402,9 +415,10 @@ export function formatApiErrorDetails(details: unknown): string[] { const visible = kept.slice(0, 8) const lines = [ ' details:', - ...visible.map( - (issue) => ` ${issue.path.length > 0 ? issue.path.join('.') : 'request'}: ${issue.message}` - ), + ...visible.map((issue) => { + const label = issue.path.length > 0 ? issue.path.join('.') : 'request' + return ` ${label}: ${withoutLeadingLabel(issue.message, label)}` + }), ] if (kept.length > visible.length) lines.push(` … ${kept.length - visible.length} more issues`) return lines @@ -644,9 +658,25 @@ export async function requestAllPages( 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 [] + if (limit <= 0) return { items: [], truncated: false } const items: T[] = [] const progress = pageProgress() @@ -673,7 +703,7 @@ export async function requestAllPages( progress.finish() } - return items.slice(0, limit) + return { items: items.slice(0, limit), truncated: cursor !== null || items.length > limit } } /** diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts index b0a42c10148..d0f0bbaf72d 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -113,7 +113,12 @@ async function run(argv: string[], response: unknown = { data: [], nextCursor: n mockRequest.mockResolvedValue(response) vi.spyOn(console, 'log').mockImplementation(() => {}) await program().parseAsync(['node', 'sim', ...argv]) - return mockRequest.mock.calls[0] + // `--all-workspaces` asks `/api/v2/meta` whether the key can make an + // account-wide read before it makes one, so the operation's own call is not + // always the first. + const call = mockRequest.mock.calls.find(([path]) => path !== V2_OPERATIONS.getMeta.path) + if (!call) throw new Error('the command made no request of its own') + return call } describe('commands parsed through commander', () => { @@ -173,14 +178,12 @@ describe('commands parsed through commander', () => { .replace(/\s+/g, ' ') expect(flat('workflows', 'state', 'replace')).toContain( - 'Confirm this destructive operation (required unless --dry-run)' + 'Confirm this operation (required unless --dry-run)' ) expect(flat('workflows', 'operations', 'apply')).toContain( - 'Confirm this destructive operation (required unless --dry-run)' - ) - expect(flat('tables', 'rows', 'delete')).toContain( - 'Confirm this destructive operation (required)' + 'Confirm this operation (required unless --dry-run)' ) + expect(flat('tables', 'rows', 'delete')).toContain('Confirm this operation (required)') }) }) @@ -1676,8 +1679,92 @@ describe('bodies and fields the generator cannot flatten', () => { expect(options.body).toMatchObject({ rowIds: ['row_1', 'row_2'] }) expect(options.body).not.toHaveProperty('limit') }) + + /** + * The help says "0 is not accepted" and the CLI sent it anyway: `0`, `-1` + * and `1.5` all reached the wire to be refused by the route. + */ + it('refuses a row cap the help already documents as invalid', async () => { + for (const [value, message] of [ + ['0', '--limit must be 1 or more'], + ['-1', '--limit must be 1 or more'], + ['1.5', '--limit must be a whole number'], + // Above 2^52 the parse itself drops the fraction, so `Number.isInteger` + // alone would pass this and send a value the caller never typed. + ['4503599627370496.5', '--limit must be a whole number'], + ] as const) { + for (const command of ['batch-delete', 'batch-update'] as const) { + const argv = ['tables', 'rows', command, 'tbl_1', '--filter', '{"all":[]}'] + if (command === 'batch-update') argv.push('--data', '{"a":1}') + argv.push('--limit', value, '--yes') + await expect(run(argv)).rejects.toThrow(message) + } + } + expect(mockRequest).not.toHaveBeenCalled() + }) + + /** + * The route decides this with a refine whose message describes the opposite + * mistake when neither flag is typed — and half in wire names. + */ + it('requires exactly one of the two ways to choose the rows', async () => { + await expect(run(['tables', 'rows', 'batch-delete', 'tbl_1', '--yes'])).rejects.toThrow( + '--filter or --row is required to choose the rows to delete' + ) + await expect( + run([ + 'tables', + 'rows', + 'batch-delete', + 'tbl_1', + '--filter', + '{"all":[]}', + '--row', + 'row_1', + '--yes', + ]) + ).rejects.toThrow('--filter and --row choose the rows to delete two different ways') + expect(mockRequest).not.toHaveBeenCalled() + }) }) + /** + * `--limit` on a cursor-paginated operation is a client-side total, stripped + * from the request while the CLI walks the pages — so `--limit 0` reached the + * whole table with run state attached as many individually-legal pages, which + * is what the route's own `limit: 0` refusal exists to prevent. + */ + /** + * An `integer` field said so in the contract, and the refusal was left to the + * server — which answered in library wording naming neither the flag nor the + * value. + */ + it('refuses a fractional or unrepresentable value on an integer flag', async () => { + await expect(run(['files', 'read', 'file_1', '--max-bytes', '5.5'])).rejects.toThrow( + '--max-bytes must be a whole number' + ) + await expect( + run(['files', 'read', 'file_1', '--max-bytes', '999999999999999999999']) + ).rejects.toThrow('--max-bytes is outside the whole-number range the API accepts') + expect(mockRequest).not.toHaveBeenCalled() + }) + + /** + * `--workspace` is a root-program global, so commander accepts it everywhere + * while only an operation declaring `workspaceId` ever uses it. On the rest it + * was parsed and dropped, and three different values produced byte-identical + * requests. + */ + /** + * `activate create` is the deployed cutover addressed by version, the same + * production change `rollback` and `undeploy` both gate. `deploy` stays + * ungated: it publishes the draft as a NEW version, which is the forward + * action the caller asked for and which a rollback undoes. + */ + /** + * `--all-workspaces` reaches the wire as the absence of `workspaceId`, so a + * workspace key answered with its own workspace's figures and exit 0. + */ it('still gives paginated lists their numeric --limit', async () => { const [, options] = await run(['files', 'list', '--limit', '7']) expect(options.query).toMatchObject({ limit: 7 }) diff --git a/packages/sim-cli/src/runtime/execute.ts b/packages/sim-cli/src/runtime/execute.ts index d56bda32502..79be86a713d 100644 --- a/packages/sim-cli/src/runtime/execute.ts +++ b/packages/sim-cli/src/runtime/execute.ts @@ -181,6 +181,30 @@ const EXCLUSIVE_CAP_FIELDS: Readonly< deleteTableRows: { cap: 'limit', ids: 'rowIds' }, } +/** + * The pager's `--limit`, where `0` means "no ceiling". + * + * Read whole, not up to the first character that stops looking numeric. + * `parseInt` truncated before the guard could see what was typed, so + * `--limit 3.9` quietly fetched 3, `--limit 1e3` fetched 1, and `--limit -0.5` + * parsed as `-0` — which is not less than zero, so it slipped the guard and + * then read as the `0` that means everything. `Number` keeps the value intact + * so each of those is refused instead of reinterpreted, and it reads `0x10` and + * `1e3` as the caller wrote them. + * + * The empty string is refused explicitly because `Number('')` is `0`: without + * 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() + 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) + } + return value +} + /** Refuses a row cap typed alongside the explicit id list that supersedes it. */ function assertCapIsUsable(operation: V2OperationName, flags: Record): void { const exclusive = EXCLUSIVE_CAP_FIELDS[operation] @@ -195,6 +219,40 @@ function assertCapIsUsable(operation: V2OperationName, flags: Record + > +> = { + deleteTableRows: { fields: ['filter', 'rowIds'], noun: 'rows to delete' }, +} + +/** Refuses a selection that names neither of the two ways to make it, or both. */ +function assertSelectorIsUsable(operation: V2OperationName, flags: Record): void { + const selector = REQUIRED_SELECTORS[operation] + if (!selector) return + + const [first, second] = selector.fields.map((field) => flagNameFor(operation, field)) + const given = [first, second].filter((name) => flags[camel(name)] !== undefined) + if (given.length === 1) return + throw new SimApiError( + given.length === 0 + ? `--${first} or --${second} is required to choose the ${selector.noun}` + : `--${first} and --${second} choose the ${selector.noun} two different ways; pass one, not both`, + 0 + ) +} + /** * Moves a value supplied under a flag's former name onto its current one. * @@ -257,6 +315,7 @@ export async function executeOperation( foldRenamedFlags(operation, commandSpec, requestFlags) assertCapIsUsable(operation, requestFlags) + assertSelectorIsUsable(operation, requestFlags) /** * A dry run writes nothing, so it never needs the destructive confirmation. @@ -292,36 +351,23 @@ export async function executeOperation( */ const needsWorkspace = (hasWorkspaceField || commandSpec.profileWorkspacePath === true) && !omitsWorkspace + const paging = cursorSlot(operationSpec) + /** + * Checked before the request is built, because `buildRequest` also validates + * `limit` and would otherwise answer a paginated `--limit 1.5` with the + * generic integer refusal — losing the `0 for everything` this pager depends + * on the caller knowing. + */ + const pagedLimit = paging ? readPagedLimit(requestFlags.limit) : 0 const request = buildRequest( operation, positional, requestFlags, needsWorkspace ? client.requireWorkspace() : profile.workspaceId ) - const paging = cursorSlot(operationSpec) if (paging) { - /** - * Read whole, not up to the first character that stops looking numeric. - * - * `parseInt` truncated before the guard could see what was typed, so - * `--limit 3.9` quietly fetched 3, `--limit 1e3` fetched 1, and - * `--limit -0.5` parsed as `-0` — which is not less than zero, so it slipped - * the guard and then read as the `0` that means everything. `Number` keeps - * the value intact so each of those is refused instead of reinterpreted, - * and it reads `0x10` and `1e3` as the caller wrote them. - * - * The empty string is refused explicitly because `Number('')` is `0`, and - * `0` here means "no ceiling": without this, `--limit ''` would go from - * today's error to an unbounded walk of a shared workspace. - */ - const limitText = String(requestFlags.limit ?? DEFAULT_LIMIT).trim() - const rawLimit = limitText === '' ? Number.NaN : Number(limitText) - if (!Number.isInteger(rawLimit) || rawLimit < 0) { - throw new SimApiError('--limit must be a whole number of 0 or more (0 for everything)', 0) - } - - const limit = rawLimit === 0 ? Number.POSITIVE_INFINITY : rawLimit + 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[] = [] diff --git a/packages/sim-cli/src/runtime/naming.test.ts b/packages/sim-cli/src/runtime/naming.test.ts index 05ffb7ec988..d1f82d9f3e6 100644 --- a/packages/sim-cli/src/runtime/naming.test.ts +++ b/packages/sim-cli/src/runtime/naming.test.ts @@ -67,6 +67,38 @@ describe('a validation error restated in the spellings a caller can type', () => expect(retyped.message).not.toContain('--name') }) + /** + * Some routes name the field inside the message as well as in `path`, and the + * details column prints both: `--sort-by: --sort-by: only "startedAt" …`. + */ + it('does not print the field label twice on one detail line', () => { + const message = + 'sortBy: only "startedAt" can order job runs; drop includeJobRuns or sort by "startedAt"' + const line = detailLines('listLogs', [{ path: ['sortBy'], message }])[1] + + expect(line).toContain(' --sort-by: only "startedAt"') + expect(line).not.toContain('--sort-by: --sort-by:') + }) + + /** + * Only multi-segment camelCase is safely rewritable in prose, so a sentence + * enumerating both kinds came out half in flags and half in wire names: + * `At least one of name, description, or --folder is required`. + */ + it('never mixes the two vocabularies in one sentence', () => { + expect( + retype( + 'updateWorkflow', + new SimApiError('At least one of name, description, or folderPath is required', 400) + ).message + ).toBe('At least one of name, description, or folderPath is required') + + // A sentence with nothing ambiguous left in it still gets the translation. + expect( + retype('listWorkflows', new SimApiError('folderPath must be canonical', 400)).message + ).toBe('--folder must be canonical') + }) + it('names the global flag the workspace comes from', () => { expect( detailLines('listLogs', [{ path: ['workspaceId'], message: 'Workspace is required' }])[1] diff --git a/packages/sim-cli/src/runtime/naming.ts b/packages/sim-cli/src/runtime/naming.ts index b0683c0e132..22dcbd4b7bb 100644 --- a/packages/sim-cli/src/runtime/naming.ts +++ b/packages/sim-cli/src/runtime/naming.ts @@ -73,13 +73,39 @@ function typeableFields( return spellings } -/** Rewrites wire names a message quotes into the flags the caller typed. */ +/** + * Rewrites wire names a message quotes into the flags the caller typed. + * + * All or nothing. Only multi-segment camelCase is safely rewritable in prose — + * see {@link WIRE_IDENTIFIER} — so a sentence enumerating both kinds came out + * half in one vocabulary and half in the other: `At least one of name, + * description, or --folder is required` reads as three different things, one of + * which is a flag. When a single-word field of the same operation survives the + * pass, the whole message is left as the server wrote it: entirely in wire + * names, which is at least internally consistent and matches the REST + * reference the caller can look the names up in. + * + * The veto reads the message the server sent, not the rewritten one: a flag + * spelling contains its own field name, so re-scanning the output would let a + * successful translation veto itself. + * + * The cost is that a message mentioning `folderPath` and the English word + * `name` loses a translation it could have had. That is the deliberate trade — + * telling the two apart is the undecidable problem that produced the mixed + * sentence in the first place, and an unhelpful sentence beats a misleading one. + */ function retypeMessage(message: string, spellings: Map): string { let retyped = message for (const [field, spelling] of spellings) { if (!WIRE_IDENTIFIER.test(field)) continue retyped = retyped.replaceAll(new RegExp(`\\b${field}\\b`, 'g'), spelling) } + if (retyped === message) return message + + for (const field of spellings.keys()) { + if (WIRE_IDENTIFIER.test(field)) continue + if (new RegExp(`\\b${field}\\b`).test(message)) return message + } return retyped } diff --git a/packages/sim-cli/src/runtime/options.test.ts b/packages/sim-cli/src/runtime/options.test.ts index a4492463a2e..cf7f179f226 100644 --- a/packages/sim-cli/src/runtime/options.test.ts +++ b/packages/sim-cli/src/runtime/options.test.ts @@ -31,9 +31,11 @@ describe('the --yes flag on a destructive command', () => { */ it('describes itself as the confirmation, not as skipping one', () => { const help = confirmHelp() - expect(help).toMatch(/-y, --yes\s+Confirm this destructive operation \(required\)/) + expect(help).toMatch(/-y, --yes\s+Confirm this operation \(required\)/) expect(help).not.toMatch(/skip/i) expect(help).not.toMatch(/prompt/i) + // Nor "destructive": the same gate covers `files unzip`, which only adds. + expect(help).not.toMatch(/destructive/i) }) }) diff --git a/packages/sim-cli/src/runtime/options.ts b/packages/sim-cli/src/runtime/options.ts index 7c87b2c9733..dc8f48d2425 100644 --- a/packages/sim-cli/src/runtime/options.ts +++ b/packages/sim-cli/src/runtime/options.ts @@ -257,11 +257,15 @@ export function addOperationOptions( // outright would send a caller reaching for `--yes` to preview a change. const exemptedByDryRun = operationSpec.query?.dryRun !== undefined || operationSpec.body?.dryRun !== undefined + // Not "destructive": the gate also covers operations that only add — + // `files unzip` writes the archive's contents into the workspace and + // destroys nothing — so the adjective was wrong on the help line while the + // refusal itself, which states the operation's own consequence, was right. command.option( '-y, --yes', exemptedByDryRun - ? 'Confirm this destructive operation (required unless --dry-run)' - : 'Confirm this destructive operation (required)' + ? 'Confirm this operation (required unless --dry-run)' + : 'Confirm this operation (required)' ) } } diff --git a/packages/sim-cli/src/runtime/request.ts b/packages/sim-cli/src/runtime/request.ts index 3d1147a404d..04dba16d7f3 100644 --- a/packages/sim-cli/src/runtime/request.ts +++ b/packages/sim-cli/src/runtime/request.ts @@ -318,6 +318,17 @@ export function encodeFolderPath(value: string): string { .join('/') } +/** + * A fractional part `Number` cannot keep. + * + * Above 2^52 a double's spacing is 1, so `Number('4503599627370496.5')` is an + * integer — `Number.isInteger` passes and the API receives a value the caller + * did not type. Read the text as well as the parsed number so the refusal + * covers the range where the parse itself loses the fraction. Digits that are + * all zero are not a fraction, so `1.0` stays a whole number. + */ +const FRACTIONAL_DIGITS = /\.\d*[1-9]/ + /** * Points at `@` when a value that failed to parse looks like a filename. * @@ -381,6 +392,27 @@ export function coerce(raw: unknown, field: FieldSpec, flag: FlagSpec, flagName: if (NUMERIC_KINDS.has(field.kind)) { const value = Number(raw) if (Number.isNaN(value)) throw new SimApiError(`--${flagName} must be a number`, 0) + /** + * An `integer` field said so in the contract, and every other constraint on + * one is already refused here by hand. Leaving integrality to the server + * answered `--max-bytes 5.5` with `Invalid input: expected int, received + * number` and `--max-bytes 999999999999999999999` with `Too big: expected + * int to be <=9007199254740991` — library wording naming neither the flag + * nor anything the caller typed, on the one flag whose blank, zero and + * non-numeric cases all had a sentence written for them. + */ + if ( + field.kind === 'integer' && + (!Number.isInteger(value) || FRACTIONAL_DIGITS.test(String(raw))) + ) { + throw new SimApiError(`--${flagName} must be a whole number`, 0) + } + if (field.kind === 'integer' && !Number.isSafeInteger(value)) { + throw new SimApiError( + `--${flagName} is outside the whole-number range the API accepts (±${Number.MAX_SAFE_INTEGER})`, + 0 + ) + } return value } @@ -551,6 +583,23 @@ export function buildRequest( const value = coerce(raw ?? undefined, descriptor, flag, flagName) + /** + * A non-paginated `limit` is a row cap the route bounds at `1`, which is + * what `--help` already tells the caller ("note 0 is not accepted") — so + * `--limit 0`, `-1` and `1.5` were shipping a round trip to be told + * something the CLI had documented. The ceiling stays with the server: + * it is per-route policy, and nothing in the terminal states it. + */ + if ( + field === 'limit' && + !paginatedLimit && + NUMERIC_KINDS.has(descriptor.kind) && + typeof value === 'number' && + value < 1 + ) { + throw new SimApiError(`--${flagName} must be 1 or more`, 0) + } + if (value === undefined) { if (descriptor.required) { throw new SimApiError( diff --git a/packages/sim-cli/src/runtime/result.ts b/packages/sim-cli/src/runtime/result.ts index 1d24a88db81..4754931e91b 100644 --- a/packages/sim-cli/src/runtime/result.ts +++ b/packages/sim-cli/src/runtime/result.ts @@ -433,7 +433,7 @@ function writeEnvelopeTruncation(envelope: unknown): void { * 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. */ -function writeCursorTruncation(count: number, truncated: boolean): void { +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`)