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.2] - 2026-04-21

### Fixed

- `scenes execute --dry-run` now outputs structured result on stdout instead of silently exiting (#54)
- MCP `send_command` dry-run validates command name against catalog before returning success (#55)
- MCP `run_scene` dry-run validates sceneId against scene list before returning success (#56)

## [2.6.1] - 2026-04-21

Follow-up to v2.6.0 from the OpenClaw re-audit. Three real findings
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.1",
"version": "2.6.2",
"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
23 changes: 22 additions & 1 deletion src/commands/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,13 @@ 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 },
});
}
const wouldSend = {
deviceId,
command,
Expand Down Expand Up @@ -529,7 +536,21 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
},
async ({ sceneId, dryRun }) => {
if (dryRun) {
const wouldSend = { sceneId };
let scenes: Array<{ sceneId: string; sceneName: string }> = [];
try {
scenes = await fetchScenes();
} catch {
// network failure — degrade gracefully, skip validation
}
const found = scenes.find((s) => s.sceneId === sceneId);
if (scenes.length > 0 && !found) {
return mcpError('usage', 2, `Scene not found: ${sceneId}`, {
subKind: 'scene-not-found',
hint: "Check the sceneId with 'list_scenes' (IDs are case-sensitive).",
context: { sceneId, candidates: scenes.map((s) => ({ sceneId: s.sceneId, sceneName: s.sceneName })).slice(0, 5) },
});
}
const wouldSend = { sceneId, sceneName: found?.sceneName ?? null };
const structured = { ok: true as const, dryRun: true as const, wouldSend };
return {
content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
Expand Down
10 changes: 10 additions & 0 deletions src/commands/scenes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Command } from 'commander';
import { printJson, isJsonMode, handleError, StructuredUsageError } from '../utils/output.js';
import { resolveFormat, resolveFields, renderRows } from '../utils/format.js';
import { fetchScenes, executeScene } from '../lib/scenes.js';
import { isDryRun } from '../utils/flags.js';

export function registerScenesCommand(program: Command): void {
const scenes = program
Expand Down Expand Up @@ -67,6 +68,15 @@ Example:
candidates: sceneList.map((s) => ({ sceneId: s.sceneId, sceneName: s.sceneName })),
});
}
if (isDryRun()) {
const wouldSend = { method: 'POST', url: `/v1.1/scenes/${sceneId}/execute`, sceneId, sceneName: found.sceneName };
if (isJsonMode()) {
printJson({ dryRun: true, wouldSend });
} else {
console.log(`[dry-run] Would POST /v1.1/scenes/${sceneId}/execute (${found.sceneName})`);
}
return;
}
await executeScene(sceneId);
if (isJsonMode()) {
printJson({ ok: true, sceneId });
Expand Down
1 change: 1 addition & 0 deletions src/utils/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ export class UsageError extends Error {
export type ErrorSubKind =
| 'device-offline'
| 'device-not-found'
| 'scene-not-found'
| 'command-not-supported'
| 'auth-failed'
| 'quota-exceeded'
Expand Down
71 changes: 71 additions & 0 deletions tests/commands/dry-run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,38 @@ describe('dryRun support on mutating tools', () => {
expect(apiMock.__instance.post).toHaveBeenCalledTimes(1);
});

// ---- send_command command-name validation in dry-run (bug #55) --------------

it('send_command dryRun:true rejects unknown command name (bug #55)', async () => {
cacheMock.map.set('BULB3', { type: 'Color Bulb', name: 'Desk Lamp', category: 'physical' });
const { client } = await pair();

const res = await client.callTool({
name: 'send_command',
arguments: { deviceId: 'BULB3', command: 'fakeCmd', 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(apiMock.__instance.post).not.toHaveBeenCalled();
});

it('send_command dryRun:true accepts case-normalized command name (bug #55)', async () => {
cacheMock.map.set('BULB4', { type: 'Color Bulb', name: 'Ceiling', category: 'physical' });
const { client } = await pair();

const res = await client.callTool({
name: 'send_command',
arguments: { deviceId: 'BULB4', command: 'turnon', dryRun: true },
});

expect(res.isError).toBeFalsy();
const parsed = JSON.parse((res.content as Array<{ text: string }>)[0].text);
expect(parsed.dryRun).toBe(true);
expect(apiMock.__instance.post).not.toHaveBeenCalled();
});

// ---- run_scene ------------------------------------------------------------

it('run_scene dryRun:true returns wouldSend without calling the API', async () => {
Expand Down Expand Up @@ -177,4 +209,43 @@ describe('dryRun support on mutating tools', () => {
expect(res.isError).toBeFalsy();
expect(apiMock.__instance.post).toHaveBeenCalledWith('/v1.1/scenes/S123/execute');
});

// ---- run_scene sceneId validation in dry-run (bug #56) --------------------

it('run_scene dryRun:true rejects unknown sceneId (bug #56)', async () => {
apiMock.__instance.get.mockResolvedValueOnce({
data: { body: [{ sceneId: 'S1', sceneName: 'Good Morning' }] },
});
const { client } = await pair();

const res = await client.callTool({
name: 'run_scene',
arguments: { sceneId: 'FAKE', dryRun: true },
});

expect(res.isError).toBe(true);
const parsed = JSON.parse((res.content as Array<{ text: string }>)[0].text);
expect(parsed.error.subKind).toBe('scene-not-found');
expect(parsed.error.message).toContain('FAKE');
expect(apiMock.__instance.post).not.toHaveBeenCalled();
});

it('run_scene dryRun:true returns sceneName in wouldSend for valid sceneId (bug #56)', async () => {
apiMock.__instance.get.mockResolvedValueOnce({
data: { body: [{ sceneId: 'S1', sceneName: 'Good Morning' }] },
});
const { client } = await pair();

const res = await client.callTool({
name: 'run_scene',
arguments: { sceneId: 'S1', 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.sceneId).toBe('S1');
expect(parsed.wouldSend.sceneName).toBe('Good Morning');
expect(apiMock.__instance.post).not.toHaveBeenCalled();
});
});
38 changes: 38 additions & 0 deletions tests/commands/scenes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,44 @@ describe('scenes command', () => {
expect(parsed.error?.context?.error).toBe('scene_not_found');
expect(parsed.error?.context?.sceneId).toBe('BOGUS-ID');
});

it('--dry-run --json returns structured wouldSend on stdout (bug #54)', async () => {
apiMock.__instance.get.mockResolvedValue({
data: { body: [{ sceneId: 'SCENE-1', sceneName: 'Morning' }] },
});
const res = await runCli(registerScenesCommand, ['scenes', 'execute', 'SCENE-1', '--dry-run', '--json']);
expect(res.exitCode).toBeNull();
expect(apiMock.__instance.post).not.toHaveBeenCalled();
const out = res.stdout.join('\n');
const parsed = JSON.parse(out);
expect(parsed.data.dryRun).toBe(true);
expect(parsed.data.wouldSend.sceneId).toBe('SCENE-1');
expect(parsed.data.wouldSend.sceneName).toBe('Morning');
});

it('--dry-run plaintext prints Would POST on stdout (bug #54)', async () => {
apiMock.__instance.get.mockResolvedValue({
data: { body: [{ sceneId: 'SCENE-1', sceneName: 'Morning' }] },
});
const res = await runCli(registerScenesCommand, ['scenes', 'execute', 'SCENE-1', '--dry-run']);
expect(res.exitCode).toBeNull();
expect(apiMock.__instance.post).not.toHaveBeenCalled();
const out = res.stdout.join('\n');
expect(out).toContain('[dry-run]');
expect(out).toContain('SCENE-1');
});

it('--dry-run with bogus sceneId still exits 2 with scene_not_found (bug #54)', async () => {
apiMock.__instance.get.mockResolvedValue({
data: { body: [{ sceneId: 'S1', sceneName: 'Good Morning' }] },
});
const res = await runCli(registerScenesCommand, ['scenes', 'execute', 'BOGUS', '--dry-run', '--json']);
expect(res.exitCode).toBe(2);
expect(apiMock.__instance.post).not.toHaveBeenCalled();
const out = res.stdout.join('\n');
const parsed = JSON.parse(out);
expect(parsed.error?.context?.error).toBe('scene_not_found');
});
});

describe('describe', () => {
Expand Down
Loading