diff --git a/.gitignore b/.gitignore index 87f911f..df3b0df 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ CLAUDE.md # Init transcript 2026-04-10-155920-command-messageinitcommand-message.txt tmp/ +smoke-v3/ diff --git a/src/commands/batch.ts b/src/commands/batch.ts index ac373b1..fa69fc1 100644 --- a/src/commands/batch.ts +++ b/src/commands/batch.ts @@ -1,7 +1,7 @@ import { Command } from 'commander'; import type { AxiosInstance } from 'axios'; import { intArg, enumArg, stringArg } from '../utils/arg-parsers.js'; -import { printJson, isJsonMode, handleError, buildErrorPayload, UsageError, emitJsonError, type ErrorPayload } from '../utils/output.js'; +import { printJson, isJsonMode, handleError, buildErrorPayload, UsageError, emitJsonError, exitWithError, type ErrorPayload } from '../utils/output.js'; import { fetchDeviceList, executeCommand, @@ -242,20 +242,10 @@ Examples: }, getClient); } catch (error) { if (error instanceof FilterSyntaxError) { - if (isJsonMode()) { - emitJsonError({ code: 2, kind: 'usage', message: error.message }); - } else { - console.error(`Error: ${error.message}`); - } - process.exit(2); + exitWithError(`Error: ${error.message}`); } if (error instanceof Error && error.message.startsWith('No target devices')) { - if (isJsonMode()) { - emitJsonError({ code: 2, kind: 'usage', message: error.message }); - } else { - console.error(`Error: ${error.message}`); - } - process.exit(2); + exitWithError(`Error: ${error.message}`); } handleError(error); } diff --git a/src/commands/catalog.ts b/src/commands/catalog.ts index 78a08f2..df7f1cf 100644 --- a/src/commands/catalog.ts +++ b/src/commands/catalog.ts @@ -84,6 +84,14 @@ Examples: .description("Show the effective catalog (or one entry). Alias: 'list'. Defaults to 'effective' source.") .argument('[type...]', 'Optional device type/alias (case-insensitive, partial match)') .option('--source ', 'Which catalog to show: built-in | overlay | effective (default)', enumArg('--source', SOURCES), 'effective') + .addHelpText('after', ` +Examples: + $ switchbot catalog show + $ switchbot catalog show Bot + $ switchbot catalog show Robot Vacuum + $ switchbot catalog show --source built-in + $ switchbot catalog show --json +`) .action((typeParts: string[], options: { source: string }) => { try { const source = options.source; diff --git a/src/commands/config.ts b/src/commands/config.ts index c118800..fec3006 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -4,7 +4,7 @@ import readline from 'node:readline'; import { execFileSync } from 'node:child_process'; import { stringArg } from '../utils/arg-parsers.js'; import { intArg } from '../utils/arg-parsers.js'; -import { saveConfig, showConfig, listProfiles, readProfileMeta } from '../config.js'; +import { saveConfig, showConfig, getConfigSummary, listProfiles, readProfileMeta } from '../config.js'; import { isJsonMode, printJson, emitJsonError } from '../utils/output.js'; import chalk from 'chalk'; @@ -250,6 +250,10 @@ Files are written with mode 0600. Profiles live under ~/.switchbot/profiles/ { + if (isJsonMode()) { + printJson(getConfigSummary()); + return; + } showConfig(); }); diff --git a/src/commands/devices.ts b/src/commands/devices.ts index 591164d..a21cdda 100644 --- a/src/commands/devices.ts +++ b/src/commands/devices.ts @@ -1,6 +1,6 @@ import { Command } from 'commander'; import { enumArg, stringArg } from '../utils/arg-parsers.js'; -import { printTable, printKeyValue, printJson, isJsonMode, handleError, UsageError, StructuredUsageError, emitJsonError } from '../utils/output.js'; +import { printTable, printKeyValue, printJson, isJsonMode, handleError, UsageError, StructuredUsageError, emitJsonError, exitWithError } from '../utils/output.js'; import { resolveFormat, resolveFields, renderRows } from '../utils/format.js'; import { findCatalogEntry, getEffectiveCatalog, DeviceCatalogEntry } from '../devices/catalog.js'; import { getCachedDevice, loadCache } from '../devices/cache.js'; @@ -27,6 +27,15 @@ import { registerExpandCommand } from './expand.js'; import { registerDevicesMetaCommand } from './device-meta.js'; import { isDryRun } from '../utils/flags.js'; import { DryRunSignal } from '../api/client.js'; +import { resolveField, listSupportedFieldInputs } from '../schema/field-aliases.js'; + +const EXPAND_HINTS: Record = { + 'Air Conditioner': { command: 'setAll', flags: '--temp 26 --mode cool --fan low --power on' }, + 'Curtain': { command: 'setPosition', flags: '--position 50 --mode silent' }, + 'Curtain 3': { command: 'setPosition', flags: '--position 50' }, + 'Blind Tilt': { command: 'setPosition', flags: '--direction up --angle 50' }, + 'Relay Switch 2PM': { command: 'setMode', flags: '--channel 1 --mode edge' }, +}; export function registerDevicesCommand(program: Command): void { const COMMAND_TYPES = ['command', 'customize'] as const; @@ -94,7 +103,7 @@ Examples: `) .option('--wide', 'Show all columns (controlType, family, roomID, room, hub, cloud)') .option('--show-hidden', 'Include devices hidden via "devices meta set --hide"') - .option('--filter ', 'Filter devices: comma-separated clauses. Each clause is "key=value" (substring; exact for category), "key!=value" (negated substring), "key~value" (explicit substring), or "key=/regex/" (case-insensitive regex). Supported keys: type, name, category, room.', stringArg('--filter')) + .option('--filter ', 'Filter devices: comma-separated clauses. Each clause is "key=value" (substring; exact for category), "key!=value" (negated substring), "key~value" (explicit substring), or "key=/regex/" (case-insensitive regex). Supported keys: deviceId/id, deviceName/name, deviceType/type, controlType, roomName/room, category.', stringArg('--filter')) .action(async (options: { wide?: boolean; showHidden?: boolean; filter?: string }) => { try { const body = await fetchDeviceList(); @@ -106,18 +115,33 @@ Examples: // Parse --filter into a list of clauses. Shared grammar across // `devices list`, `devices batch`, and `events tail` / `mqtt-tail`. - const LIST_KEYS = ['type', 'name', 'category', 'room'] as const; + const LIST_KEYS = ['deviceId', 'type', 'name', 'category', 'room', 'controlType'] as const; + const LIST_FILTER_CANONICAL = ['deviceId', 'deviceName', 'deviceType', 'controlType', 'roomName', 'category'] as const; + const LIST_FILTER_TO_RUNTIME: Record = { + deviceId: 'deviceId', + deviceName: 'name', + deviceType: 'type', + controlType: 'controlType', + roomName: 'room', + category: 'category', + }; let listClauses: FilterClause[] | null = null; if (options.filter) { try { - listClauses = parseFilterExpr(options.filter, LIST_KEYS); + listClauses = parseFilterExpr(options.filter, LIST_KEYS, { + resolveKey: (input) => { + const canonical = resolveField(input, LIST_FILTER_CANONICAL); + return LIST_FILTER_TO_RUNTIME[canonical]; + }, + supportedKeys: listSupportedFieldInputs(LIST_FILTER_CANONICAL), + }); } catch (err) { if (err instanceof FilterSyntaxError) throw new UsageError(err.message); throw err; } } - const matchesFilter = (entry: { type: string; name: string; category: 'physical' | 'ir'; room: string }) => { + const matchesFilter = (entry: { deviceId: string; type: string; name: string; category: 'physical' | 'ir'; room: string; controlType: string }) => { if (!listClauses || listClauses.length === 0) return true; for (const c of listClauses) { const fieldVal = (entry as Record)[c.key] ?? ''; @@ -129,11 +153,11 @@ Examples: if (fmt === 'json' && process.argv.includes('--json')) { if (listClauses) { const filteredDeviceList = deviceList.filter((d) => - matchesFilter({ type: d.deviceType || '', name: d.deviceName, category: 'physical', room: d.roomName || '' }) + matchesFilter({ deviceId: d.deviceId, type: d.deviceType || '', name: d.deviceName, category: 'physical', room: d.roomName || '', controlType: d.controlType || '' }) ); const filteredIrList = infraredRemoteList.filter((d) => { const inherited = hubLocation.get(d.hubDeviceId); - return matchesFilter({ type: d.remoteType, name: d.deviceName, category: 'ir', room: inherited?.room || '' }); + return matchesFilter({ deviceId: d.deviceId, type: d.remoteType, name: d.deviceName, category: 'ir', room: inherited?.room || '', controlType: d.controlType || '' }); }); printJson({ ok: true, deviceList: filteredDeviceList, infraredRemoteList: filteredIrList }); } else { @@ -150,7 +174,7 @@ Examples: for (const d of deviceList) { if (!options.showHidden && deviceMeta.devices[d.deviceId]?.hidden) continue; - if (!matchesFilter({ type: d.deviceType || '', name: d.deviceName, category: 'physical', room: d.roomName || '' })) continue; + if (!matchesFilter({ deviceId: d.deviceId, type: d.deviceType || '', name: d.deviceName, category: 'physical', room: d.roomName || '', controlType: d.controlType || '' })) continue; rows.push([ d.deviceId, d.deviceName, @@ -169,7 +193,7 @@ Examples: for (const d of infraredRemoteList) { if (!options.showHidden && deviceMeta.devices[d.deviceId]?.hidden) continue; const inherited = hubLocation.get(d.hubDeviceId); - if (!matchesFilter({ type: d.remoteType, name: d.deviceName, category: 'ir', room: inherited?.room || '' })) continue; + if (!matchesFilter({ deviceId: d.deviceId, type: d.remoteType, name: d.deviceName, category: 'ir', room: inherited?.room || '', controlType: d.controlType || '' })) continue; rows.push([ d.deviceId, d.deviceName, @@ -193,9 +217,19 @@ Examples: const defaultFields = options.wide ? undefined : narrowHeaders; // Accept API field names and short aliases alongside canonical column names const DEVICE_LIST_ALIASES: Record = { - id: 'deviceId', name: 'deviceName', deviceType: 'type', type: 'type', - roomName: 'room', familyName: 'family', - hubDeviceId: 'hub', enableCloudService: 'cloud', + id: 'deviceId', + name: 'deviceName', + deviceType: 'type', + type: 'type', + roomName: 'room', + familyName: 'family', + hubDeviceId: 'hub', + enableCloudService: 'cloud', + controlType: 'controlType', + deviceName: 'deviceName', + deviceId: 'deviceId', + category: 'category', + alias: 'alias', }; renderRows(wideHeaders, rows, fmt, userFields ?? defaultFields, DEVICE_LIST_ALIASES); if (fmt === 'table') { @@ -439,90 +473,71 @@ Examples: } } const validation = validateCommand(deviceId, cmd, parameter, options.type); - if (!validation.ok) { - const err = validation.error; - if (isJsonMode()) { - const obj: Record = { code: 2, kind: 'usage', message: err.message }; - if (err.hint) obj.hint = err.hint; - obj.context = { validationKind: err.kind }; - emitJsonError(obj); - } else { - console.error(`Error: ${err.message}`); - if (err.hint) console.error(err.hint); - if (err.kind === 'unknown-command') { - const cached = getCachedDevice(deviceId); - if (cached) { - console.error( - `Run 'switchbot devices commands ${JSON.stringify(cached.type)}' for parameter formats and descriptions.` - ); - console.error( - `(If the catalog is out of date, run 'switchbot devices list' to refresh the local cache, or pass --type customize for custom IR buttons.)` - ); + if (!validation.ok) { + const err = validation.error; + if (isJsonMode()) { + const obj: Record = { code: 2, kind: 'usage', message: err.message }; + if (err.hint) obj.hint = err.hint; + obj.context = { validationKind: err.kind }; + emitJsonError(obj); + } else { + console.error(`Error: ${err.message}`); + if (err.hint) console.error(err.hint); + if (err.kind === 'unknown-command') { + const cached = getCachedDevice(deviceId); + if (cached) { + console.error( + `Run 'switchbot devices commands ${JSON.stringify(cached.type)}' for parameter formats and descriptions.` + ); + console.error( + `(If the catalog is out of date, run 'switchbot devices list' to refresh the local cache, or pass --type customize for custom IR buttons.)` + ); + } } } + process.exit(2); } - process.exit(2); - } - // Case-only mismatch: emit a warning and continue with the canonical name. - if (validation.caseNormalizedFrom && validation.normalized) { - console.error( - `Note: '${validation.caseNormalizedFrom}' normalized to '${validation.normalized}' (case mismatch). Use exact casing to silence this warning.` - ); - cmd = validation.normalized; - } else if (validation.normalized) { - cmd = validation.normalized; - } + // Case-only mismatch: emit a warning and continue with the canonical name. + if (validation.caseNormalizedFrom && validation.normalized) { + console.error( + `Note: '${validation.caseNormalizedFrom}' normalized to '${validation.normalized}' (case mismatch). Use exact casing to silence this warning.` + ); + cmd = validation.normalized; + } else if (validation.normalized) { + cmd = validation.normalized; + } - // Raw-parameter validation (runs for known (deviceType, command) pairs only). - const cachedForParam = getCachedDevice(deviceId); - if (cachedForParam && options.type === 'command' && !options.skipParamValidation) { - const paramCheck = validateParameter(cachedForParam.type, cmd, parameter); - if (!paramCheck.ok) { - if (isJsonMode()) { - emitJsonError({ - code: 2, - kind: 'usage', - message: paramCheck.error, + // Raw-parameter validation (runs for known (deviceType, command) pairs only). + const cachedForParam = getCachedDevice(deviceId); + if (cachedForParam && options.type === 'command' && !options.skipParamValidation) { + const paramCheck = validateParameter(cachedForParam.type, cmd, parameter); + if (!paramCheck.ok) { + exitWithError({ + message: `Error: ${paramCheck.error}`, context: { command: cmd, deviceType: cachedForParam.type, deviceId, humanHint: paramCheck.error }, }); - } else { - console.error(`Error: ${paramCheck.error}`); } - process.exit(2); + if (paramCheck.normalized !== undefined) parameter = paramCheck.normalized; } - if (paramCheck.normalized !== undefined) parameter = paramCheck.normalized; - } - const cachedForGuard = getCachedDevice(deviceId); - if ( - !options.yes && - !isDryRun() && - isDestructiveCommand(cachedForGuard?.type, cmd, options.type) - ) { - const typeLabel = cachedForGuard?.type ?? 'unknown'; - const reason = getDestructiveReason(cachedForGuard?.type, cmd, options.type); - if (isJsonMode()) { - emitJsonError({ - code: 2, + const cachedForGuard = getCachedDevice(deviceId); + if ( + !options.yes && + !isDryRun() && + isDestructiveCommand(cachedForGuard?.type, cmd, options.type) + ) { + const typeLabel = cachedForGuard?.type ?? 'unknown'; + const reason = getDestructiveReason(cachedForGuard?.type, cmd, options.type); + exitWithError({ kind: 'guard', - message: `"${cmd}" on ${typeLabel} is destructive and requires --yes.`, + message: `Refusing to run destructive command "${cmd}" on ${typeLabel} without --yes.`, hint: reason ? `Re-run with --yes to confirm. Reason: ${reason}` : 'Re-run with --yes to confirm, or --dry-run to preview without sending.', context: { command: cmd, deviceType: typeLabel, deviceId, ...(reason ? { destructiveReason: reason } : {}) }, }); - } else { - console.error( - `Refusing to run destructive command "${cmd}" on ${typeLabel} without --yes.` - ); - if (reason) console.error(`Reason: ${reason}`); - console.error( - `Re-run with --yes to confirm, or --dry-run to preview without sending.` - ); } - process.exit(2); - } // Warn when --yes is given but the command is not destructive (no-op flag) if (options.yes && !isDestructiveCommand(cachedForGuard?.type, cmd, options.type) && !isDryRun()) { @@ -740,7 +755,8 @@ JSON output shape (--json): liveStatus: }, source: "catalog" | "live" | "catalog+live" | "none", - suggestedActions: [{command, parameter?, description}] + suggestedActions: [{command, parameter?, description}], + expandHint?: {command, flags, example} // present when the type supports 'devices expand' } Examples: @@ -761,6 +777,7 @@ Examples: const { device, isPhysical, typeName, controlType, catalog, capabilities, source, suggestedActions: picks } = result; if (isJsonMode()) { + const expandHint = catalog ? EXPAND_HINTS[catalog.type] : undefined; printJson({ device, controlType, @@ -768,6 +785,7 @@ Examples: capabilities, source, suggestedActions: picks, + ...(expandHint ? { expandHint: { command: expandHint.command, flags: expandHint.flags, example: `switchbot devices expand ${deviceId} ${expandHint.command} ${expandHint.flags}` } } : {}), }); return; } @@ -826,19 +844,12 @@ Examples: } catch (error) { if (error instanceof DeviceNotFoundError) { const message = `${error.message} Try 'switchbot devices list' to see the full list.`; - if (isJsonMode()) { - emitJsonError({ - code: 1, - kind: 'runtime', - message, - errorClass: 'runtime', - transient: false, - }); - } else { - console.error(error.message); - console.error(`Try 'switchbot devices list' to see the full list.`); - } - process.exit(1); + exitWithError({ + code: 1, + kind: 'runtime', + message, + extra: { errorClass: 'runtime', transient: false }, + }); } handleError(error); } @@ -896,4 +907,9 @@ function renderCatalogEntry(entry: DeviceCatalogEntry): void { console.log('\nStatus fields (from "devices status"):'); console.log(' ' + entry.statusFields.join(', ')); } + + const expandHint = EXPAND_HINTS[entry.type]; + if (expandHint) { + console.log(`\nTip: Use 'devices expand ${expandHint.command} ${expandHint.flags}' for semantic flags instead of raw parameters.`); + } } diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index 0e58c85..94c8bd4 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -4,7 +4,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { z } from 'zod'; import { intArg, stringArg } from '../utils/arg-parsers.js'; -import { handleError, isJsonMode, buildErrorPayload, emitJsonError, type ErrorPayload, type ErrorSubKind } from '../utils/output.js'; +import { handleError, isJsonMode, buildErrorPayload, emitJsonError, exitWithError, type ErrorPayload, type ErrorSubKind } from '../utils/output.js'; import { VERSION } from '../version.js'; import { fetchDeviceList, @@ -37,7 +37,7 @@ import { import { todayUsage } from '../utils/quota.js'; import { describeCache } from '../devices/cache.js'; import { withRequestContext } from '../lib/request-context.js'; -import { profileFilePath, loadConfig, tryLoadConfig } from '../config.js'; +import { profileFilePath, tryLoadConfig } from '../config.js'; import fs from 'node:fs'; /** @@ -346,6 +346,7 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`, }, async ({ deviceId, command, parameter, commandType, confirm, idempotencyKey, dryRun }) => { const effectiveType = commandType ?? 'command'; + let effectiveCommand = command; let effectiveParameter: unknown = parameter; // stringifiedParam mirrors the CLI form that validateCommand / @@ -366,51 +367,42 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`, context: { deviceId }, }); } + const dryValidation = validateCommand(deviceId, effectiveCommand, stringifiedParam, effectiveType); + if (!dryValidation.ok) { + return mcpError( + 'usage', + 2, + dryValidation.error.message, + { + hint: dryValidation.error.hint, + context: { + validationKind: dryValidation.error.kind, + deviceType: cached.type, + command: effectiveCommand, + }, + }, + ); + } + if (dryValidation.normalized) { + effectiveCommand = dryValidation.normalized; + } // R-2: run B-1 param validation in dry-run too, so dry-run doesn't // falsely accept inputs the live API would reject. if (effectiveType !== 'customize') { - const pv = validateParameter(cached.type, command, stringifiedParam); + const pv = validateParameter(cached.type, effectiveCommand, stringifiedParam); if (!pv.ok) { return mcpError('usage', 2, pv.error, { hint: 'Dry-run rejected the parameter client-side; the API would reject it too.', - context: { deviceType: cached.type, command, parameter: stringifiedParam }, + context: { deviceType: cached.type, command: effectiveCommand, parameter: stringifiedParam }, }); } if (pv.normalized !== undefined) { effectiveParameter = pv.normalized; } } - // 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, - command, + command: effectiveCommand, parameter: effectiveParameter ?? 'default', commandType: effectiveType, }; @@ -437,23 +429,23 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`, typeName = physical ? physical.deviceType : ir!.remoteType; } - if (isDestructiveCommand(typeName, command, effectiveType) && !confirm) { - const reason = getDestructiveReason(typeName, command, effectiveType); + if (isDestructiveCommand(typeName, effectiveCommand, effectiveType) && !confirm) { + const reason = getDestructiveReason(typeName, effectiveCommand, effectiveType); const entry = typeName ? findCatalogEntry(typeName) : null; const spec = entry && !Array.isArray(entry) - ? entry.commands.find((c) => c.command === command) + ? entry.commands.find((c) => c.command === effectiveCommand) : undefined; const hint = reason ? `Re-issue with confirm:true after confirming with the user. Reason: ${reason}` : 'Re-issue the call with confirm:true to proceed.'; return mcpError( 'guard', 3, - `Command "${command}" on device type "${typeName}" is destructive and requires confirm:true.`, + `Command "${effectiveCommand}" on device type "${typeName}" is destructive and requires confirm:true.`, { hint, context: { - command, + command: effectiveCommand, deviceType: typeName, description: spec?.description ?? null, ...(reason ? { destructiveReason: reason } : {}), @@ -465,23 +457,29 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`, // validateCommand covers command existence + required/unexpected-parameter. // stringifiedParam was computed once at the top of the handler so dry-run // and live paths share the same shape. - const validation = validateCommand(deviceId, command, stringifiedParam, effectiveType); + const validation = validateCommand(deviceId, effectiveCommand, stringifiedParam, effectiveType); if (!validation.ok) { return mcpError( 'usage', 2, validation.error.message, - { hint: validation.error.hint, context: { validationKind: validation.error.kind } }, + { + hint: validation.error.hint, + context: { validationKind: validation.error.kind, deviceType: typeName, command: effectiveCommand }, + }, ); } + if (validation.normalized) { + effectiveCommand = validation.normalized; + } // R-2: run B-1 client-side parameter validator (range/format checks). // Customize commands (user-defined IR buttons) opt out — the catalog // cannot know their expected shape. if (effectiveType !== 'customize') { - const pv = validateParameter(typeName, command, stringifiedParam); + const pv = validateParameter(typeName, effectiveCommand, stringifiedParam); if (!pv.ok) { return mcpError('usage', 2, pv.error, { - context: { deviceType: typeName, command, parameter: stringifiedParam, validationKind: 'param-out-of-range' }, + context: { deviceType: typeName, command: effectiveCommand, parameter: stringifiedParam, validationKind: 'param-out-of-range' }, }); } if (pv.normalized !== undefined) { @@ -491,7 +489,7 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`, let result: unknown; try { - result = await executeCommand(deviceId, command, effectiveParameter, effectiveType, undefined, { + result = await executeCommand(deviceId, effectiveCommand, effectiveParameter, effectiveType, undefined, { idempotencyKey, }); } catch (err) { @@ -517,7 +515,7 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`, reason: string; suggestedFollowup: string; }; - } = { ok: true as const, command, deviceId, result }; + } = { ok: true as const, command: effectiveCommand, deviceId, result }; if (isIr) { structured.verification = { verifiable: false, @@ -932,18 +930,19 @@ Inspect locally: .option('--auth-token ', 'Bearer token for HTTP requests (required for --bind 0.0.0.0; falls back to SWITCHBOT_MCP_TOKEN env var)', stringArg('--auth-token')) .option('--cors-origin ', 'Allowed CORS origin(s) for HTTP (repeatable)', stringArg('--cors-origin')) .option('--rate-limit ', 'Max requests per minute per profile (default 60)', intArg('--rate-limit', { min: 1 }), '60') + .addHelpText('after', ` +Examples: + $ switchbot mcp serve + $ switchbot mcp serve --port 8787 + $ switchbot mcp serve --port 8787 --bind 127.0.0.1 --auth-token your-token + $ switchbot mcp serve --port 8787 --bind 0.0.0.0 --auth-token your-token +`) .action(async (options: { port?: string; bind?: string; authToken?: string; corsOrigin?: string | string[]; rateLimit?: string }) => { try { if (options.port) { const port = Number(options.port); if (!Number.isFinite(port) || port < 1 || port > 65535) { - const msg = `Invalid --port "${options.port}". Must be 1-65535.`; - if (isJsonMode()) { - emitJsonError({ code: 2, kind: 'usage', message: msg }); - } else { - console.error(msg); - } - process.exit(2); + exitWithError(`Invalid --port "${options.port}". Must be 1-65535.`); } const bind = options.bind ?? '127.0.0.1'; @@ -954,13 +953,7 @@ Inspect locally: // Guard: refuse to bind non-localhost without auth const isLocalhost = bind === '127.0.0.1' || bind === 'localhost' || bind === '::1'; if (!isLocalhost && !authToken) { - const msg = 'Refusing to listen on 0.0.0.0 without --auth-token. Pass --auth-token or bind to localhost (default).'; - if (isJsonMode()) { - emitJsonError({ code: 2, kind: 'usage', message: msg }); - } else { - console.error(msg); - } - process.exit(2); + exitWithError('Refusing to listen on 0.0.0.0 without --auth-token. Pass --auth-token or bind to localhost (default).'); } const { createServer } = await import('node:http'); @@ -1188,6 +1181,29 @@ process_uptime_seconds ${Math.floor(process.uptime())} const server = createSwitchBotMcpServer({ eventManager }); const transport = new StdioServerTransport(); await server.connect(transport); + + let isShuttingDown = false; + const gracefulShutdown = async () => { + if (isShuttingDown) return; + isShuttingDown = true; + console.error('Shutting down...'); + // Force exit after 30s if shutdown hangs (e.g. stuck MQTT disconnect). + const forceExit = setTimeout(() => { + console.error('Force exiting after 30s timeout'); + process.exit(1); + }, 30000); + forceExit.unref(); + try { + await eventManager.shutdown(); + } catch (err) { + console.error('Error during shutdown:', err instanceof Error ? err.message : String(err)); + } + process.exit(0); + }; + + process.on('SIGTERM', gracefulShutdown); + process.on('SIGINT', gracefulShutdown); + process.stdin.on('end', gracefulShutdown); } catch (error) { handleError(error); } diff --git a/src/config.ts b/src/config.ts index 86cfe66..9f39ad1 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3,6 +3,7 @@ import path from 'node:path'; import os from 'node:os'; import { getConfigPath } from './utils/flags.js'; import { getActiveProfile } from './lib/request-context.js'; +import { emitJsonError, isJsonMode } from './utils/output.js'; export interface SwitchBotConfig { token: string; @@ -13,6 +14,17 @@ export interface SwitchBotConfig { defaults?: { flags?: string[] }; } +export interface ConfigSummary { + source: 'env' | 'file' | 'none' | 'invalid'; + path?: string; + token?: string; + secret?: string; + label?: string; + description?: string; + dailyCap?: number; + defaultFlags?: string[]; +} + function sanitizeOptionalString(v: unknown): string | undefined { if (typeof v !== 'string') return undefined; const trimmed = v.trim(); @@ -63,7 +75,12 @@ export function loadConfig(): SwitchBotConfig { const hint = profile ? `No credentials configured for profile "${profile}". Run: switchbot --profile ${profile} config set-token ` : 'No credentials configured. Run: switchbot config set-token '; - console.error(`${hint}\nOr set SWITCHBOT_TOKEN and SWITCHBOT_SECRET environment variables.`); + const msg = `${hint}\nOr set SWITCHBOT_TOKEN and SWITCHBOT_SECRET environment variables.`; + if (isJsonMode()) { + emitJsonError({ code: 1, kind: 'runtime', message: hint }); + } else { + console.error(msg); + } process.exit(1); } @@ -71,12 +88,20 @@ export function loadConfig(): SwitchBotConfig { const raw = fs.readFileSync(file, 'utf-8'); const cfg = JSON.parse(raw) as SwitchBotConfig; if (!cfg.token || !cfg.secret) { - console.error('Invalid config format. Please re-run: switchbot config set-token'); + if (isJsonMode()) { + emitJsonError({ code: 1, kind: 'runtime', message: 'Invalid config format. Please re-run: switchbot config set-token' }); + } else { + console.error('Invalid config format. Please re-run: switchbot config set-token'); + } process.exit(1); } return cfg; } catch { - console.error('Failed to read config file. Please re-run: switchbot config set-token'); + if (isJsonMode()) { + emitJsonError({ code: 1, kind: 'runtime', message: 'Failed to read config file. Please re-run: switchbot config set-token' }); + } else { + console.error('Failed to read config file. Please re-run: switchbot config set-token'); + } process.exit(1); } } @@ -171,34 +196,62 @@ export function readProfileMeta(profile?: string): { } export function showConfig(): void { + const summary = getConfigSummary(); + if (summary.source === 'env') { + console.log('Credential source: environment variables'); + console.log(`token : ${summary.token ?? ''}`); + console.log(`secret: ${summary.secret ?? ''}`); + return; + } + if (summary.source === 'none') { + console.log('No credentials configured'); + return; + } + if (summary.source === 'invalid') { + console.error('Failed to read config file'); + return; + } + console.log(`Credential source: ${summary.path}`); + if (summary.label) console.log(`label : ${summary.label}`); + if (summary.description) console.log(`desc : ${summary.description}`); + console.log(`token : ${summary.token ?? ''}`); + console.log(`secret: ${summary.secret ?? ''}`); + if (summary.dailyCap) console.log(`limits: dailyCap=${summary.dailyCap}`); + if (summary.defaultFlags?.length) console.log(`defaults: ${summary.defaultFlags.join(' ')}`); +} + +export function getConfigSummary(): ConfigSummary { const envToken = process.env.SWITCHBOT_TOKEN; const envSecret = process.env.SWITCHBOT_SECRET; if (envToken && envSecret) { - console.log('Credential source: environment variables'); - console.log(`token : ${maskCredential(envToken)}`); - console.log(`secret: ${maskSecret(envSecret)}`); - return; + return { + source: 'env', + token: maskCredential(envToken), + secret: maskSecret(envSecret), + }; } const file = configFilePath(); if (!fs.existsSync(file)) { - console.log('No credentials configured'); - return; + return { source: 'none' }; } try { const raw = fs.readFileSync(file, 'utf-8'); const cfg = JSON.parse(raw) as SwitchBotConfig; - console.log(`Credential source: ${file}`); - if (cfg.label) console.log(`label : ${cfg.label}`); - if (cfg.description) console.log(`desc : ${cfg.description}`); - console.log(`token : ${maskCredential(cfg.token)}`); - console.log(`secret: ${maskSecret(cfg.secret)}`); - if (cfg.limits?.dailyCap) console.log(`limits: dailyCap=${cfg.limits.dailyCap}`); - if (cfg.defaults?.flags?.length) console.log(`defaults: ${cfg.defaults.flags.join(' ')}`); + return { + source: 'file', + path: file, + label: cfg.label, + description: cfg.description, + token: maskCredential(cfg.token), + secret: maskSecret(cfg.secret), + dailyCap: cfg.limits?.dailyCap, + defaultFlags: cfg.defaults?.flags, + }; } catch { - console.error('Failed to read config file'); + return { source: 'invalid', path: file }; } } diff --git a/src/devices/catalog.ts b/src/devices/catalog.ts index 5537b57..43983de 100644 --- a/src/devices/catalog.ts +++ b/src/devices/catalog.ts @@ -259,7 +259,7 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ category: 'physical', description: 'Entry-level robot vacuum with start/stop/dock and four suction power levels.', role: 'cleaning', - aliases: ['Robot Vacuum Cleaner S1 Plus', 'K10+'], + aliases: ['Robot Vacuum', 'Robot Vacuum Cleaner S1 Plus', 'K10+'], commands: [ { command: 'start', parameter: '—', description: 'Start cleaning', idempotent: true }, { command: 'stop', parameter: '—', description: 'Stop cleaning', idempotent: true }, diff --git a/src/index.ts b/src/index.ts index 066b9fe..a37d5e8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ import { createRequire } from 'node:module'; import chalk from 'chalk'; import { intArg, stringArg, enumArg } from './utils/arg-parsers.js'; import { parseDurationToMs } from './utils/flags.js'; +import { emitJsonError, isJsonMode } from './utils/output.js'; import { registerConfigCommand } from './commands/config.js'; import { registerDevicesCommand } from './commands/devices.js'; import { registerScenesCommand } from './commands/scenes.js'; @@ -31,6 +32,11 @@ if (process.argv.includes('--no-color') || Boolean(process.env.NO_COLOR)) { } const program = new Command(); +if (isJsonMode()) { + // In --json mode, commander writes plain-text usage errors by default. + // Silence that channel and emit a single structured error in the catch block. + program.configureOutput({ writeErr: () => {} }); +} // Top-level subcommand names. Used by stringArg to produce clearer errors when // a value is omitted and the next argv token turns out to be a subcommand name. @@ -141,10 +147,7 @@ Docs: https://github.com/OpenWonderLabs/SwitchBotAPI // per-command: subcommand errors won't bubble to the root override, so walk // every registered command and apply the same handler. const usageExitHandler = (err: CommanderError): never => { - if (err.code === 'commander.helpDisplayed' || err.code === 'commander.version') { - process.exit(0); - } - process.exit(2); + throw err; }; function applyExitOverride(cmd: Command): void { @@ -171,6 +174,9 @@ try { if (err.code === 'commander.helpDisplayed' || err.code === 'commander.version') { process.exit(0); } + if (isJsonMode()) { + emitJsonError({ code: 2, kind: 'usage', message: err.message }); + } process.exit(2); } throw err; diff --git a/src/lib/devices.ts b/src/lib/devices.ts index 79fda98..89dd257 100644 --- a/src/lib/devices.ts +++ b/src/lib/devices.ts @@ -76,7 +76,7 @@ export class DeviceNotFoundError extends Error { export class CommandValidationError extends Error { constructor( message: string, - public readonly kind: 'unknown-command' | 'unexpected-parameter' | 'missing-parameter', + public readonly kind: 'read-only-device' | 'unknown-command' | 'unexpected-parameter' | 'missing-parameter', public readonly hint?: string ) { super(message); @@ -236,7 +236,16 @@ export function validateCommand( if (!match || Array.isArray(match)) return { ok: true }; const builtinCommands = match.commands.filter((c) => c.commandType !== 'customize'); - if (builtinCommands.length === 0) return { ok: true }; + if (match.readOnly || builtinCommands.length === 0) { + return { + ok: false, + error: new CommandValidationError( + `${cached.name} (${cached.type}) is a read-only sensor — it has no control commands.`, + 'read-only-device', + `Use 'switchbot devices status ${deviceId}' to read this device instead.`, + ), + }; + } let spec = builtinCommands.find((c) => c.command === cmd); let caseNormalizedFrom: string | undefined; diff --git a/src/schema/field-aliases.ts b/src/schema/field-aliases.ts new file mode 100644 index 0000000..b6327e0 --- /dev/null +++ b/src/schema/field-aliases.ts @@ -0,0 +1,45 @@ +import { UsageError } from '../utils/output.js'; + +export const FIELD_ALIASES: Record = { + deviceId: ['id'], + deviceName: ['name'], + deviceType: ['type'], + controlType: ['control'], + roomName: ['room'], + roomID: ['roomid'], + familyName: ['family'], + hubDeviceId: ['hub'], + enableCloudService: ['cloud'], + alias: ['alias'], +}; + +export function resolveField( + input: string, + allowedCanonical: readonly string[], +): string { + const normalized = input.trim().toLowerCase(); + if (!normalized) { + throw new UsageError('Field name cannot be empty.'); + } + + for (const canonical of allowedCanonical) { + if (canonical.toLowerCase() === normalized) return canonical; + const aliases = FIELD_ALIASES[canonical] ?? []; + if (aliases.some((a) => a.toLowerCase() === normalized)) return canonical; + } + throw new UsageError( + `Unknown field "${input}". Supported: ${listSupportedFieldInputs(allowedCanonical).join(', ')}`, + ); +} + +export function listSupportedFieldInputs( + allowedCanonical: readonly string[], +): string[] { + const out = new Set(); + for (const canonical of allowedCanonical) { + out.add(canonical); + for (const alias of FIELD_ALIASES[canonical] ?? []) out.add(alias); + } + return [...out]; +} + diff --git a/src/utils/filter.ts b/src/utils/filter.ts index 0ce1cfc..379a860 100644 --- a/src/utils/filter.ts +++ b/src/utils/filter.ts @@ -20,6 +20,11 @@ export interface FilterClause { regex?: RegExp; } +export interface ParseFilterOptions { + resolveKey?: (key: string) => string; + supportedKeys?: readonly string[]; +} + export class FilterSyntaxError extends Error { constructor(message: string) { super(message); @@ -46,6 +51,7 @@ export class FilterSyntaxError extends Error { export function parseFilterExpr( expr: string | undefined, allowedKeys: readonly string[], + options?: ParseFilterOptions, ): FilterClause[] { if (!expr) return []; const parts = expr.split(',').map((p) => p.trim()).filter((p) => p.length > 0); @@ -102,13 +108,26 @@ export function parseFilterExpr( if (!raw) { throw new FilterSyntaxError(`Empty value for filter clause "${part}"`); } - if (!allowedKeys.includes(key)) { + let resolvedKey = key; + if (options?.resolveKey) { + try { + resolvedKey = options.resolveKey(key); + } catch (err) { + if (err instanceof Error) { + throw new FilterSyntaxError(err.message); + } + throw err; + } + } + + if (!allowedKeys.includes(resolvedKey)) { + const printableKeys = options?.supportedKeys ?? allowedKeys; throw new FilterSyntaxError( - `Unknown filter key "${key}" — supported: ${allowedKeys.join(', ')}`, + `Unknown filter key "${key}" – supported: ${printableKeys.join(', ')}`, ); } - clauses.push({ key, op, raw, regex }); + clauses.push({ key: resolvedKey, op, raw, regex }); } return clauses; diff --git a/src/utils/output.ts b/src/utils/output.ts index 9f57aec..4e523f3 100644 --- a/src/utils/output.ts +++ b/src/utils/output.ts @@ -34,6 +34,32 @@ export function emitJsonError(errorPayload: Record): void { } } +interface ExitWithErrorOptions { + message: string; + kind?: 'usage' | 'guard' | 'runtime'; + code?: number; + hint?: string; + context?: Record; + extra?: Record; +} + +export function exitWithError(messageOrOpts: string | ExitWithErrorOptions): never { + const opts: ExitWithErrorOptions = + typeof messageOrOpts === 'string' ? { message: messageOrOpts } : messageOrOpts; + const { message, kind = 'usage', code = 2, hint, context, extra } = opts; + if (isJsonMode()) { + const payload: Record = { code, kind, message }; + if (hint) payload.hint = hint; + if (context) payload.context = context; + if (extra) Object.assign(payload, extra); + emitJsonError(payload); + } else { + console.error(message); + if (hint) console.error(hint); + } + process.exit(code); +} + function escapeMarkdownCell(s: string): string { // Pipes break markdown table layout; backslash-escape them. Collapse // newlines into
so each row stays on one line. diff --git a/tests/commands/catalog.test.ts b/tests/commands/catalog.test.ts index d6478cb..05c35e6 100644 --- a/tests/commands/catalog.test.ts +++ b/tests/commands/catalog.test.ts @@ -92,6 +92,13 @@ describe('catalog show', () => { expect(out).toMatch(/Smart Lock/); }); + it('resolves "Robot Vacuum" to a single catalog entry', async () => { + const { stdout, exitCode } = await runCli(registerCatalogCommand, ['catalog', 'show', 'Robot', 'Vacuum']); + expect(exitCode).toBeNull(); + const out = stdout.join('\n'); + expect(out).toContain('Robot Vacuum Cleaner S1'); + }); + it('--source built-in ignores overlay', async () => { writeOverlay([{ type: 'Bot', remove: true }]); const { stdout } = await runCli(registerCatalogCommand, ['catalog', 'show', '--source', 'built-in']); diff --git a/tests/commands/config.test.ts b/tests/commands/config.test.ts index 969fff3..ecf56d9 100644 --- a/tests/commands/config.test.ts +++ b/tests/commands/config.test.ts @@ -6,6 +6,7 @@ import path from 'node:path'; const configMock = vi.hoisted(() => ({ saveConfig: vi.fn(), showConfig: vi.fn(), + getConfigSummary: vi.fn(() => ({ source: 'none' })), listProfiles: vi.fn(() => [] as string[]), readProfileMeta: vi.fn(() => null), })); @@ -19,6 +20,8 @@ describe('config command', () => { beforeEach(() => { configMock.saveConfig.mockReset(); configMock.showConfig.mockReset(); + configMock.getConfigSummary.mockReset(); + configMock.getConfigSummary.mockReturnValue({ source: 'none' }); configMock.listProfiles.mockReset(); configMock.listProfiles.mockReturnValue([]); }); @@ -66,6 +69,20 @@ describe('config command', () => { await runCli(registerConfigCommand, ['config', 'show']); expect(configMock.showConfig).toHaveBeenCalledTimes(1); }); + + it('emits structured JSON in --json mode', async () => { + configMock.getConfigSummary.mockReturnValue({ + source: 'file', + path: '/tmp/config.json', + token: 'abcd****wxyz', + secret: 'ab****yz', + }); + const res = await runCli(registerConfigCommand, ['--json', 'config', 'show']); + const parsed = JSON.parse(res.stdout.join('\n')); + expect(parsed.data.source).toBe('file'); + expect(parsed.data.path).toBe('/tmp/config.json'); + expect(parsed.data.token).toBe('abcd****wxyz'); + }); }); describe('list-profiles', () => { diff --git a/tests/commands/devices.test.ts b/tests/commands/devices.test.ts index 485767d..40b5928 100644 --- a/tests/commands/devices.test.ts +++ b/tests/commands/devices.test.ts @@ -395,6 +395,14 @@ describe('devices command', () => { expect(lines[1]).toBe('ABC123\tLiving Lamp'); }); + it('--fields roomName resolves to the room column (API canonical alias)', async () => { + apiMock.__instance.get.mockResolvedValue({ data: { body: sampleBody } }); + const res = await runCli(registerDevicesCommand, ['devices', 'list', '--format', 'tsv', '--fields', 'roomName']); + const lines = res.stdout.join('\n').split('\n'); + expect(lines[0]).toBe('room'); + expect(lines).toContain('Living Room'); + }); + it('--format=id outputs one deviceId per line', async () => { apiMock.__instance.get.mockResolvedValue({ data: { body: sampleBody } }); const res = await runCli(registerDevicesCommand, ['devices', 'list', '--format', 'id']); @@ -453,6 +461,30 @@ describe('devices command', () => { expect(out).toContain('IR-001'); }); + it('--filter deviceType=Color Bulb accepts API canonical field name', async () => { + apiMock.__instance.get.mockResolvedValue({ data: { body: sampleBody } }); + const res = await runCli(registerDevicesCommand, ['devices', 'list', '--filter', 'deviceType=Color Bulb', '--json']); + const out = JSON.parse(res.stdout.join('\n')); + expect(out.data.deviceList).toHaveLength(1); + expect(out.data.deviceList[0].deviceId).toBe('ABC123'); + }); + + it('--filter deviceName=Kitchen accepts API canonical field name', async () => { + apiMock.__instance.get.mockResolvedValue({ data: { body: sampleBody } }); + const res = await runCli(registerDevicesCommand, ['devices', 'list', '--filter', 'deviceName=Kitchen', '--json']); + const out = JSON.parse(res.stdout.join('\n')); + expect(out.data.deviceList).toHaveLength(1); + expect(out.data.deviceList[0].deviceId).toBe('BLE-001'); + }); + + it('--filter deviceId=ABC123 matches by id', async () => { + apiMock.__instance.get.mockResolvedValue({ data: { body: sampleBody } }); + const res = await runCli(registerDevicesCommand, ['devices', 'list', '--filter', 'deviceId=ABC123', '--json']); + const out = JSON.parse(res.stdout.join('\n')); + expect(out.data.deviceList).toHaveLength(1); + expect(out.data.deviceList[0].deviceId).toBe('ABC123'); + }); + it('--filter --json applies filter to JSON output', async () => { apiMock.__instance.get.mockResolvedValue({ data: { body: sampleBody } }); const res = await runCli(registerDevicesCommand, ['devices', 'list', '--filter', 'category=physical', '--json']); @@ -493,6 +525,23 @@ describe('devices command', () => { expect(out.data.deviceList).toHaveLength(1); expect(out.data.deviceList[0].deviceId).toBe('ABC123'); }); + + it('--filter controlType=Bot filters by controlType', async () => { + apiMock.__instance.get.mockResolvedValue({ data: { body: sampleBody } }); + const res = await runCli(registerDevicesCommand, ['devices', 'list', '--filter', 'controlType=Bot', '--json']); + const out = JSON.parse(res.stdout.join('\n')); + expect(out.data.deviceList).toHaveLength(1); + expect(out.data.deviceList[0].deviceId).toBe('BLE-001'); + }); + + it('--filter roomName=Living filters by roomName (API canonical name)', async () => { + apiMock.__instance.get.mockResolvedValue({ data: { body: sampleBody } }); + const res = await runCli(registerDevicesCommand, ['devices', 'list', '--filter', 'roomName=Living', '--json']); + const out = JSON.parse(res.stdout.join('\n')); + // ABC123 and NOHUB-1 both have roomName='Living Room'; BLE-001 has null + expect(out.data.deviceList).toHaveLength(2); + expect(out.data.deviceList.map((d: { deviceId: string }) => d.deviceId)).not.toContain('BLE-001'); + }); }); // ===================================================================== @@ -1749,6 +1798,32 @@ describe('devices command', () => { expect(apiMock.__instance.post).not.toHaveBeenCalled(); }); + it('rejects read-only device commands (meter) with exit 2', async () => { + updateCacheFromDeviceList({ + deviceList: [ + { deviceId: DID, deviceName: 'Bedroom Meter', deviceType: 'Meter' }, + ], + infraredRemoteList: [], + }); + const res = await runCmd('turnOn'); + expect(res.exitCode).toBe(2); + expect(res.stderr.join('\n')).toMatch(/read-only sensor/i); + expect(apiMock.__instance.post).not.toHaveBeenCalled(); + }); + + it('--allow-unknown-device does not bypass read-only rejection for cached devices', async () => { + updateCacheFromDeviceList({ + deviceList: [ + { deviceId: DID, deviceName: 'Bedroom Meter', deviceType: 'Meter' }, + ], + infraredRemoteList: [], + }); + const res = await runCmd('turnOn', '--allow-unknown-device'); + expect(res.exitCode).toBe(2); + expect(res.stderr.join('\n')).toMatch(/read-only sensor/i); + expect(apiMock.__instance.post).not.toHaveBeenCalled(); + }); + it('rejects a parameter on a no-param command with exit 2', async () => { const res = await runCmd('turnOn', 'someparam'); expect(res.exitCode).toBe(2); diff --git a/tests/commands/mcp-stdio.test.ts b/tests/commands/mcp-stdio.test.ts new file mode 100644 index 0000000..3138b69 --- /dev/null +++ b/tests/commands/mcp-stdio.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from 'vitest'; +import { spawn } from 'node:child_process'; +import path from 'node:path'; + +interface ProcResult { + code: number | null; + signal: NodeJS.Signals | null; + stdout: string; + stderr: string; +} + +async function runMcpStdioOnce(timeoutMs = 12000): Promise { + const cliPath = path.resolve(process.cwd(), 'dist/index.js'); + const child = spawn(process.execPath, [cliPath, 'mcp', 'serve'], { + stdio: ['pipe', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk) => { stdout += chunk; }); + child.stderr.on('data', (chunk) => { stderr += chunk; }); + + const init = { + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'vitest', version: '1' }, + }, + }; + const notify = { jsonrpc: '2.0', method: 'notifications/initialized' }; + const tools = { jsonrpc: '2.0', id: 2, method: 'tools/list' }; + child.stdin.write(`${JSON.stringify(init)}\n`); + child.stdin.write(`${JSON.stringify(notify)}\n`); + child.stdin.write(`${JSON.stringify(tools)}\n`); + child.stdin.end(); + + return await new Promise((resolve, reject) => { + const t = setTimeout(() => { + child.kill('SIGKILL'); + reject(new Error(`mcp stdio did not exit within ${timeoutMs}ms`)); + }, timeoutMs); + child.on('error', (err) => { + clearTimeout(t); + reject(err); + }); + child.on('close', (code, signal) => { + clearTimeout(t); + resolve({ code, signal, stdout, stderr }); + }); + }); +} + +describe('mcp serve stdio lifecycle', () => { + it('exits gracefully on stdin EOF after initialize/tools/list', async () => { + const res = await runMcpStdioOnce(); + expect(res.signal).toBeNull(); + expect(res.code).toBe(0); + expect(res.stdout).toContain('"id":1'); + expect(res.stdout).toContain('"id":2'); + }); +}); + diff --git a/tests/commands/mcp.test.ts b/tests/commands/mcp.test.ts index c4074d5..c47b86a 100644 --- a/tests/commands/mcp.test.ts +++ b/tests/commands/mcp.test.ts @@ -179,6 +179,22 @@ describe('mcp server', () => { expect(apiMock.__instance.post).not.toHaveBeenCalled(); }); + it('send_command rejects read-only device commands before calling the API', async () => { + cacheMock.map.set('METER1', { type: 'Meter', name: 'Bedroom Meter', category: 'physical' }); + const { client } = await pair(); + + const res = await client.callTool({ + name: 'send_command', + arguments: { deviceId: 'METER1', command: 'turnOn' }, + }); + + expect(res.isError).toBe(true); + const parsed = JSON.parse((res.content as Array<{ text: string }>)[0].text); + expect(parsed.error.kind).toBe('usage'); + expect(parsed.error.context.validationKind).toBe('read-only-device'); + expect(apiMock.__instance.post).not.toHaveBeenCalled(); + }); + it('send_command sends non-destructive commands through without confirm', async () => { cacheMock.map.set('BULB1', { type: 'Color Bulb', name: 'Desk Lamp', category: 'physical' }); apiMock.__instance.post.mockResolvedValueOnce({ @@ -295,6 +311,23 @@ describe('mcp server', () => { expect(structured.wouldSend?.parameter).toBe('255:0:0'); }); + it('send_command normalizes command casing (e.g. turnon → turnOn)', async () => { + cacheMock.map.set('BOT1', { type: 'Bot', name: 'My Bot', category: 'physical' }); + apiMock.__instance.post.mockResolvedValueOnce({ + data: { statusCode: 100, body: {} }, + }); + const { client } = await pair(); + + const res = await client.callTool({ + name: 'send_command', + arguments: { deviceId: 'BOT1', command: 'turnon' }, + }); + + expect(res.isError).toBeFalsy(); + const [, body] = apiMock.__instance.post.mock.calls[0]; + expect(body).toMatchObject({ command: 'turnOn' }); + }); + it('list_devices returns the raw API body and refreshes the cache', async () => { const body = { deviceList: [], infraredRemoteList: [] }; apiMock.__instance.get.mockResolvedValueOnce({ data: { statusCode: 100, body } });