fix(cli): refuse what the help already says is invalid (#7157)

* fix(cli): refuse what the help already says is invalid

- validate integer fields against the generated spec's own `kind`, so
  `--limit 1.5` names the flag and the value instead of surfacing zod's
  `expected int, received number`
- enforce `--limit` >= 1 on the two non-paginated row mutations, which the
  help already promised and the server already required
- refuse a blank `-c/--conversation`: it is falsy, so it was dropped from
  the body and silently started a new conversation instead of continuing one
- refuse a blank `chat` message before the request rather than after
- constrain `--recipe` to the recipes the generated body type declares, so
  regenerating the surface breaks the build if they diverge
- announce truncation on `ls` the way `list` already does; the capped answer
  was printed silently
- stop a successful message translation from vetoing itself: the veto now
  reads the message the server sent, not the rewritten one, which restores
  `--folder must name an existing folder` and unblocks 100+ operations
- drop "destructive" from the `--yes` help line; the gate also covers
  operations that only add, such as `files unzip`
- fix the `logs follow` help, which illustrated `--workflow` with a file id

* fix(cli): refuse a fraction the integer parse silently drops

Above 2^52 a double's spacing is 1, so Number('4503599627370496.5') is an
integer and the safe-integer guard passed it — the API received a value the
caller never typed. Read the raw text alongside the parsed number. Digits
that are all zero are not a fraction, so 1.0 stays a whole number.

Also corrects a chat test comment that described a UUID check the command
does not perform; it refuses only a blank -c.

* fix(cli): redact the value the root-flag refusal suggests

The refusal prints a command for the caller to run, and interpolated the
value verbatim. A U+2028 in it split the terminal line, so the tail rendered
as a second, plausible-looking suggestion. redact() is what the other twenty
messages in this package already use, including one forty lines below.
This commit is contained in:
Waleed
2026-08-26 22:34:39 -07:00
committed by GitHub
parent 286b30e155
commit e906190bae
34 changed files with 638 additions and 162 deletions
+1 -1
View File
@@ -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**
@@ -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. |
</CommandTable>
@@ -49,7 +49,7 @@ sim custom-tools delete <customToolId> [options]
| Option | Required | Description |
| --- | --- | --- |
| `-y, --yes` | Yes | Confirm this destructive operation. |
| `-y, --yes` | Yes | Confirm this operation. |
</CommandTable>
+4 -4
View File
@@ -22,7 +22,7 @@ sim files batch-delete [options]
| Option | Required | Description |
| --- | --- | --- |
| `--file-ids <value...>` | 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. |
</CommandTable>
@@ -85,7 +85,7 @@ sim files folders delete <path> [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. |
</CommandTable>
@@ -168,7 +168,7 @@ sim files delete <fileId> [options]
| Option | Required | Description |
| --- | --- | --- |
| `-y, --yes` | Yes | Confirm this destructive operation. |
| `-y, --yes` | Yes | Confirm this operation. |
</CommandTable>
@@ -377,7 +377,7 @@ sim files unzip <fileId> [options]
| Option | Required | Description |
| --- | --- | --- |
| `-y, --yes` | Yes | Confirm this destructive operation. |
| `-y, --yes` | Yes | Confirm this operation. |
</CommandTable>
+11 -11
View File
@@ -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. |
</CommandTable>
@@ -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. |
</CommandTable>
@@ -273,7 +273,7 @@ Enable, disable, or delete many chunks at once (personal API key required)
| --- | --- | --- |
| `--operation <value>` | Yes | What to do with the selected chunks. Accepted values: `enable`, `disable`, `delete`. |
| `--chunk <value...>` | 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. |
</CommandTable>
@@ -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. |
</CommandTable>
@@ -477,7 +477,7 @@ sim knowledge documents delete <knowledgeBaseId> <documentId> [options]
| Option | Required | Description |
| --- | --- | --- |
| `-y, --yes` | Yes | Confirm this destructive operation. |
| `-y, --yes` | Yes | Confirm this operation. |
</CommandTable>
@@ -606,8 +606,8 @@ sim knowledge documents upload <knowledgeBaseId> <path> [options]
| --- | --- | --- |
| `--name <name>` | No | Store it under a different name. |
| `--tag <value...>` | No | Document tags, in tag1 through tag7 order. |
| `--recipe <name>` | No | Document processing recipe. |
| `--lang <code>` | No | Document language code. |
| `--recipe <name>` | No | Document processing recipe. Accepted values: `default`, `plain`, `markdown`, `code`. |
| `--lang <code>` | No | Document language tag: hyphen-separated letter and digit subtags, for example en or en-US. |
</CommandTable>
@@ -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. |
</CommandTable>
@@ -903,11 +903,11 @@ sim knowledge folders delete <path> [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. |
</CommandTable>
## List folders
## List knowledge folders
```bash
sim knowledge folders list [options]
@@ -969,7 +969,7 @@ sim knowledge delete <knowledgeBaseId> [options]
| Option | Required | Description |
| --- | --- | --- |
| `-y, --yes` | Yes | Confirm this destructive operation. |
| `-y, --yes` | Yes | Confirm this operation. |
</CommandTable>
+1 -1
View File
@@ -35,7 +35,7 @@ sim logs get <runId> [options]
</CommandTable>
## Summarize run counts, failures, and cost over a window
## Summarize run counts, failures and latency over a window
```bash
sim logs stats [options]
@@ -58,7 +58,7 @@ sim mcp-servers delete <mcpServerId> [options]
| Option | Required | Description |
| --- | --- | --- |
| `-y, --yes` | Yes | Confirm this destructive operation. |
| `-y, --yes` | Yes | Confirm this operation. |
</CommandTable>
+51 -51
View File
@@ -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 <credentialId> [options]
| Option | Required | Description |
| --- | --- | --- |
| `-y, --yes` | Yes | Confirm this destructive operation. |
| `-y, --yes` | Yes | Confirm this operation. |
</CommandTable>
@@ -602,7 +602,7 @@ sim custom-tools delete <customToolId> [options]
| Option | Required | Description |
| --- | --- | --- |
| `-y, --yes` | Yes | Confirm this destructive operation. |
| `-y, --yes` | Yes | Confirm this operation. |
</CommandTable>
@@ -694,7 +694,7 @@ sim files batch-delete [options]
| Option | Required | Description |
| --- | --- | --- |
| `--file-ids <value...>` | 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. |
</CommandTable>
@@ -763,13 +763,13 @@ sim files folders delete <path> [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. |
</CommandTable>
### sim files folders list
List Folders
List folders
```bash
sim files folders list [options]
@@ -854,7 +854,7 @@ sim files delete <fileId> [options]
| Option | Required | Description |
| --- | --- | --- |
| `-y, --yes` | Yes | Confirm this destructive operation. |
| `-y, --yes` | Yes | Confirm this operation. |
</CommandTable>
@@ -1079,7 +1079,7 @@ sim files unzip <fileId> [options]
| Option | Required | Description |
| --- | --- | --- |
| `-y, --yes` | Yes | Confirm this destructive operation. |
| `-y, --yes` | Yes | Confirm this operation. |
</CommandTable>
@@ -1332,7 +1332,7 @@ sim knowledge tags delete <knowledgeBaseId> <tagId> [options]
| Option | Required | Description |
| --- | --- | --- |
| `-y, --yes` | Yes | Confirm this destructive operation. |
| `-y, --yes` | Yes | Confirm this operation. |
</CommandTable>
@@ -1362,7 +1362,7 @@ sim knowledge tags cleanup <knowledgeBaseId> [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. |
</CommandTable>
@@ -1487,7 +1487,7 @@ sim knowledge chunks batch-update <knowledgeBaseId> <documentId> [options]
| --- | --- | --- |
| `--operation <value>` | Yes | What to do with the selected chunks. Accepted values: `enable`, `disable`, `delete`. |
| `--chunk <value...>` | 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. |
</CommandTable>
@@ -1548,7 +1548,7 @@ sim knowledge chunks delete <knowledgeBaseId> <documentId> <chunkId> [options]
| Option | Required | Description |
| --- | --- | --- |
| `-y, --yes` | Yes | Confirm this destructive operation. |
| `-y, --yes` | Yes | Confirm this operation. |
</CommandTable>
@@ -1693,7 +1693,7 @@ sim knowledge documents delete <knowledgeBaseId> <documentId> [options]
| Option | Required | Description |
| --- | --- | --- |
| `-y, --yes` | Yes | Confirm this destructive operation. |
| `-y, --yes` | Yes | Confirm this operation. |
</CommandTable>
@@ -1828,8 +1828,8 @@ sim knowledge documents upload <knowledgeBaseId> <path> [options]
| --- | --- | --- |
| `--name <name>` | No | Store it under a different name. |
| `--tag <value...>` | No | Document tags, in tag1 through tag7 order. |
| `--recipe <name>` | No | Document processing recipe. |
| `--lang <code>` | No | Document language code. |
| `--recipe <name>` | No | Document processing recipe. Accepted values: `default`, `plain`, `markdown`, `code`. |
| `--lang <code>` | No | Document language tag: hyphen-separated letter and digit subtags, for example en or en-US. |
</CommandTable>
@@ -1913,7 +1913,7 @@ sim knowledge connectors delete <knowledgeBaseId> <connectorId> [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. |
</CommandTable>
@@ -2131,13 +2131,13 @@ sim knowledge folders delete <path> [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. |
</CommandTable>
### sim knowledge folders list
List Folders
List knowledge folders
```bash
sim knowledge folders list [options]
@@ -2203,7 +2203,7 @@ sim knowledge delete <knowledgeBaseId> [options]
| Option | Required | Description |
| --- | --- | --- |
| `-y, --yes` | Yes | Confirm this destructive operation. |
| `-y, --yes` | Yes | Confirm this operation. |
</CommandTable>
@@ -2423,7 +2423,7 @@ sim logs get <runId> [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 <mcpServerId> [options]
| Option | Required | Description |
| --- | --- | --- |
| `-y, --yes` | Yes | Confirm this destructive operation. |
| `-y, --yes` | Yes | Confirm this operation. |
</CommandTable>
@@ -2715,7 +2715,7 @@ sim secrets delete <name> [options]
| Option | Required | Description |
| --- | --- | --- |
| `--scope <value>` | 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. |
</CommandTable>
@@ -2821,7 +2821,7 @@ sim skills delete <skillId> [options]
| Option | Required | Description |
| --- | --- | --- |
| `-y, --yes` | Yes | Confirm this destructive operation. |
| `-y, --yes` | Yes | Confirm this operation. |
</CommandTable>
@@ -2926,7 +2926,7 @@ sim skills editors delete <skillId> [options]
| Option | Required | Description |
| --- | --- | --- |
| `--email <value>` | Yes | Email address of a current workspace member. |
| `-y, --yes` | Yes | Confirm this destructive operation. |
| `-y, --yes` | Yes | Confirm this operation. |
</CommandTable>
@@ -3038,7 +3038,7 @@ sim tables columns delete <tableId> [options]
| Option | Required | Description |
| --- | --- | --- |
| `--column-name <value>` | Yes | Name of the column to delete. |
| `-y, --yes` | Yes | Confirm this destructive operation. |
| `-y, --yes` | Yes | Confirm this operation. |
</CommandTable>
@@ -3127,7 +3127,7 @@ sim tables groups delete <tableId> [options]
| Option | Required | Description |
| --- | --- | --- |
| `--group-id <value>` | Yes | Workflow group to delete. |
| `-y, --yes` | Yes | Confirm this destructive operation. |
| `-y, --yes` | Yes | Confirm this operation. |
</CommandTable>
@@ -3204,7 +3204,7 @@ sim tables batch-delete [options]
| --- | --- | --- |
| `--table-ids <json\|@file>` | No | Tables to archive, by identifier. (JSON, or @path / @- to read a file or stdin). |
| `--folder <value...>` | 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. |
</CommandTable>
@@ -3290,7 +3290,7 @@ sim tables rows delete <tableId> <rowId> [options]
| Option | Required | Description |
| --- | --- | --- |
| `-y, --yes` | Yes | Confirm this destructive operation. |
| `-y, --yes` | Yes | Confirm this operation. |
</CommandTable>
@@ -3321,7 +3321,7 @@ sim tables rows batch-delete <tableId> [options]
| `--filter <json\|@file>` | No | Predicate: &#123;"all":[&#123;"field":"status","op":"eq","value":"active"&#125;]&#125;; 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 <value>` | 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 <value...>` | 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. |
</CommandTable>
@@ -3522,7 +3522,7 @@ sim tables rows batch-update <tableId> [options]
| `--filter <json\|@file>` | Yes | Predicate: &#123;"all":[&#123;"field":"status","op":"eq","value":"active"&#125;]&#125;; 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 <json\|@file>` | Yes | Row-data patch applied to every matching row. (JSON, or @path / @- to read a file or stdin). |
| `--limit <value>` | 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. |
</CommandTable>
@@ -3580,7 +3580,7 @@ sim tables dispatches cancel <tableId> <dispatchId> [options]
| Option | Required | Description |
| --- | --- | --- |
| `-y, --yes` | Yes | Confirm this destructive operation. |
| `-y, --yes` | Yes | Confirm this operation. |
</CommandTable>
@@ -3763,7 +3763,7 @@ sim tables imports cancel <importId> [options]
| Option | Required | Description |
| --- | --- | --- |
| `-y, --yes` | Yes | Confirm this destructive operation. |
| `-y, --yes` | Yes | Confirm this operation. |
</CommandTable>
@@ -3813,7 +3813,7 @@ sim tables cancel-runs <tableId> [options]
| `--row-id <value>` | No | Row whose runs should be canceled for row scope. |
| `--filter <json\|@file>` | No | Predicate: &#123;"all":[&#123;"field":"status","op":"eq","value":"active"&#125;]&#125;; 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 <value...>` | 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. |
</CommandTable>
@@ -3881,13 +3881,13 @@ sim tables folders delete <path> [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. |
</CommandTable>
### sim tables folders list
List Folders
List table folders
```bash
sim tables folders list [options]
@@ -4001,7 +4001,7 @@ sim tables views delete <tableId> <viewId> [options]
| Option | Required | Description |
| --- | --- | --- |
| `-y, --yes` | Yes | Confirm this destructive operation. |
| `-y, --yes` | Yes | Confirm this operation. |
</CommandTable>
@@ -4099,7 +4099,7 @@ sim tables delete <tableId> [options]
| Option | Required | Description |
| --- | --- | --- |
| `-y, --yes` | Yes | Confirm this destructive operation. |
| `-y, --yes` | Yes | Confirm this operation. |
</CommandTable>
@@ -4455,7 +4455,7 @@ sim workflow-mcp-servers delete <serverId> [options]
| Option | Required | Description |
| --- | --- | --- |
| `-y, --yes` | Yes | Confirm this destructive operation. |
| `-y, --yes` | Yes | Confirm this operation. |
</CommandTable>
@@ -4533,7 +4533,7 @@ sim workflow-mcp-servers tools delete <serverId> <workflowId> [options]
| Option | Required | Description |
| --- | --- | --- |
| `-y, --yes` | Yes | Confirm this destructive operation. |
| `-y, --yes` | Yes | Confirm this operation. |
</CommandTable>
@@ -4655,12 +4655,12 @@ sim workflows operations apply <workflowId> [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 <json\|@file>` | Yes | Edits to apply, in a single batch, keyed by operation_type: [&#123;"operation_type":"add","block_id":"my-fn","params":&#123;"type":"function","name":"My Fn","inputs":&#123;"code":"return &#123;ok:true&#125;"&#125;&#125;&#125;,&#123;"operation_type":"edit","block_id":"&lt;uuid&gt;","params":&#123;"name":"Renamed","connections":&#123;"success":"my-fn"&#125;&#125;&#125;,&#123;"operation_type":"delete","block_id":"&lt;uuid&gt;"&#125;]. Also insert_into_subflow and extract_from_subflow, whose params carry &#123;"subflowId":"&lt;loop-id&gt;"&#125; (JSON, or @path / @- to read a file or stdin). |
| `--operations <json\|@file>` | Yes | Edits to apply, in a single batch, keyed by operation_type: [&#123;"operation_type":"add","block_id":"my-fn","params":&#123;"type":"function","name":"My Fn","inputs":&#123;"code":"return &#123;ok:true&#125;"&#125;&#125;&#125;,&#123;"operation_type":"edit","block_id":"&lt;uuid&gt;","params":&#123;"name":"Renamed","connections":&#123;"success":"my-fn"&#125;&#125;&#125;,&#123;"operation_type":"delete","block_id":"&lt;uuid&gt;"&#125;]. Also extract_from_subflow, whose params carry &#123;"subflowId":"&lt;loop-id&gt;"&#125;, and insert_into_subflow, which creates a block and so takes an adds 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 <value>` | 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 <json\|@file>` | No | Blocks to enable or disable, applied after --operations: [&#123;"block_id":"&lt;uuid&gt;","enabled":false&#125;]. 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). |
</CommandTable>
@@ -4689,7 +4689,7 @@ sim workflows variables update <workflowId> [options]
| Option | Required | Description |
| --- | --- | --- |
| `--operations <json\|@file>` | Yes | Variable changes to apply in order, keyed by operation: [&#123;"operation":"add","name":"my_var","type":"string","value":"hello"&#125;,&#123;"operation":"edit","name":"my_var","value":"updated"&#125;,&#123;"operation":"delete","name":"my_var"&#125;] (JSON, or @path / @- to read a file or stdin). |
| `-y, --yes` | Yes | Confirm this destructive operation. |
| `-y, --yes` | Yes | Confirm this operation. |
</CommandTable>
@@ -4900,13 +4900,13 @@ sim workflows folders delete <path> [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. |
</CommandTable>
### sim workflows folders list
List Workflow Folders
List workflow folders
```bash
sim workflows folders list [options]
@@ -4972,7 +4972,7 @@ sim workflows delete <workflowId> [options]
| Option | Required | Description |
| --- | --- | --- |
| `-y, --yes` | Yes | Confirm this destructive operation. |
| `-y, --yes` | Yes | Confirm this operation. |
</CommandTable>
@@ -5000,7 +5000,7 @@ sim workflows chat unpublish <workflowId> [options]
| Option | Required | Description |
| --- | --- | --- |
| `-y, --yes` | Yes | Confirm this destructive operation. |
| `-y, --yes` | Yes | Confirm this operation. |
</CommandTable>
@@ -5058,7 +5058,7 @@ sim workflows chat publish <workflowId> [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. |
</CommandTable>
@@ -5294,7 +5294,7 @@ sim workflows state replace <workflowId> [options]
| `--loops <json\|@file>` | No | Ignored on write: loop containers are recomputed from `blocks`. (JSON, or @path / @- to read a file or stdin). |
| `--parallels <json\|@file>` | No | Ignored on write: parallel containers are recomputed from `blocks`. (JSON, or @path / @- to read a file or stdin). |
| `--variables <json\|@file>` | 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). |
</CommandTable>
@@ -5483,7 +5483,7 @@ sim workflows revert create <workflowId> <version> [options]
| Option | Required | Description |
| --- | --- | --- |
| `-y, --yes` | Yes | Confirm this destructive operation. |
| `-y, --yes` | Yes | Confirm this operation. |
</CommandTable>
@@ -5512,7 +5512,7 @@ sim workflows rollback <workflowId> [options]
| Option | Required | Description |
| --- | --- | --- |
| `--to-version <value>` | 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. |
</CommandTable>
@@ -5540,7 +5540,7 @@ sim workflows undeploy <workflowId> [options]
| Option | Required | Description |
| --- | --- | --- |
| `-y, --yes` | Yes | Confirm this destructive operation. |
| `-y, --yes` | Yes | Confirm this operation. |
</CommandTable>
+1 -1
View File
@@ -34,7 +34,7 @@ Delete Secret (personal API key required)
| Option | Required | Description |
| --- | --- | --- |
| `--scope <value>` | 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. |
</CommandTable>
+2 -2
View File
@@ -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. |
</CommandTable>
@@ -154,7 +154,7 @@ Revoke Skill Editor (personal API key required)
| Option | Required | Description |
| --- | --- | --- |
| `--email <value>` | Yes | Email address of a current workspace member. |
| `-y, --yes` | Yes | Confirm this destructive operation. |
| `-y, --yes` | Yes | Confirm this operation. |
</CommandTable>
+13 -13
View File
@@ -58,7 +58,7 @@ sim tables columns delete <tableId> [options]
| Option | Required | Description |
| --- | --- | --- |
| `--column-name <value>` | Yes | Name of the column to delete. |
| `-y, --yes` | Yes | Confirm this destructive operation. |
| `-y, --yes` | Yes | Confirm this operation. |
</CommandTable>
@@ -141,7 +141,7 @@ sim tables groups delete <tableId> [options]
| Option | Required | Description |
| --- | --- | --- |
| `--group-id <value>` | Yes | Workflow group to delete. |
| `-y, --yes` | Yes | Confirm this destructive operation. |
| `-y, --yes` | Yes | Confirm this operation. |
</CommandTable>
@@ -212,7 +212,7 @@ sim tables batch-delete [options]
| --- | --- | --- |
| `--table-ids <json\|@file>` | No | Tables to archive, by identifier. (JSON, or @path / @- to read a file or stdin). |
| `--folder <value...>` | 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. |
</CommandTable>
@@ -292,7 +292,7 @@ sim tables rows delete <tableId> <rowId> [options]
| Option | Required | Description |
| --- | --- | --- |
| `-y, --yes` | Yes | Confirm this destructive operation. |
| `-y, --yes` | Yes | Confirm this operation. |
</CommandTable>
@@ -321,7 +321,7 @@ sim tables rows batch-delete <tableId> [options]
| `--filter <json\|@file>` | No | Predicate: &#123;"all":[&#123;"field":"status","op":"eq","value":"active"&#125;]&#125;; 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 <value>` | 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 <value...>` | 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. |
</CommandTable>
@@ -508,7 +508,7 @@ sim tables rows batch-update <tableId> [options]
| `--filter <json\|@file>` | Yes | Predicate: &#123;"all":[&#123;"field":"status","op":"eq","value":"active"&#125;]&#125;; 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 <json\|@file>` | Yes | Row-data patch applied to every matching row. (JSON, or @path / @- to read a file or stdin). |
| `--limit <value>` | 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. |
</CommandTable>
@@ -562,7 +562,7 @@ sim tables dispatches cancel <tableId> <dispatchId> [options]
| Option | Required | Description |
| --- | --- | --- |
| `-y, --yes` | Yes | Confirm this destructive operation. |
| `-y, --yes` | Yes | Confirm this operation. |
</CommandTable>
@@ -729,7 +729,7 @@ sim tables imports cancel <importId> [options]
| Option | Required | Description |
| --- | --- | --- |
| `-y, --yes` | Yes | Confirm this destructive operation. |
| `-y, --yes` | Yes | Confirm this operation. |
</CommandTable>
@@ -775,7 +775,7 @@ sim tables cancel-runs <tableId> [options]
| `--row-id <value>` | No | Row whose runs should be canceled for row scope. |
| `--filter <json\|@file>` | No | Predicate: &#123;"all":[&#123;"field":"status","op":"eq","value":"active"&#125;]&#125;; 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 <value...>` | 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. |
</CommandTable>
@@ -837,11 +837,11 @@ sim tables folders delete <path> [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. |
</CommandTable>
## List folders
## List table folders
```bash
sim tables folders list [options]
@@ -947,7 +947,7 @@ sim tables views delete <tableId> <viewId> [options]
| Option | Required | Description |
| --- | --- | --- |
| `-y, --yes` | Yes | Confirm this destructive operation. |
| `-y, --yes` | Yes | Confirm this operation. |
</CommandTable>
@@ -1037,7 +1037,7 @@ sim tables delete <tableId> [options]
| Option | Required | Description |
| --- | --- | --- |
| `-y, --yes` | Yes | Confirm this destructive operation. |
| `-y, --yes` | Yes | Confirm this operation. |
</CommandTable>
@@ -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. |
</CommandTable>
@@ -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. |
</CommandTable>
+11 -11
View File
@@ -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 <json\|@file>` | Yes | Edits to apply, in a single batch, keyed by operation_type: [&#123;"operation_type":"add","block_id":"my-fn","params":&#123;"type":"function","name":"My Fn","inputs":&#123;"code":"return &#123;ok:true&#125;"&#125;&#125;&#125;,&#123;"operation_type":"edit","block_id":"&lt;uuid&gt;","params":&#123;"name":"Renamed","connections":&#123;"success":"my-fn"&#125;&#125;&#125;,&#123;"operation_type":"delete","block_id":"&lt;uuid&gt;"&#125;]. Also insert_into_subflow and extract_from_subflow, whose params carry &#123;"subflowId":"&lt;loop-id&gt;"&#125; (JSON, or @path / @- to read a file or stdin). |
| `--operations <json\|@file>` | Yes | Edits to apply, in a single batch, keyed by operation_type: [&#123;"operation_type":"add","block_id":"my-fn","params":&#123;"type":"function","name":"My Fn","inputs":&#123;"code":"return &#123;ok:true&#125;"&#125;&#125;&#125;,&#123;"operation_type":"edit","block_id":"&lt;uuid&gt;","params":&#123;"name":"Renamed","connections":&#123;"success":"my-fn"&#125;&#125;&#125;,&#123;"operation_type":"delete","block_id":"&lt;uuid&gt;"&#125;]. Also extract_from_subflow, whose params carry &#123;"subflowId":"&lt;loop-id&gt;"&#125;, and insert_into_subflow, which creates a block and so takes an adds 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 <value>` | 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 <json\|@file>` | No | Blocks to enable or disable, applied after --operations: [&#123;"block_id":"&lt;uuid&gt;","enabled":false&#125;]. 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). |
</CommandTable>
@@ -86,7 +86,7 @@ sim workflows variables update <workflowId> [options]
| Option | Required | Description |
| --- | --- | --- |
| `--operations <json\|@file>` | Yes | Variable changes to apply in order, keyed by operation: [&#123;"operation":"add","name":"my_var","type":"string","value":"hello"&#125;,&#123;"operation":"edit","name":"my_var","value":"updated"&#125;,&#123;"operation":"delete","name":"my_var"&#125;] (JSON, or @path / @- to read a file or stdin). |
| `-y, --yes` | Yes | Confirm this destructive operation. |
| `-y, --yes` | Yes | Confirm this operation. |
</CommandTable>
@@ -285,7 +285,7 @@ sim workflows folders delete <path> [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. |
</CommandTable>
@@ -351,7 +351,7 @@ sim workflows delete <workflowId> [options]
| Option | Required | Description |
| --- | --- | --- |
| `-y, --yes` | Yes | Confirm this destructive operation. |
| `-y, --yes` | Yes | Confirm this operation. |
</CommandTable>
@@ -379,7 +379,7 @@ Take a workflows chat deployment offline (personal API key required)
| Option | Required | Description |
| --- | --- | --- |
| `-y, --yes` | Yes | Confirm this destructive operation. |
| `-y, --yes` | Yes | Confirm this operation. |
</CommandTable>
@@ -437,7 +437,7 @@ Publish or replace a workflows 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. |
</CommandTable>
@@ -661,7 +661,7 @@ Replace Workflow State (personal API key required)
| `--loops <json\|@file>` | No | Ignored on write: loop containers are recomputed from `blocks`. (JSON, or @path / @- to read a file or stdin). |
| `--parallels <json\|@file>` | No | Ignored on write: parallel containers are recomputed from `blocks`. (JSON, or @path / @- to read a file or stdin). |
| `--variables <json\|@file>` | 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). |
</CommandTable>
@@ -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. |
</CommandTable>
@@ -865,7 +865,7 @@ Rollback Workflow (personal API key required)
| Option | Required | Description |
| --- | --- | --- |
| `--to-version <value>` | 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. |
</CommandTable>
@@ -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. |
</CommandTable>
@@ -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')
+1 -1
View File
@@ -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
)
}
@@ -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('<message> 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' },
})
@@ -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 `<message>` 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('<message> 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()
@@ -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')
@@ -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<string,
return metadata
}
/**
* The recipes the route accepts. Stated as `choices` like every other
* constrained flag in this CLI, so `--help` lists them and a typo is refused
* before the file is uploaded rather than after.
*/
type UploadRecipe = NonNullable<
NonNullable<CreateKnowledgeDocumentUploadBody['processingOptions']>['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 <name>', 'Store it under a different name')
.option('--tag <value...>', 'Document tags, in tag1 through tag7 order')
.option('--recipe <name>', 'Document processing recipe')
.option('--lang <code>', 'Document language code')
.addOption(new Option('--recipe <name>', 'Document processing recipe').choices(UPLOAD_RECIPES))
.option('--lang <code>', LANGUAGE_TAG_HELP)
.action(
async (
knowledgeBaseId: string,
@@ -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}-/)
})
})
@@ -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'
`
)
@@ -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'])
@@ -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<DirectoryResource[]> {
): 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<V2Page<DirectoryResource>>(path, { query })
return page.data.slice(0, limit)
return { items: page.data.slice(0, limit), truncated: page.data.length > limit }
}
return requestAllPages<DirectoryResource>(client, path, {
return requestPages<DirectoryResource>(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
+14 -3
View File
@@ -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')
})
})
+19 -4
View File
@@ -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":"<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>"}'
'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 adds params plus that subflowId'
const WORKFLOW_SET_BLOCK_ENABLED_HELP =
'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'
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' },
+35 -5
View File
@@ -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<T>(
path: string,
options: RequestAllPagesOptions
): Promise<T[]> {
return (await requestPages<T>(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<T>(
client: Pick<SimClient, 'request'>,
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<T>(
progress.finish()
}
return items.slice(0, limit)
return { items: items.slice(0, limit), truncated: cursor !== null || items.length > limit }
}
/**
+93 -6
View File
@@ -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 })
+68 -22
View File
@@ -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<string, unknown>): void {
const exclusive = EXCLUSIVE_CAP_FIELDS[operation]
@@ -195,6 +219,40 @@ function assertCapIsUsable(operation: V2OperationName, flags: Record<string, unk
)
}
/**
* Operations that select their targets through exactly one of two flags.
*
* `tables rows batch-delete` left the choice to the route, whose refusal
* `Provide either filter or rowIds, but not both` describes the wrong mistake
* when neither was typed, and describes it half in wire names. Its sibling
* `tables rows batch-update` already refuses locally, because its `filter` is
* `required` in the contract; stating this one here puts the requirement in the
* same place for both.
*/
const REQUIRED_SELECTORS: Readonly<
Partial<
Record<V2OperationName, { readonly fields: readonly [string, string]; readonly noun: string }>
>
> = {
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<string, unknown>): 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[] = []
@@ -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]
+27 -1
View File
@@ -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, string>): 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
}
+3 -1
View File
@@ -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)
})
})
+6 -2
View File
@@ -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)'
)
}
}
+49
View File
@@ -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(
+1 -1
View File
@@ -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`)