Skip to content

Commit da611cb

Browse files
feat(cli): streamline common resource workflows
1 parent 0b3451f commit da611cb

26 files changed

Lines changed: 495 additions & 247 deletions

‎packages/sim-cli/README.md‎

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
Talk to the [Sim](https://sim.ai) API from your terminal.
44

55
```bash
6-
npm install -g @sim/cli
6+
bun add --global @sim/cli
77
sim login
88
sim workflows list
99
```
@@ -53,7 +53,7 @@ Each setting resolves independently, first match wins:
5353

5454
| Rank | Source |
5555
| --- | --- |
56-
| 1 | Command-line flag (`--endpoint`, `--workspace`) |
56+
| 1 | Command-line flag (`--endpoint`, `--workspace`, `--output`) |
5757
| 2 | Environment (`SIM_ENDPOINT`, `SIM_API_KEY`, `SIM_WORKSPACE`, `SIM_OUTPUT`) |
5858
| 3 | `~/.sim/config` / `~/.sim/credentials` for the selected profile |
5959
| 4 | Built-in default (`https://sim.ai`, `table`) |
@@ -112,12 +112,15 @@ also accepts its singular form: for example, `sim table list`,
112112
`sim file download`, and `sim workflow get` are equivalent to their plural
113113
spellings.
114114

115+
`knowledge` also accepts the shorter `kb` alias.
116+
115117
```bash
116118
sim workflows ls [path] [--search <text>] [--limit <n>]
117119
sim workflows list [--folder <path>] [--deployed-only] [--limit <n>]
118120
sim workflows get <id>
119121
sim workflows mv <id> --folder <path>
120122
sim workflows deploy|undeploy|rollback <id>
123+
sim workflows run <id> [--input <json|@file>] [--select-output <path>…]
121124

122125
sim logs list [--level error] [--workflow <id>…] [--trigger <name>…] [--start <date>]
123126
sim logs get <id>
@@ -129,7 +132,10 @@ sim tables get <tableId>
129132
sim tables mv <tableId> --folder <path>
130133
sim tables columns <tableId>
131134
sim tables rows list <tableId> [--limit <n>]
135+
sim tables rows create <tableId> --data <json|@file>
136+
sim tables rows create <tableId> --rows <json|@file>
132137
sim tables rows query <tableId> [--filter <json>] [--sort <json>] [--limit <n>]
138+
sim tables rows query <tableId> --filter '{"all":[{"field":"status","op":"eq","value":"active"}]}'
133139
sim tables upsert <tableId> --data <json>
134140
sim tables rows batch-delete <tableId> (--row <id>… | --filter <json>) --yes
135141

@@ -138,10 +144,10 @@ sim files list [--folder <path>]
138144
sim files get <fileId>
139145
sim files create --name <name> [--folder <path>] [--content <value>] [--encoding utf-8|base64]
140146
sim files upload <path> [--name <name>] [--folder <path>]
141-
sim files download <fileId> [-o <path>]
147+
sim files download <fileId> [-o <path|->]
142148
sim files mv --file-ids <id>… [--to <path>]
143149
sim files batch-delete --file-ids <id>… --yes
144-
sim files delete <fileId>
150+
sim files delete <fileId> --yes
145151

146152
sim knowledge ls [path] [--search <text>] [--limit <n>]
147153
sim knowledge list [--folder <path>]
@@ -150,8 +156,14 @@ sim knowledge mv <id> --folder <path>
150156
sim knowledge documents <id> [--search <text>]
151157
sim knowledge documents upload <id> <path> [--tag <value>...]
152158
sim knowledge search --query <text> --kb <id>… [--search-mode vector|hybrid]
159+
160+
sim billing
161+
sim billing logs [--period 7d] [--limit <n>]
153162
```
154163

164+
Workflow output selectors use `blockName.field` syntax, such as
165+
`--select-output agent_1.content`; fields that are not produced are omitted.
166+
155167
`ls` is a directory view: it combines the resources at its optional path with
156168
that folder's direct child folders. It never includes deeper descendants. Its
157169
`ref` column is the resource ID or canonical folder path to pass to the next
@@ -166,14 +178,13 @@ sim tables folders ls --parent Reports
166178
sim tables mkdir Reports/Quarterly
167179
sim tables folders create Reports/Quarterly
168180
sim tables folders mv Reports/Quarterly Archive/Quarterly
169-
sim tables folders delete Archive/Quarterly --recursive false --yes
181+
sim tables folders delete Archive/Quarterly --yes
182+
sim tables folders delete Archive --recursive --yes
170183
```
171184

172185
`mkdir` is the concise form of `folders create`. Replace `tables` with `files`,
173-
`workflows`, or `knowledge`. The leading `/` is optional on CLI inputs; the CLI
174-
adds it before calling the API. Omit the `ls` path to list root. A slash that
175-
belongs to a folder name is percent-encoded as `%2F` rather than treated as a
176-
separator.
186+
`workflows`, or `knowledge`. The leading `/` is optional on API inputs; the API
187+
returns the canonical leading-slash form. Omit the `ls` path to list root.
177188

178189
### List inputs
179190

@@ -210,9 +221,9 @@ everything" default.
210221

211222
### Output formats
212223

213-
Output format is a **profile setting**, not a per-command flag — there is no
214-
`--output`. Set it once with `sim configure --set-output <format>`, or override
215-
ambiently with `SIM_OUTPUT` for a one-off or for CI:
224+
Output format can be selected per command with `--output`, saved as a profile
225+
default with `sim configure --set-output <format>`, or set ambiently with
226+
`SIM_OUTPUT` for CI:
216227

217228
| Format | For |
218229
| --- | --- |
@@ -230,7 +241,8 @@ parsing.
230241
sim configure --set-output json # for this profile, from now on
231242
sim configure --set-output text --profile scripts # a profile dedicated to scripting
232243

233-
SIM_OUTPUT=json sim logs list --level error | jq -r '.[].executionId'
244+
sim --output json logs list --level error | jq -r '.[].executionId'
245+
sim logs list --level error --output json | jq -r '.[].executionId'
234246
SIM_OUTPUT=yaml sim logs list --level error > logs.yaml
235247

236248
SIM_OUTPUT=text sim files list | while IFS=$'\t' read -r id name size type uploaded; do
@@ -241,9 +253,9 @@ done
241253
An absent value is an em-dash in `table` and an **empty field** in `text`, so
242254
emptiness tests downstream behave.
243255

244-
A bad `SIM_OUTPUT` or `output =` is ignored and falls back to `table`. Both are
245-
ambient — set once, then read by every later command — so one bad value should
246-
not break the CLI outright.
256+
An invalid active `SIM_OUTPUT` or `output =` value fails with the accepted
257+
formats. A valid higher-priority `--output` still overrides a stale lower tier,
258+
so `sim --output table configure --set-output json` can repair a profile.
247259

248260
## How this stays in sync with the API
249261

‎packages/sim-cli/src/commands/protocol/files-download.test.ts‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,35 @@ describe('files download', () => {
102102
target,
103103
])
104104

105-
expect(JSON.parse(logged[0])).toEqual({ id: 'file_1', path: target, status: 'saved' })
105+
expect(JSON.parse(logged[0])).toEqual({
106+
id: 'file_1',
107+
path: target,
108+
status: 'saved',
109+
})
110+
})
111+
112+
it('streams raw bytes to stdout with the conventional - destination', async () => {
113+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('downloaded', { status: 200 })))
114+
const chunks: Uint8Array[] = []
115+
vi.spyOn(process.stdout, 'write').mockImplementation((chunk: string | Uint8Array) => {
116+
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk)
117+
return true
118+
})
119+
const logged = vi.spyOn(console, 'log').mockImplementation(() => {})
120+
121+
await program().parseAsync(['node', 'sim', 'file', 'download', 'file_1', '-o', '-'])
122+
123+
expect(Buffer.concat(chunks).toString('utf8')).toBe('downloaded')
124+
expect(logged).not.toHaveBeenCalled()
125+
})
126+
127+
it('rejects overwrite semantics for stdout', async () => {
128+
const fetch = vi.fn()
129+
vi.stubGlobal('fetch', fetch)
130+
131+
await expect(
132+
program().parseAsync(['node', 'sim', 'file', 'download', 'file_1', '-o', '-', '--force'])
133+
).rejects.toThrow(/--force cannot be used/)
134+
expect(fetch).not.toHaveBeenCalled()
106135
})
107136
})

‎packages/sim-cli/src/commands/protocol/files-download.ts‎

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,18 +47,39 @@ export async function streamToFile(
4747
}
4848
}
4949

50+
/** Streams a fetch body to stdout without closing the process-wide stream. */
51+
export async function streamToStdout(
52+
body: ReadableStream<Uint8Array>,
53+
output: NodeJS.WriteStream = process.stdout
54+
): Promise<void> {
55+
const reader = body.getReader()
56+
try {
57+
while (true) {
58+
const { done, value } = await reader.read()
59+
if (done) return
60+
if (!output.write(value)) await once(output, 'drain')
61+
}
62+
} finally {
63+
reader.releaseLock()
64+
}
65+
}
66+
5067
export function attachFileDownload(files: Command): void {
5168
files
5269
.command('download <fileId>')
5370
.description('Download a file')
54-
.option('-o, --output-file <path>', 'Where to write it (defaults to the file name)')
71+
.option('-o, --output-file <path>', 'Where to write it (default: file name; -: stdout)')
5572
.option('--force', 'Overwrite the destination if it already exists')
5673
.action(
5774
async (
5875
fileId: string,
5976
options: { outputFile?: string; force?: boolean },
6077
command: Command
6178
) => {
79+
if (options.outputFile === '-' && options.force) {
80+
throw new SimApiError('--force cannot be used when --output-file is -', 0)
81+
}
82+
6283
const { client, profile } = clientFrom(command)
6384
const workspaceId = client.requireWorkspace()
6485

@@ -70,7 +91,9 @@ export function attachFileDownload(files: Command): void {
7091
url.searchParams.set('workspaceId', workspaceId)
7192

7293
// boundary-raw-fetch: binary download cannot pass through the JSON client
73-
const response = await fetch(url, { headers: { 'x-api-key': profile.apiKey } })
94+
const response = await fetch(url, {
95+
headers: { 'x-api-key': profile.apiKey },
96+
})
7497
if (!response.ok || !response.body) {
7598
const raw = await response.text().catch(() => '')
7699
throw new SimApiError(
@@ -79,6 +102,11 @@ export function attachFileDownload(files: Command): void {
79102
)
80103
}
81104

105+
if (options.outputFile === '-') {
106+
await streamToStdout(response.body)
107+
return
108+
}
109+
82110
const target =
83111
options.outputFile ??
84112
basename(
@@ -90,7 +118,11 @@ export function attachFileDownload(files: Command): void {
90118
response.body,
91119
createWriteStream(target, { flags: options.force ? 'w' : 'wx' })
92120
)
93-
printProtocolResult(profile.output, { id: fileId, path: target, status: 'saved' })
121+
printProtocolResult(profile.output, {
122+
id: fileId,
123+
path: target,
124+
status: 'saved',
125+
})
94126
}
95127
)
96128
}

‎packages/sim-cli/src/commands/protocol/files-upload.test.ts‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ function program(): Command {
4444
}
4545

4646
describe('files upload', () => {
47-
it('uses a signed PUT transfer and completes with an empty body', async () => {
47+
it('uses a signed PUT transfer and completes without a request body', async () => {
4848
const path = join(dir, 'notes.txt')
4949
writeFileSync(path, 'hello')
5050
mockRequest
@@ -114,7 +114,7 @@ describe('files upload', () => {
114114
name: 'notes.txt',
115115
contentType: 'text/plain',
116116
size: 5,
117-
folderPath: '/Reports',
117+
folderPath: 'Reports',
118118
},
119119
},
120120
])
@@ -124,14 +124,18 @@ describe('files upload', () => {
124124
method: 'POST',
125125
query: { workspaceId: 'ws_local' },
126126
headers: { 'upload-token': 'secret-token' },
127-
body: {},
128127
},
129128
])
130129
expect(JSON.parse(logged[0])).toEqual({
131130
id: 'file_1',
132131
name: 'notes.txt',
133132
size: 5,
134-
status: 'uploaded',
133+
type: 'text/plain',
134+
key: 'workspace/ws_local/notes.txt',
135+
folderPath: '/',
136+
uploadedBy: 'user_1',
137+
uploadedAt: '2026-08-04T19:00:00.000Z',
138+
updatedAt: '2026-08-04T19:00:00.000Z',
135139
})
136140
expect(logged[0]).not.toContain('secret-token')
137141
})

‎packages/sim-cli/src/commands/protocol/files-upload.ts‎

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import type {
44
CompleteFileUploadResponse,
55
CreateFileUploadResponse,
66
} from '../../generated/v2-api.js'
7-
import { normalizeFolderPath } from '../../runtime/folder-path.js'
87
import { contentTypeFor, localFile } from '../../transfer/local-file.js'
98
import { finishUploadSession } from '../../transfer/upload-session.js'
109
import { printProtocolResult } from './result.js'
@@ -13,7 +12,7 @@ export function attachFileUpload(files: Command): void {
1312
files
1413
.command('upload <path>')
1514
.description('Upload a file to the workspace')
16-
.option('--folder <path>', 'Canonical destination folder path (defaults to /)')
15+
.option('--folder <path>', 'Destination folder path (defaults to /)')
1716
.option('--name <name>', 'Store it under a different name')
1817
.action(async (path: string, options: { folder?: string; name?: string }, command: Command) => {
1918
const { client, profile } = clientFrom(command)
@@ -27,9 +26,7 @@ export function attachFileUpload(files: Command): void {
2726
name,
2827
contentType: contentTypeFor(name),
2928
size,
30-
...(options.folder !== undefined
31-
? { folderPath: normalizeFolderPath(options.folder) }
32-
: {}),
29+
...(options.folder !== undefined ? { folderPath: options.folder } : {}),
3330
},
3431
})
3532
const { session, uploadToken, transfer } = created.data
@@ -45,11 +42,9 @@ export function attachFileUpload(files: Command): void {
4542
path
4643
)
4744

48-
printProtocolResult(profile.output, {
49-
id: completed.file?.id ?? session.id,
50-
name,
51-
size,
52-
status: 'uploaded',
53-
})
45+
if (!completed.file) {
46+
throw new Error(`File upload ${session.id} completed without a file`)
47+
}
48+
printProtocolResult(profile.output, completed.file)
5449
})
5550
}

‎packages/sim-cli/src/commands/protocol/knowledge-document-upload.test.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,8 +166,10 @@ describe('knowledge documents upload', () => {
166166
expect(mockRequest.mock.calls[2][0]).toBe(
167167
'/api/v2/knowledge/kb_1/documents/uploads/upload_1/complete'
168168
)
169-
expect(mockRequest.mock.calls[2][1].body).toEqual({
170-
parts: [{ partNumber: 1, etag: 'etag-1' }],
169+
expect(mockRequest.mock.calls[2][1]).toEqual({
170+
method: 'POST',
171+
query: { workspaceId: 'ws_local' },
172+
headers: { 'upload-token': 'secret-token' },
171173
})
172174
expect(JSON.parse(logged[0])).toEqual({
173175
id: 'doc_1',

‎packages/sim-cli/src/commands/protocol/resource-directory.test.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ describe('resource directory', () => {
8787
expect(mockRequest).toHaveBeenCalledWith('/api/v2/tables/folders', {
8888
query: {
8989
workspaceId: 'ws_local',
90-
parentPath: '/Reports',
90+
parentPath: 'Reports',
9191
search: 'r',
9292
sortBy: 'name',
9393
sortOrder: 'asc',
@@ -96,7 +96,7 @@ describe('resource directory', () => {
9696
expect(mockRequest).toHaveBeenCalledWith('/api/v2/tables', {
9797
query: {
9898
workspaceId: 'ws_local',
99-
folderPath: '/Reports',
99+
folderPath: 'Reports',
100100
search: 'r',
101101
sortBy: 'name',
102102
sortOrder: 'asc',
@@ -140,7 +140,7 @@ describe('resource directory', () => {
140140

141141
expect(mockRequest).toHaveBeenCalledWith('/api/v2/tables/folders', {
142142
method: 'POST',
143-
body: { workspaceId: 'ws_local', path: '/Reports/Quarterly' },
143+
body: { workspaceId: 'ws_local', path: 'Reports/Quarterly' },
144144
})
145145
})
146146

‎packages/sim-cli/src/commands/protocol/resource-directory.ts‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import {
1414
} from '../../generated/v2-api.js'
1515
import { SimApiError, type SimClient, type V2Page } from '../../http/client.js'
1616
import { type Column, printList, text, timestamp } from '../../output/render.js'
17-
import { normalizeFolderPath } from '../../runtime/folder-path.js'
1817
import { DEFAULT_LIMIT } from '../../runtime/options.js'
1918
import { renderResult } from '../../runtime/result.js'
2019

@@ -179,7 +178,7 @@ export function attachResourceDirectoryCommands(
179178
}
180179

181180
const limit = rawLimit === 0 ? Number.POSITIVE_INFINITY : rawLimit
182-
const folderPath = normalizeFolderPath(path ?? '/')
181+
const folderPath = path ?? '/'
183182
const { client, profile } = clientFrom(command)
184183
const workspaceId = client.requireWorkspace()
185184
const [folders, resources] = await Promise.all([
@@ -193,13 +192,13 @@ export function attachResourceDirectoryCommands(
193192
group
194193
.command('mkdir <path>')
195194
.allowExcessArguments(false)
196-
.description(`Create a ${config.kind} directory at a canonical path`)
195+
.description(`Create a ${config.kind} directory at a path`)
197196
.action(async (path: string, _options: Record<string, never>, command: Command) => {
198197
const { client, profile } = clientFrom(command)
199198
const operation = V2_OPERATIONS[config.createFolder]
200199
const result = await client.request<{ data?: unknown }>(operation.path, {
201200
method: operation.method,
202-
body: { workspaceId: client.requireWorkspace(), path: normalizeFolderPath(path) },
201+
body: { workspaceId: client.requireWorkspace(), path },
203202
})
204203
renderResult(config.createFolder, profile.output, result.data ?? result, {})
205204
})

0 commit comments

Comments
 (0)