diff --git a/CHANGELOG.md b/CHANGELOG.md index b25ad73..e47f72a 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.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 diff --git a/package.json b/package.json index cef6d6c..ec45889 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index c347ce7..3028c14 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -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, @@ -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) }], diff --git a/src/commands/scenes.ts b/src/commands/scenes.ts index fc1859f..81d8e95 100644 --- a/src/commands/scenes.ts +++ b/src/commands/scenes.ts @@ -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 @@ -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 }); diff --git a/src/utils/output.ts b/src/utils/output.ts index 5db48d2..9f57aec 100644 --- a/src/utils/output.ts +++ b/src/utils/output.ts @@ -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' diff --git a/tests/commands/dry-run.test.ts b/tests/commands/dry-run.test.ts index 1feb8ca..d8d017e 100644 --- a/tests/commands/dry-run.test.ts +++ b/tests/commands/dry-run.test.ts @@ -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 () => { @@ -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(); + }); }); diff --git a/tests/commands/scenes.test.ts b/tests/commands/scenes.test.ts index 22dc0fc..ded14ef 100644 --- a/tests/commands/scenes.test.ts +++ b/tests/commands/scenes.test.ts @@ -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', () => {