Skip to content

Commit 89b4d9b

Browse files
fix(cli): make boolean flags able to say false
`--is-active false` turned sharing ON and reported success. Booleans were declared presence-only, so the flag meant `true` and commander dropped the `false` as an argument the command had no use for — silently, because excess arguments are ignored by default. A required boolean now takes its value (`--is-active <true|false>`): it is a state to set, not a switch to flip on, and as a presence flag it could only ever send one of the two values it needs to express. Optional booleans stay presence-flags — `--deployed-only` reads better than `--deployed-only true` — but each also gets `--no-<name>`. Omitting one means "leave it alone", which is not the same as setting it false; without the negation there was no way to disable an MCP server or unlock a folder. Excess arguments are now an error on every generated command, so a value attached to the wrong flag stops rather than being silently discarded. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0148a65fkPhP4N8tgGPYtRTU
1 parent b97987e commit 89b4d9b

2 files changed

Lines changed: 82 additions & 0 deletions

File tree

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

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,14 @@ vi.mock('../context.js', () => ({
2828
function program(): Command {
2929
const root = new Command('sim').exitOverride()
3030
for (const group of buildGeneratedCommands(new Set())) root.addCommand(group)
31+
// Recursively, not just on the root: a parse error raised by a leaf (an
32+
// unknown option, an excess argument) exits the process otherwise, which a
33+
// test cannot assert on.
34+
const override = (command: Command) => {
35+
command.exitOverride()
36+
command.commands.forEach(override)
37+
}
38+
override(root)
3139
return root
3240
}
3341

@@ -270,3 +278,53 @@ describe('rows whose content sits in a wrapper', () => {
270278
expect(lines[1]).toContain('E')
271279
})
272280
})
281+
282+
describe('boolean flags', () => {
283+
it('takes an explicit value when the field is required', async () => {
284+
// As a presence-only flag this could only ever send `true`: `--is-active
285+
// false` turned sharing ON and reported success, with the `false` dropped
286+
// as a stray argument.
287+
const [, options] = await run([
288+
'files',
289+
'share',
290+
'set',
291+
'f_1',
292+
'--is-active',
293+
'false',
294+
'--auth-type',
295+
'public',
296+
])
297+
expect(options.body).toMatchObject({ isActive: false })
298+
299+
const [, on] = await run([
300+
'files',
301+
'share',
302+
'set',
303+
'f_1',
304+
'--is-active',
305+
'true',
306+
'--auth-type',
307+
'public',
308+
])
309+
expect(on.body).toMatchObject({ isActive: true })
310+
})
311+
312+
it('negates an optional boolean, which omitting it cannot do', async () => {
313+
// Omitting `enabled` means "leave it alone"; there was no way to say false,
314+
// so an MCP server could not be disabled or a folder unlocked.
315+
const [, off] = await run(['mcp-servers', 'update', 'mcp_1', '--no-enabled'])
316+
expect(off.body).toMatchObject({ enabled: false })
317+
318+
const [, on] = await run(['mcp-servers', 'update', 'mcp_1', '--enabled'])
319+
expect(on.body).toMatchObject({ enabled: true })
320+
321+
const [, absent] = await run(['mcp-servers', 'update', 'mcp_1', '--name', 'x'])
322+
expect(absent.body).not.toHaveProperty('enabled')
323+
})
324+
325+
it('rejects an argument the command has no meaning for', async () => {
326+
await expect(run(['mcp-servers', 'update', 'mcp_1', '--enabled', 'bogus'])).rejects.toThrow(
327+
/too many arguments/
328+
)
329+
})
330+
})

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,27 @@ function addFieldOption(
205205
}
206206

207207
if (descriptor.kind === 'boolean') {
208+
// A required boolean is a state to set, not a switch to flip on: it takes
209+
// the value explicitly. As a presence-only flag it could only ever send
210+
// `true`, so `--is-active false` set sharing ON — commander read the flag as
211+
// true and dropped the `false` as a stray argument.
212+
if (descriptor.required) {
213+
command.addOption(
214+
new Option(`${short}--${name} <true|false>`, flag.describe ?? `Set ${field}`).choices([
215+
'true',
216+
'false',
217+
])
218+
)
219+
return
220+
}
221+
222+
// Optional booleans stay presence-flags — `--deployed-only` reads better
223+
// than `--deployed-only true` — but every one of them also gets a negation,
224+
// because for a state field (`enabled`, `locked`) omitting the flag means
225+
// "leave it alone", which is not the same as setting it false. Without this
226+
// there was no way to disable an MCP server or unlock a folder.
208227
command.option(`${short}--${name}`, flag.describe ?? `Set ${field}`)
228+
command.option(`--no-${name}`, `Set ${field} to false`)
209229
return
210230
}
211231

@@ -246,6 +266,10 @@ function buildLeaf(operation: V2OperationName, spec: CommandSpec, leafName: stri
246266
// NAME, so `sim tables upsert` would never match it and would silently fall
247267
// through to the group's help. Arguments have to be declared separately.
248268
const command = new Command(leafName)
269+
// Commander ignores arguments beyond those declared. That silence is how
270+
// `--is-active false` ran as though the `false` had never been typed; an
271+
// argument the command has no meaning for is a mistake worth stopping on.
272+
command.allowExcessArguments(false)
249273
for (const param of operationSpec.pathParams) {
250274
command.argument(`<${param}>`)
251275
}

0 commit comments

Comments
 (0)