Skip to content

Commit 7918784

Browse files
feat(cli): JSON flags accept @file and @- alongside inline JSON
A workflow export is hundreds of lines, and `--workflow` only took it inline. The shell makes that miserable: unquoted `$(cat wf.json)` word-splits into broken JSON, and nothing in the help said passing a file was an option. Every JSON flag now reads `@path`, or `@-` for stdin, so the round trip is `sim workflows export <id> > wf.json` then `import --workflow @wf.json` — or one pipe. `@` cannot collide with a real value because JSON only ever starts with `{ [ " -`, a digit, or t/f/n. Stdin drains with a readSync loop rather than readFileSync(0): a pipe is opened non-blocking, so the single-read form returned EAGAIN and died with a raw stack trace exactly when the upstream process had not written yet. Parse failures that look like a filename now say so — naming @path, or the file itself when the bare value turns out to exist. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0148a65fkPhP4N8tgGPYtRTU
1 parent 676bd83 commit 7918784

4 files changed

Lines changed: 148 additions & 6 deletions

File tree

packages/sim-cli/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ Examples:
5555
$ sim logs list --level error --limit 20
5656
$ sim configure --set-output json Output format is a profile setting
5757
$ sim knowledge search "refund policy" --kb kb_123
58+
$ sim workflows export wf_123 > wf.json JSON flags read files with @
59+
$ sim workflows import --workflow @wf.json
5860
$ sim whoami --profile dev
5961
`
6062
)

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

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -190,10 +190,14 @@ function addFieldOption(
190190
}
191191

192192
const takesList = flag.list === true
193-
const placeholder = takesList ? `<value...>` : takesJson(descriptor, flag) ? `<json>` : `<value>`
193+
const wantsJson = takesJson(descriptor, flag)
194+
const placeholder = takesList ? `<value...>` : wantsJson ? `<json|@file>` : `<value>`
194195
const describe =
195-
flag.describe ??
196-
(descriptor.values ? `One of: ${descriptor.values.join(', ')}` : `Set ${field}`)
196+
(flag.describe ??
197+
(descriptor.values ? `One of: ${descriptor.values.join(', ')}` : `Set ${field}`)) +
198+
// Otherwise the only way to discover `@file` is to read the source. A JSON
199+
// document big enough to want a file is exactly when help gets consulted.
200+
(wantsJson ? ' (JSON, or @path / @- to read a file or stdin)' : '')
197201

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

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

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
import { rmSync, writeFileSync } from 'node:fs'
2+
import { tmpdir } from 'node:os'
3+
import { join } from 'node:path'
14
import { describe, expect, it } from 'vitest'
25
import { SimApiError } from '../http/client.js'
36
import { deriveCommandPath } from './derive.js'
4-
import { buildRequest } from './request.js'
7+
import { buildRequest, coerce, type FieldSpec } from './request.js'
58

69
const WORKSPACE = 'ws_local'
710

@@ -140,3 +143,48 @@ describe('repeated flags encode per the field kind, not uniformly', () => {
140143
expect(built.body?.knowledgeBaseIds).toEqual(['kb_1', 'kb_2'])
141144
})
142145
})
146+
147+
describe('JSON flags that name a file', () => {
148+
const field: FieldSpec = { kind: 'object' }
149+
150+
it('reads @path', () => {
151+
const path = join(tmpdir(), 'sim-cli-arg.json')
152+
writeFileSync(path, '{"version":"1.0","state":{"blocks":{}}}')
153+
expect(coerce(`@${path}`, field, {}, 'workflow')).toEqual({
154+
version: '1.0',
155+
state: { blocks: {} },
156+
})
157+
rmSync(path)
158+
})
159+
160+
it('still accepts inline JSON', () => {
161+
expect(coerce('{"a":1}', field, {}, 'workflow')).toEqual({ a: 1 })
162+
})
163+
164+
it('names the file it could not read', () => {
165+
expect(() => coerce('@/nope/missing.json', field, {}, 'workflow')).toThrow(
166+
/cannot read \/nope\/missing\.json/
167+
)
168+
})
169+
170+
it('says which file the bad JSON came from', () => {
171+
const path = join(tmpdir(), 'sim-cli-bad.json')
172+
writeFileSync(path, 'not json')
173+
expect(() => coerce(`@${path}`, field, {}, 'workflow')).toThrow(/read from .*sim-cli-bad\.json/)
174+
rmSync(path)
175+
})
176+
177+
it('points at @ when a bare filename was passed instead', () => {
178+
// `--workflow export.json` is the natural first guess; "must be valid JSON"
179+
// alone never reveals that passing a file is supported at all.
180+
const path = join(tmpdir(), 'sim-cli-bare.json')
181+
writeFileSync(path, '{}')
182+
expect(() => coerce(path, field, {}, 'workflow')).toThrow(new RegExp(`pass it as @${path}`))
183+
rmSync(path)
184+
expect(() => coerce('export.json', field, {}, 'workflow')).toThrow(/pass @path/)
185+
})
186+
187+
it('does not suggest a path for malformed inline JSON', () => {
188+
expect(() => coerce('{"a":', field, {}, 'workflow')).not.toThrow(/@path/)
189+
})
190+
})

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

Lines changed: 90 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { existsSync, readFileSync, readSync } from 'node:fs'
12
import { CLI_CONTRACT } from '../contract/commands.js'
23
import type { FlagSpec } from '../contract/types.js'
34
import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api.js'
@@ -37,6 +38,89 @@ export function takesJson(field: FieldSpec, flag: FlagSpec): boolean {
3738
return flag.json === true || JSON_KINDS.has(field.kind)
3839
}
3940

41+
/**
42+
* Drains stdin synchronously.
43+
*
44+
* `readFileSync(0)` looks like the obvious way to do this and fails on the one
45+
* case that matters: a pipe is opened non-blocking, so a single read of an
46+
* upstream process that has not written yet returns EAGAIN rather than waiting,
47+
* and `export … | import --workflow @-` died with a raw stack trace. Reading in
48+
* a loop and treating EAGAIN as "not ready yet" is what makes a pipe work.
49+
*
50+
* `Atomics.wait` is the only synchronous sleep available; without it the retry
51+
* spins a core for as long as the writer takes.
52+
*/
53+
function readStdin(): string {
54+
const idle = new Int32Array(new SharedArrayBuffer(4))
55+
const buffer = Buffer.alloc(64 * 1024)
56+
const chunks: Buffer[] = []
57+
58+
for (;;) {
59+
let read: number
60+
try {
61+
read = readSync(0, buffer, 0, buffer.length, null)
62+
} catch (error) {
63+
const code = (error as NodeJS.ErrnoException).code
64+
if (code === 'EAGAIN') {
65+
Atomics.wait(idle, 0, 0, 5)
66+
continue
67+
}
68+
// Some platforms report end-of-input on a pipe as EOF rather than 0.
69+
if (code === 'EOF') break
70+
throw error
71+
}
72+
if (read === 0) break
73+
chunks.push(Buffer.from(buffer.subarray(0, read)))
74+
}
75+
76+
return Buffer.concat(chunks).toString('utf8')
77+
}
78+
79+
/**
80+
* Resolves a JSON flag's argument, which may name a file instead of carrying
81+
* the document inline.
82+
*
83+
* `@path` reads the file and `@-` reads stdin, the curl convention. A workflow
84+
* export is hundreds of lines, and the shell makes passing that literally
85+
* 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.
88+
*/
89+
function readJsonArgument(raw: string, flagName: string): { text: string; from: string } {
90+
if (!raw.startsWith('@')) return { text: raw, from: '' }
91+
92+
const path = raw.slice(1)
93+
if (path === '-') {
94+
if (process.stdin.isTTY) {
95+
throw new SimApiError(`--${flagName} @- reads stdin, but nothing is piped in`, 0)
96+
}
97+
try {
98+
return { text: readStdin(), from: ' (read from stdin)' }
99+
} catch (error) {
100+
throw new SimApiError(`--${flagName} cannot read stdin: ${(error as Error).message}`, 0)
101+
}
102+
}
103+
104+
try {
105+
return { text: readFileSync(path, 'utf8'), from: ` (read from ${path})` }
106+
} catch (error) {
107+
throw new SimApiError(`--${flagName} cannot read ${path}: ${(error as Error).message}`, 0)
108+
}
109+
}
110+
111+
/**
112+
* Points at `@` when a value that failed to parse looks like a filename.
113+
*
114+
* `--workflow export.json` is the natural first guess, and "must be valid JSON"
115+
* alone gives no clue that passing a file is even supported.
116+
*/
117+
function pathHint(raw: string): string {
118+
if (raw.startsWith('@') || /^\s*[[{"\-\d]|^\s*(true|false|null)/.test(raw)) return ''
119+
return existsSync(raw)
120+
? `. ${raw} is a file — pass it as @${raw}`
121+
: '. To read a file, pass @path (or @- for stdin)'
122+
}
123+
40124
/**
41125
* Turns the string argv provides into the value the contract expects.
42126
*
@@ -66,10 +150,14 @@ export function coerce(raw: unknown, field: FieldSpec, flag: FlagSpec, flagName:
66150

67151
if (takesJson(field, flag)) {
68152
if (typeof raw !== 'string') return raw
153+
const source = readJsonArgument(raw, flagName)
69154
try {
70-
return JSON.parse(raw)
155+
return JSON.parse(source.text)
71156
} catch (error) {
72-
throw new SimApiError(`--${flagName} must be valid JSON: ${(error as Error).message}`, 0)
157+
throw new SimApiError(
158+
`--${flagName} must be valid JSON${source.from}: ${(error as Error).message}${pathHint(raw)}`,
159+
0
160+
)
73161
}
74162
}
75163

0 commit comments

Comments
 (0)