Skip to content

Commit 9172e44

Browse files
TheodoreSpeaksclaude
authored andcommitted
feat(cli): yaml and text output formats
`--output` now takes table | json | yaml | text, settable per-command, via SIM_OUTPUT, or persisted per profile as before. `yaml` joins `json` in rendering the API's raw values rather than the table's formatted cells, so a duration stays `1500` instead of becoming `"1.5s"` — switching format changes the encoding, never the data. Line folding is disabled: valid YAML, but it breaks line-oriented greps and is miserable to read. `text` is tab-separated with no header and no colour — the shape `cut -f2` and `while IFS=$'\t' read` expect, so shell plumbing works on a box with no JSON tool. It uses the rendered cells rather than raw values, since it is a human-ish format for pipelines rather than something to parse. An absent value collapses to an empty field instead of the table's em-dash: `cut` returning a literal `—` would read as a value to every downstream emptiness test. A bad `--output` is now an error (commander `.choices`) rather than a silent fall back to `table`. The environment variable and the config file stay tolerant — those are ambient and set once, so a bad value should not break every command, but a flag just typed should not be quietly disregarded. Uses js-yaml 4.3.0, already a direct dependency of apps/sim, rather than adding a second YAML library to the monorepo. Also drops a stale README reference to check:openapi-drift, which the v2-endpoints merge superseded with the deeper check:openapi. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EsThUPqZXjwuuyRjBbmVkj
1 parent 342dd14 commit 9172e44

7 files changed

Lines changed: 192 additions & 25 deletions

File tree

packages/sim-cli/README.md

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ Each setting resolves independently, first match wins:
5858
| 3 | `~/.sim/config` / `~/.sim/credentials` for the selected profile |
5959
| 4 | Built-in default (`https://sim.ai`, `table`) |
6060

61+
Formats are listed under [Output formats](#output-formats).
62+
6163
`sim whoami` prints the winning source per setting, which is usually the fastest
6264
way to explain a surprising result.
6365

@@ -150,13 +152,38 @@ page so a sparse row doesn't hide a column.
150152
Deletions require an explicit selector *and* `--yes`; there is no "delete
151153
everything" default.
152154

153-
Every command takes `--output json` for scripting; the JSON is the API's own
154-
response shape, so it pipes cleanly into `jq`.
155+
### Output formats
156+
157+
`--output` / `-o`, or `SIM_OUTPUT`, or `output =` in the profile:
158+
159+
| Format | For |
160+
| --- | --- |
161+
| `table` | reading (default) |
162+
| `json` | piping into `jq` |
163+
| `yaml` | piping into anything that reads YAML |
164+
| `text` | shell loops — tab-separated, no header, no colour |
165+
166+
`json` and `yaml` emit the API's **raw** values, not the table's formatting — a
167+
duration stays `1500`, not `"1.5s"` — so switching format never changes the data.
168+
`text` uses the rendered cells, since it is meant for shell plumbing rather than
169+
parsing.
155170

156171
```bash
157-
sim logs list --level error --output json | jq -r '.[].executionId'
172+
sim logs list --level error -o json | jq -r '.[].executionId'
173+
sim logs list --level error -o yaml > logs.yaml
174+
175+
sim files list -o text | while IFS=$'\t' read -r id name size type uploaded; do
176+
echo "$id $name"
177+
done
158178
```
159179

180+
An absent value is an em-dash in `table` and an **empty field** in `text`, so
181+
emptiness tests downstream behave.
182+
183+
A bad `--output` is an error; a bad `SIM_OUTPUT` or `output =` is ignored and
184+
falls back to `table` — ambient settings should not brick every command, but a
185+
flag you just typed should not be silently disregarded.
186+
160187
## How this stays in sync with the API
161188

162189
`src/generated/v2-api.ts` is generated from the Zod route contracts in
@@ -166,21 +193,21 @@ It holds every response/request type plus the operation table (method, path,
166193
path params) the client dispatches through.
167194

168195
```bash
169-
bun run generate:cli-api # regenerate after changing a contract
170-
bun run check:cli-api # CI: fails if the generated file is stale
171-
bun run check:openapi-drift # CI: fails if the docs and contracts disagree
196+
bun run generate:cli-api # regenerate after changing a contract
197+
bun run check:cli-api # CI: fails if the generated file is stale
198+
bun run check:openapi # CI: fails if the docs and contracts disagree
172199
```
173200

174201
The generated file contains only type declarations and one const — no imports —
175202
so the `packages/*` must not import `apps/*` boundary is preserved; the script
176203
does the crossing at build time.
177204

178205
The OpenAPI documents under `apps/docs` are deliberately **not** generated. They
179-
carry ~1000 hand-written descriptions and ~400 examples that Zod schemas don't
180-
encode, so regenerating them would trade real documentation for mechanical
181-
accuracy. `check:openapi-drift` reconciles their *structure* against the
182-
contracts instead — every v2 path and method must exist on both sides — so the
183-
prose survives while drift still fails the build.
206+
carry hand-written descriptions, examples, and error responses that Zod schemas
207+
don't encode, so regenerating them would trade real documentation for mechanical
208+
accuracy. `check:openapi` reconciles them against the same contracts instead —
209+
field by field, and it parses every documented example with the real Zod schema —
210+
so the prose survives while drift still fails the build.
184211

185212
## Notes
186213

packages/sim-cli/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,12 @@
3333
},
3434
"dependencies": {
3535
"chalk": "5.6.2",
36-
"commander": "^11.1.0"
36+
"commander": "^11.1.0",
37+
"js-yaml": "4.3.0"
3738
},
3839
"devDependencies": {
3940
"@sim/tsconfig": "workspace:*",
41+
"@types/js-yaml": "4.0.9",
4042
"@types/node": "24.2.1",
4143
"typescript": "^7.0.2",
4244
"vitest": "^3.2.4"

packages/sim-cli/src/config/profile.test.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { configPath, credentialsPath } from './paths.js'
66
import {
77
deleteProfile,
88
listProfiles,
9+
OUTPUT_FORMATS,
910
resolveProfile,
1011
writeConfigProfile,
1112
writeCredentialsProfile,
@@ -96,10 +97,19 @@ describe('profile resolution', () => {
9697
})
9798

9899
it('ignores an unrecognized output format instead of failing the whole resolve', () => {
99-
process.env.SIM_OUTPUT = 'yaml'
100+
// Ambient sources tolerate garbage so one bad value cannot brick every
101+
// command; the `--output` flag is strict instead (commander `.choices`).
102+
process.env.SIM_OUTPUT = 'xml'
100103
expect(resolveProfile().output).toBe('table')
101104
})
102105

106+
it('accepts every documented output format from the environment', () => {
107+
for (const format of OUTPUT_FORMATS) {
108+
process.env.SIM_OUTPUT = format
109+
expect(resolveProfile().output).toBe(format)
110+
}
111+
})
112+
103113
it('writes credentials 0600 even when the file already existed world-readable', () => {
104114
writeFileSync(credentialsPath(), '', { mode: 0o644 })
105115
writeCredentialsProfile('default', 'sim_key')

packages/sim-cli/src/config/profile.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,15 @@ import { configPath, credentialsPath } from './paths.js'
1313

1414
export const DEFAULT_PROFILE = 'default'
1515
export const DEFAULT_ENDPOINT = 'https://sim.ai'
16-
export const OUTPUT_FORMATS = ['table', 'json'] as const
16+
17+
/**
18+
* Output formats, in the order `--help` lists them.
19+
*
20+
* `table` is for reading, `json`/`yaml` for piping into a parser, and `text` is
21+
* the one for shell loops: tab-separated, no header, no colour, so `cut`/`awk`/
22+
* `while read` work without a JSON tool on the box.
23+
*/
24+
export const OUTPUT_FORMATS = ['table', 'json', 'yaml', 'text'] as const
1725
export type OutputFormat = (typeof OUTPUT_FORMATS)[number]
1826

1927
/** Everything a command needs to make a call, after the resolution chain runs. */

packages/sim-cli/src/index.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
#!/usr/bin/env node
22

33
import chalk from 'chalk'
4-
import { Command } from 'commander'
4+
import { Command, Option } from 'commander'
55
import { loginCommand, logoutCommand, profilesCommand, whoamiCommand } from './commands/auth.js'
66
import { configureCommand } from './commands/configure.js'
77
import { filesCommand } from './commands/files.js'
@@ -21,7 +21,16 @@ program
2121
.option('-p, --profile <name>', 'Profile to use (env: SIM_PROFILE)')
2222
.option('--endpoint <url>', 'Sim deployment to talk to (env: SIM_ENDPOINT)')
2323
.option('-w, --workspace <id>', 'Workspace to target (env: SIM_WORKSPACE)')
24-
.option('-o, --output <format>', `Output format: ${OUTPUT_FORMATS.join(' | ')} (env: SIM_OUTPUT)`)
24+
// `.choices` so a typo'd format is an error, not a silent fall back to
25+
// `table`. Deliberately stricter than SIM_OUTPUT and the config file, which
26+
// tolerate an unknown value: those are ambient and set once, and a bad one
27+
// should not make every command fail — but a flag is an instruction just
28+
// typed, so honouring something else is a lie.
29+
.addOption(
30+
new Option('-o, --output <format>', 'Output format (env: SIM_OUTPUT)').choices([
31+
...OUTPUT_FORMATS,
32+
])
33+
)
2534

2635
program.addCommand(loginCommand())
2736
program.addCommand(logoutCommand())

packages/sim-cli/src/output/render.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import chalk, { Chalk } from 'chalk'
2+
import { load } from 'js-yaml'
23
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
34
import {
45
bytes,
@@ -87,6 +88,54 @@ describe('printList', () => {
8788
printList('json', [{ name: 'alpha', status: 'error' }], COLUMNS)
8889
expect(JSON.parse(logged[0])).toEqual([{ name: 'alpha', status: 'error' }])
8990
})
91+
92+
it('prints the raw rows for yaml too', () => {
93+
printList('yaml', [{ name: 'alpha', status: 'error' }], COLUMNS)
94+
expect(load(logged[0])).toEqual([{ name: 'alpha', status: 'error' }])
95+
})
96+
97+
it('keeps machine formats identical in content — only the encoding differs', () => {
98+
const rows = [{ name: 'alpha', status: 'error' }]
99+
printList('json', rows, COLUMNS)
100+
printList('yaml', rows, COLUMNS)
101+
expect(load(logged[1])).toEqual(JSON.parse(logged[0]))
102+
})
103+
104+
it('does not fold long yaml values across lines', () => {
105+
// Folding is valid YAML but breaks line-oriented greps and is miserable to read.
106+
const long = 'x'.repeat(300)
107+
printList('yaml', [{ name: long, status: 'ok' }], COLUMNS)
108+
expect(logged[0]).toContain(long)
109+
})
110+
111+
it('emits tab-separated cells with no header for text', () => {
112+
printList(
113+
'text',
114+
[
115+
{ name: 'alpha', status: 'error' },
116+
{ name: 'b', status: 'ok' },
117+
],
118+
COLUMNS
119+
)
120+
expect(logged).toEqual(['alpha\terror', 'b\tok'])
121+
})
122+
123+
it('strips colour from text output so cut and awk see plain fields', () => {
124+
printList('text', [{ name: 'alpha', status: coloured.red('error') }], COLUMNS)
125+
expect(logged[0]).toBe('alpha\terror')
126+
})
127+
128+
it('renders an absent value as an empty text field, not a dash', () => {
129+
// `cut -f2` returning a literal em-dash would read as a value to every
130+
// downstream emptiness test.
131+
printList('text', [{ name: 'alpha', status: text(null) }], COLUMNS)
132+
expect(logged[0]).toBe('alpha\t')
133+
})
134+
135+
it('prints nothing at all for an empty text list', () => {
136+
printList('text', [], COLUMNS)
137+
expect(logged).toEqual([])
138+
})
90139
})
91140

92141
describe('printRecord', () => {
@@ -95,6 +144,16 @@ describe('printRecord', () => {
95144
expect(JSON.parse(logged[0])).toEqual({ name: 'alpha', hidden: 1 })
96145
})
97146

147+
it('prints the raw object for yaml, ignoring the field list', () => {
148+
printRecord('yaml', [['Name', 'alpha']], { name: 'alpha', hidden: 1 })
149+
expect(load(logged[0])).toEqual({ name: 'alpha', hidden: 1 })
150+
})
151+
152+
it('prints label-tab-value for text', () => {
153+
printRecord('text', [['ID', 'abc']], {})
154+
expect(logged[0]).toBe('ID\tabc')
155+
})
156+
98157
it('prints one aligned line per field for table', () => {
99158
printRecord(
100159
'table',

packages/sim-cli/src/output/render.ts

Lines changed: 61 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
import chalk from 'chalk'
2+
import { dump } from 'js-yaml'
23
import type { OutputFormat } from '../config/index.js'
34

45
export interface Column<T> {
56
header: string
67
value: (row: T) => string
78
}
89

10+
/** The glyph standing in for "no value", before colour is applied. */
11+
const EMPTY_GLYPH = '—'
12+
913
/** Cell text for values that have no useful rendering, kept visually quiet. */
10-
const EMPTY = chalk.dim('—')
14+
const EMPTY = chalk.dim(EMPTY_GLYPH)
1115

1216
export function text(value: unknown): string {
1317
if (value === null || value === undefined || value === '') return EMPTY
@@ -67,6 +71,18 @@ export function visibleWidth(value: string): number {
6771
return value.replace(ANSI_PATTERN, '').length
6872
}
6973

74+
/**
75+
* Plain text for a rendered cell.
76+
*
77+
* The empty placeholder collapses to an actual empty field: `cut -f3` returning
78+
* a literal `—` for a null would be worse than useless, since every downstream
79+
* emptiness test would read it as a value.
80+
*/
81+
function stripAnsi(value: string): string {
82+
const plain = value.replace(ANSI_PATTERN, '')
83+
return plain === EMPTY_GLYPH ? '' : plain
84+
}
85+
7086
function pad(value: string, width: number): string {
7187
return value + ' '.repeat(Math.max(0, width - visibleWidth(value)))
7288
}
@@ -94,25 +110,61 @@ function renderTable<T>(rows: T[], columns: Column<T>[]): string {
94110
return [header, ...body].join('\n')
95111
}
96112

113+
/**
114+
* Renders the machine-readable formats from the RAW value.
115+
*
116+
* Deliberately not the table's formatted cells: `--output json` piped into `jq`
117+
* must yield the API's own field names and types, so a `1500` stays a number
118+
* rather than becoming the `"1.5s"` the table would show. `yaml` follows the
119+
* same rule, so switching format never changes the data.
120+
*
121+
* Returns null when the format wants the human rendering instead.
122+
*/
123+
function renderMachine(format: OutputFormat, raw: unknown): string | null {
124+
if (format === 'json') return JSON.stringify(raw, null, 2)
125+
// `lineWidth: 0` disables YAML's line folding — a wrapped value is technically
126+
// valid but is miserable to eyeball and breaks naive line-oriented greps.
127+
if (format === 'yaml') return dump(raw, { lineWidth: 0, noRefs: true }).trimEnd()
128+
return null
129+
}
130+
97131
/**
98132
* Prints a list in the profile's output format.
99133
*
100-
* The JSON branch prints the raw rows, not the table's formatted cells — piping
101-
* to `jq` should yield the API's own field names and types, so `--output json`
102-
* is a passthrough rather than a second rendering.
134+
* `text` emits the table's cells tab-separated with no header and no colour —
135+
* the shape `cut -f2` and `while read` expect. It uses the formatted cells
136+
* rather than the raw values on purpose: it is a human-ish format for shell
137+
* plumbing, and a raw ISO timestamp or byte count is worse in that context.
103138
*/
104139
export function printList<T>(format: OutputFormat, rows: T[], columns: Column<T>[]): void {
105-
if (format === 'json') {
106-
console.log(JSON.stringify(rows, null, 2))
140+
const machine = renderMachine(format, rows)
141+
if (machine !== null) {
142+
console.log(machine)
143+
return
144+
}
145+
146+
if (format === 'text') {
147+
for (const row of rows) {
148+
console.log(columns.map((column) => stripAnsi(column.value(row))).join('\t'))
149+
}
107150
return
108151
}
152+
109153
console.log(renderTable(rows, columns))
110154
}
111155

112-
/** Prints a single record: JSON as-is, table format as aligned key/value lines. */
156+
/** Prints a single record: machine formats from the raw value, otherwise aligned lines. */
113157
export function printRecord(format: OutputFormat, fields: Array<[string, string]>, raw: unknown) {
114-
if (format === 'json') {
115-
console.log(JSON.stringify(raw, null, 2))
158+
const machine = renderMachine(format, raw)
159+
if (machine !== null) {
160+
console.log(machine)
161+
return
162+
}
163+
164+
if (format === 'text') {
165+
for (const [label, value] of fields) {
166+
console.log(`${label}\t${stripAnsi(value)}`)
167+
}
116168
return
117169
}
118170

0 commit comments

Comments
 (0)