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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,4 @@ CLAUDE.md
# Init transcript
2026-04-10-155920-command-messageinitcommand-message.txt
tmp/
smoke-v3/
16 changes: 3 additions & 13 deletions src/commands/batch.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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);
}
Expand Down
8 changes: 8 additions & 0 deletions src/commands/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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;
Expand Down
6 changes: 5 additions & 1 deletion src/commands/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -250,6 +250,10 @@ Files are written with mode 0600. Profiles live under ~/.switchbot/profiles/<nam
.command('show')
.description('Show the current credential source and a masked secret')
.action(() => {
if (isJsonMode()) {
printJson(getConfigSummary());
return;
}
showConfig();
});

Expand Down
202 changes: 109 additions & 93 deletions src/commands/devices.ts

Large diffs are not rendered by default.

132 changes: 74 additions & 58 deletions src/commands/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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 /
Expand All @@ -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,
};
Expand All @@ -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 } : {}),
Expand All @@ -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) {
Expand All @@ -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) {
Expand All @@ -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,
Expand Down Expand Up @@ -932,18 +930,19 @@ Inspect locally:
.option('--auth-token <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 <url>', 'Allowed CORS origin(s) for HTTP (repeatable)', stringArg('--cors-origin'))
.option('--rate-limit <n>', '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';
Expand All @@ -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 <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 <token> or bind to localhost (default).');
}

const { createServer } = await import('node:http');
Expand Down Expand Up @@ -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);
}
Expand Down
Loading
Loading