Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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 |

Expand Down
2 changes: 1 addition & 1 deletion docs/agent-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <n>` and `--backoff <linear|exponential>` 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).
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
33 changes: 27 additions & 6 deletions src/commands/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
48 changes: 48 additions & 0 deletions tests/commands/dry-run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Loading