diff --git a/CHANGELOG.md b/CHANGELOG.md index e47f72a..b1023af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to `@switchbot/openapi-cli` are documented in this file. The format is loosely based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.6.3] - 2026-04-21 + +### Fixed + +- MCP `send_command` dry-run now strictly rejects unknown command names when catalog has a definitive match (#55) +- MCP `send_command` dry-run rejects commands sent to read-only sensors (e.g. Meter) +- Previous v2.6.2 fix used lenient `validateCommand` which silently passed when catalog lookup was ambiguous + ## [2.6.2] - 2026-04-21 ### Fixed diff --git a/README.md b/README.md index 1f111d0..47f8c45 100644 --- a/README.md +++ b/README.md @@ -184,7 +184,10 @@ switchbot devices command --help Intercepts every non-GET request: the CLI prints the URL/body it would have sent, then exits `0` without contacting the API. `GET` requests (list, status, -query) are still executed so you can preview the state involved. +query) are still executed so you can preview the state involved. Dry-run also +validates command names against the device catalog and rejects unknown commands +(exit 2) when the device type has a known catalog entry. Commands sent to +read-only sensors (e.g. Meter) are likewise rejected. ```bash switchbot devices command ABC123 turnOn --dry-run @@ -305,7 +308,7 @@ Generic parameter shapes (which one applies is decided by the device — see the Parameters for `setAll` (Air Conditioner), `setPosition` (Curtain / Blind Tilt), `setMode` (Relay Switch), `setBrightness` (dimmable lights), and `setColor` (Color Bulb / Strip Light / Ceiling Light) are validated client-side before the request — malformed shapes, out-of-range values, and JSON for CSV fields all fail fast with exit 2. `setColor` accepts `R:G:B`, `R,G,B`, `#RRGGBB`, `#RGB`, and CSS named colors (`red`, `blue`, …); all normalize to `R:G:B` before hitting the API. Pass `--skip-param-validation` to bypass (escape hatch — prefer fixing the argument). Command names are also case-normalized against the catalog (e.g. `turnon` is auto-corrected to `turnOn` with a stderr warning); unknown names still exit 2 with the supported-commands list. -Unknown deviceIds (not in the local cache) exit 2 by default so `--dry-run` is a reliable pre-flight gate. Run `switchbot devices list` first, or pass `--allow-unknown-device` for scripted pass-through. +Unknown deviceIds (not in the local cache) exit 2 by default so `--dry-run` is a reliable pre-flight gate. Unknown command names and commands on read-only sensors are also rejected during dry-run when the device type has a catalog entry. Run `switchbot devices list` first, or pass `--allow-unknown-device` for scripted pass-through. Negative numeric parameters (e.g. `setBrightness -1` for a probe) are passed through to the command validator instead of being swallowed by the flag parser as an unknown option. @@ -717,7 +720,7 @@ switchbot cache clear --key status | Code | Meaning | | ---- | ------------------------------------------------------------------------------------------------------------------------- | -| `0` | Success (including `--dry-run` intercept) | +| `0` | Success (including `--dry-run` intercept when validation passes) | | `1` | Runtime error — API error, network failure, missing credentials | | `2` | Usage error — bad flag, missing/invalid argument, unknown subcommand, unknown device type, invalid URL, conflicting flags | diff --git a/docs/agent-guide.md b/docs/agent-guide.md index a8dc388..eb0be84 100644 --- a/docs/agent-guide.md +++ b/docs/agent-guide.md @@ -242,7 +242,7 @@ Use `switchbot doctor` to confirm the CLI is healthy before orchestrating anythi ## Safety rails 1. **Destructive-command guard**: Smart Lock `unlock`, Garage Door `open`, and anything else tagged `destructive: true` in the catalog **refuses to run** without `--yes` (or `confirm: true` in MCP, or explicit dev intent). There is no bypass flag for autonomous agents beyond `--yes` — that's by design. -2. **Dry-run**: Global `--dry-run` short-circuits every mutating HTTP request. GETs still execute. Use it for any "what would this do?" flow before letting the agent commit. +2. **Dry-run**: Global `--dry-run` short-circuits every mutating HTTP request. GETs still execute. Command names are validated against the device catalog — unknown commands exit 2 when the device type has a known catalog entry, as do commands on read-only sensors. Use it for any "what would this do?" flow before letting the agent commit. 3. **Quota**: The SwitchBot API has a per-account daily quota. `--retry-on-429 ` and `--backoff ` handle throttling; `~/.switchbot/quota.json` tracks daily counts. 4. **Audit log**: `--audit-log [path]` appends every mutating command (including dry-runs) to JSONL for post-hoc review. 5. **Non-zero exit codes are stable**: `0` success, `1` runtime error, `2` usage error (bad flag, invalid plan schema). diff --git a/package.json b/package.json index ec45889..a680656 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@switchbot/openapi-cli", - "version": "2.6.2", + "version": "2.6.3", "description": "SwitchBot smart home CLI — control devices, run scenes, stream real-time events, and integrate AI agents via MCP. Full API v1.1 coverage.", "keywords": [ "switchbot", diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index 3028c14..0e58c85 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -380,12 +380,33 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`, effectiveParameter = pv.normalized; } } - const cmdVal = validateCommand(deviceId, command, stringifiedParam, effectiveType); - if (!cmdVal.ok) { - return mcpError('usage', 2, cmdVal.error.message, { - hint: cmdVal.error.hint, - context: { validationKind: cmdVal.error.kind }, - }); + // Bug #55: validateCommand is lenient by design (passes unknown device + // types, ambiguous catalog matches). For dry-run we need stricter + // checking — query the catalog directly and reject unknown commands + // when the catalog has a definitive match. + if (effectiveType !== 'customize') { + const catalogMatch = findCatalogEntry(cached.type); + if (catalogMatch && !Array.isArray(catalogMatch)) { + const builtinCmds = catalogMatch.commands.filter((c) => c.commandType !== 'customize'); + if (builtinCmds.length > 0) { + const exactMatch = builtinCmds.find((c) => c.command === command); + const caseMatch = !exactMatch + ? builtinCmds.find((c) => c.command.toLowerCase() === command.toLowerCase()) + : null; + if (!exactMatch && !caseMatch) { + const supported = [...new Set(builtinCmds.map((c) => c.command))].join(', '); + return mcpError('usage', 2, `"${command}" is not a supported command for ${cached.name} (${cached.type}).`, { + hint: `Supported commands: ${supported}`, + context: { validationKind: 'unknown-command', deviceType: cached.type, command }, + }); + } + } else if (catalogMatch.readOnly) { + return mcpError('usage', 2, `${cached.name} (${cached.type}) is a read-only sensor — it has no control commands.`, { + hint: "Use 'get_device_status' to read this device instead.", + context: { validationKind: 'read-only-device', deviceType: cached.type, command }, + }); + } + } } const wouldSend = { deviceId, diff --git a/tests/commands/dry-run.test.ts b/tests/commands/dry-run.test.ts index d8d017e..34d2bbb 100644 --- a/tests/commands/dry-run.test.ts +++ b/tests/commands/dry-run.test.ts @@ -178,6 +178,54 @@ describe('dryRun support on mutating tools', () => { expect(apiMock.__instance.post).not.toHaveBeenCalled(); }); + it('send_command dryRun:true passes through for uncataloged device type (bug #55)', async () => { + cacheMock.map.set('NEWDEV', { type: 'FutureGadget9000', name: 'Lab Device', category: 'physical' }); + const { client } = await pair(); + + const res = await client.callTool({ + name: 'send_command', + arguments: { deviceId: 'NEWDEV', command: 'anyCommand', dryRun: true }, + }); + + expect(res.isError).toBeFalsy(); + const parsed = JSON.parse((res.content as Array<{ text: string }>)[0].text); + expect(parsed.dryRun).toBe(true); + expect(parsed.wouldSend.command).toBe('anyCommand'); + expect(apiMock.__instance.post).not.toHaveBeenCalled(); + }); + + it('send_command dryRun:true rejects commands on read-only sensor (bug #55)', async () => { + cacheMock.map.set('METER1', { type: 'Meter', name: 'Office Meter', category: 'physical' }); + const { client } = await pair(); + + const res = await client.callTool({ + name: 'send_command', + arguments: { deviceId: 'METER1', command: 'turnOn', dryRun: true }, + }); + + expect(res.isError).toBe(true); + const parsed = JSON.parse((res.content as Array<{ text: string }>)[0].text); + expect(parsed.error.context?.validationKind).toBe('read-only-device'); + expect(apiMock.__instance.post).not.toHaveBeenCalled(); + }); + + it('send_command dryRun:true error includes supported commands hint (bug #55)', async () => { + cacheMock.map.set('BOT1', { type: 'Bot', name: 'Kitchen Bot', category: 'physical' }); + const { client } = await pair(); + + const res = await client.callTool({ + name: 'send_command', + arguments: { deviceId: 'BOT1', command: 'explode', dryRun: true }, + }); + + expect(res.isError).toBe(true); + const parsed = JSON.parse((res.content as Array<{ text: string }>)[0].text); + expect(parsed.error.context?.validationKind).toBe('unknown-command'); + expect(parsed.error.hint).toContain('turnOn'); + expect(parsed.error.hint).toContain('press'); + expect(apiMock.__instance.post).not.toHaveBeenCalled(); + }); + // ---- run_scene ------------------------------------------------------------ it('run_scene dryRun:true returns wouldSend without calling the API', async () => {