Skip to content

Commit 9c3df54

Browse files
feat(cli): accept simple list inputs
1 parent 383f0f8 commit 9c3df54

7 files changed

Lines changed: 139 additions & 13 deletions

File tree

‎packages/sim-cli/README.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,8 @@ sim files list
133133
sim files create --name <name> [--content <value>] [--encoding utf-8|base64]
134134
sim files upload <path> [--name <name>] [--folder-id <id>]
135135
sim files download <fileId> [-o <path>]
136+
sim files move [--file-ids <id>…] [--folder-ids <id>…] [--target-folder-id <id>]
137+
sim files batch-archive [--file-ids <id>…] [--folder-ids <id>…] --yes
136138
sim files delete <fileId>
137139

138140
sim knowledge list
@@ -142,6 +144,20 @@ sim knowledge documents upload <id> <path> [--tag <value>...]
142144
sim knowledge search --query <text> --kb <id>… [--search-mode vector|hybrid]
143145
```
144146

147+
### List inputs
148+
149+
Primitive lists take space-separated values. Prefix a path with `@` to read
150+
one value per line, or use `@-` to read the list from stdin.
151+
152+
```bash
153+
sim files move --file-ids file_1 file_2 --target-folder-id folder_1
154+
sim files move --file-ids @file-ids.txt --target-folder-id folder_1
155+
printf 'file_1\nfile_2\n' | sim files move --file-ids @- --target-folder-id folder_1
156+
```
157+
158+
Arrays of objects remain JSON inputs because they cannot be represented as a
159+
flat list without losing structure.
160+
145161
### Filtering table rows
146162

147163
`--filter` takes the same predicate tree the API uses — `all` (AND) or `any`

‎packages/sim-cli/src/contract/commands.ts‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -285,11 +285,19 @@ export const CLI_CONTRACT: CliContract = {
285285
// `batch-` for the bulk form, matching `tables rows batch-delete`.
286286
command: 'files batch-archive',
287287
describe: 'Archive several files and folders at once',
288+
flags: {
289+
fileIds: { list: true },
290+
folderIds: { list: true },
291+
},
288292
confirm: 'This archives every listed file and folder, and everything inside those folders.',
289293
},
290294
moveFileItems: {
291295
command: 'files move',
292296
describe: 'Move files and folders into another folder',
297+
flags: {
298+
fileIds: { list: true },
299+
folderIds: { list: true },
300+
},
293301
},
294302
renameFile: {
295303
// Derived to `files update`, which contradicted its own summary.
@@ -314,13 +322,23 @@ export const CLI_CONTRACT: CliContract = {
314322
upsertFileShare: {
315323
command: 'files share set',
316324
describe: 'Enable or disable sharing for a file',
325+
flags: {
326+
allowedEmails: { list: true },
327+
},
317328
},
318329

319330
// ─── The expanded tables surface ──────────────────────────────────────────
320331
// `/cancel-runs`, `/rows/find`, `/restore`, `/columns/run` and the enrichment
321332
// path all put a verb where the deriver expects a sub-resource, so each became
322333
// a group holding a lone `create`.
323-
cancelTableRuns: { command: 'tables cancel-runs', describe: 'Stop every running column job' },
334+
cancelTableRuns: {
335+
command: 'tables cancel-runs',
336+
describe: 'Stop every running column job',
337+
flags: {
338+
excludeRowIds: { list: true },
339+
filter: { json: true, describe: TABLE_FILTER_HELP },
340+
},
341+
},
324342
findTableRows: {
325343
command: 'tables rows find',
326344
describe: 'Find rows matching a predicate',
@@ -336,7 +354,12 @@ export const CLI_CONTRACT: CliContract = {
336354
runTableColumn: {
337355
command: 'tables columns run',
338356
describe: 'Run a column’s workflow',
339-
flags: { filter: { json: true, describe: TABLE_FILTER_HELP } },
357+
flags: {
358+
groupIds: { list: true },
359+
rowIds: { list: true },
360+
excludeRowIds: { list: true },
361+
filter: { json: true, describe: TABLE_FILTER_HELP },
362+
},
340363
},
341364
runRowEnrichment: {
342365
command: 'tables rows enrich',

‎packages/sim-cli/src/contract/types.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ export interface FlagSpec {
3030
/** Short alias, e.g. `w` for `--workspace`. */
3131
short?: string
3232
/**
33-
* Accept the flag more than once.
33+
* Accept one or more space-separated values, or `@path` / `@-` with one
34+
* value per line.
3435
*
3536
* Only says that several values are allowed — how they reach the wire is
3637
* decided by the field's kind, not here. A `string` field is one the route

‎packages/sim-cli/src/runtime/build.test.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,27 @@ describe('commands parsed through commander', () => {
155155
})
156156
})
157157

158+
it('accepts space-separated file and folder ids', async () => {
159+
const [path, options] = await run([
160+
'file',
161+
'move',
162+
'--file-ids',
163+
'file_1',
164+
'file_2',
165+
'--folder-ids',
166+
'folder_1',
167+
'--target-folder-id',
168+
'folder_2',
169+
])
170+
expect(path).toBe('/api/v2/files/move')
171+
expect(options.body).toEqual({
172+
workspaceId: 'ws_local',
173+
fileIds: ['file_1', 'file_2'],
174+
folderIds: ['folder_1'],
175+
targetFolderId: 'folder_2',
176+
})
177+
})
178+
158179
it('exposes credential data centers added by the v2 credential contract', async () => {
159180
const [, options] = await run([
160181
'credential',
@@ -222,6 +243,12 @@ describe('commands parsed through commander', () => {
222243
expect(options.body).toMatchObject({ knowledgeBaseIds: ['kb_1'], searchMode: 'hybrid' })
223244
})
224245

246+
it('documents space-separated and file-backed lists', () => {
247+
const help = commandAt('files', 'move').helpInformation()
248+
expect(help).toContain('--file-ids <value...>')
249+
expect(help).toMatch(/space-separated.*@path.*one value per line/s)
250+
})
251+
225252
it('advertises the file-content encoding choices', () => {
226253
expect(commandAt('files', 'set-content').helpInformation()).toMatch(
227254
/--encoding.*utf-8.*base64/s

‎packages/sim-cli/src/runtime/options.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,13 @@ function addFieldOption(
5959
const choices = flag.choices ?? descriptor.values
6060
const describe = `${
6161
flag.describe ?? (choices ? `One of: ${choices.join(', ')}` : `Set ${field}`)
62-
}${wantsJson && !takesList ? ' (JSON, or @path / @- to read a file or stdin)' : ''}${
63-
descriptor.required ? ' (required)' : ''
64-
}`
62+
}${
63+
takesList
64+
? ' (space-separated, or @path / @- with one value per line)'
65+
: wantsJson
66+
? ' (JSON, or @path / @- to read a file or stdin)'
67+
: ''
68+
}${descriptor.required ? ' (required)' : ''}`
6569

6670
const option = new Option(`${short}--${name} ${placeholder}`, describe)
6771
if (choices && !takesList) option.choices([...choices])

‎packages/sim-cli/src/runtime/request.test.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,25 @@ describe('repeated flags encode per the field kind, not uniformly', () => {
142142
)
143143
expect(built.body?.knowledgeBaseIds).toEqual(['kb_1', 'kb_2'])
144144
})
145+
146+
it('reads one list value per line from @path', () => {
147+
const path = join(tmpdir(), 'sim-cli-list-values.txt')
148+
writeFileSync(path, 'file_1\nfile_2\n')
149+
expect(coerce(`@${path}`, { kind: 'array' }, { list: true }, 'file-ids')).toEqual([
150+
'file_1',
151+
'file_2',
152+
])
153+
rmSync(path)
154+
})
155+
156+
it('rejects empty lines in a list file', () => {
157+
const path = join(tmpdir(), 'sim-cli-list-empty-line.txt')
158+
writeFileSync(path, 'file_1\n\nfile_2')
159+
expect(() => coerce(`@${path}`, { kind: 'array' }, { list: true }, 'file-ids')).toThrow(
160+
/empty value on line 2/
161+
)
162+
rmSync(path)
163+
})
145164
})
146165

147166
describe('contract-provided choices', () => {

‎packages/sim-cli/src/runtime/request.ts‎

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -77,16 +77,16 @@ function readStdin(): string {
7777
}
7878

7979
/**
80-
* Resolves a JSON flag's argument, which may name a file instead of carrying
81-
* the document inline.
80+
* Resolves a flag argument that may name a file instead of carrying its value
81+
* inline.
8282
*
8383
* `@path` reads the file and `@-` reads stdin, the curl convention. A workflow
8484
* export is hundreds of lines, and the shell makes passing that literally
8585
* unpleasant — unquoted `$(cat f.json)` word-splits into broken JSON, and the
86-
* quoted form is easy to get wrong. `@` cannot collide with a real value
87-
* because JSON only ever starts with `{ [ " -`, a digit, or t/f/n.
86+
* quoted form is easy to get wrong. JSON never starts with `@`; primitive list
87+
* flags reserve it for this explicit file-input form.
8888
*/
89-
function readJsonArgument(raw: string, flagName: string): { text: string; from: string } {
89+
function readArgumentSource(raw: string, flagName: string): { text: string; from: string } {
9090
if (!raw.startsWith('@')) return { text: raw, from: '' }
9191

9292
const path = raw.slice(1)
@@ -108,6 +108,42 @@ function readJsonArgument(raw: string, flagName: string): { text: string; from:
108108
}
109109
}
110110

111+
/** Reads a primitive list from argv or a newline-delimited file. */
112+
function readListValues(raw: unknown, flagName: string): string[] {
113+
const arguments_ = Array.isArray(raw) ? raw : [raw]
114+
const values = arguments_.flatMap((argument) => {
115+
if (typeof argument !== 'string') {
116+
throw new SimApiError(`--${flagName} values must be strings`, 0)
117+
}
118+
119+
if (!argument.startsWith('@')) return [argument]
120+
121+
const source = readArgumentSource(argument, flagName)
122+
const lines = source.text.split(/\r?\n/)
123+
if (lines.at(-1) === '') lines.pop()
124+
if (lines.length === 0) {
125+
throw new SimApiError(`--${flagName}${source.from} contains no values`, 0)
126+
}
127+
128+
return lines.map((line, index) => {
129+
const value = line.trim()
130+
if (!value) {
131+
throw new SimApiError(
132+
`--${flagName}${source.from} has an empty value on line ${index + 1}`,
133+
0
134+
)
135+
}
136+
return value
137+
})
138+
})
139+
140+
return values.map((value) => {
141+
const trimmed = value.trim()
142+
if (!trimmed) throw new SimApiError(`--${flagName} values cannot be empty`, 0)
143+
return trimmed
144+
})
145+
}
146+
111147
/**
112148
* Points at `@` when a value that failed to parse looks like a filename.
113149
*
@@ -144,13 +180,13 @@ export function coerce(raw: unknown, field: FieldSpec, flag: FlagSpec, flagName:
144180
* or failed validation outright.
145181
*/
146182
if (flag.list) {
147-
const values = Array.isArray(raw) ? raw : [raw]
183+
const values = readListValues(raw, flagName)
148184
return field.kind === 'string' ? values.join(',') : values
149185
}
150186

151187
if (takesJson(field, flag)) {
152188
if (typeof raw !== 'string') return raw
153-
const source = readJsonArgument(raw, flagName)
189+
const source = readArgumentSource(raw, flagName)
154190
try {
155191
return JSON.parse(source.text)
156192
} catch (error) {

0 commit comments

Comments
 (0)