From 3dff918ed09952944d471c36c149e011c5ba1bf5 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Tue, 21 Apr 2026 14:16:17 +0800 Subject: [PATCH 01/10] feat(fields): unify canonical field alias resolution for list filters --- src/commands/devices.ts | 47 ++++++++++++++++++++++++++-------- src/schema/field-aliases.ts | 46 +++++++++++++++++++++++++++++++++ src/utils/filter.ts | 25 +++++++++++++++--- tests/commands/devices.test.ts | 37 ++++++++++++++++++++++++++ 4 files changed, 141 insertions(+), 14 deletions(-) create mode 100644 src/schema/field-aliases.ts diff --git a/src/commands/devices.ts b/src/commands/devices.ts index 591164d..2c7b6c0 100644 --- a/src/commands/devices.ts +++ b/src/commands/devices.ts @@ -27,6 +27,7 @@ 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'; export function registerDevicesCommand(program: Command): void { const COMMAND_TYPES = ['command', 'customize'] as const; @@ -94,7 +95,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, roomName/room, category.', stringArg('--filter')) .action(async (options: { wide?: boolean; showHidden?: boolean; filter?: string }) => { try { const body = await fetchDeviceList(); @@ -106,18 +107,32 @@ 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'] as const; + const LIST_FILTER_CANONICAL = ['deviceId', 'deviceName', 'deviceType', 'roomName', 'category'] as const; + const LIST_FILTER_TO_RUNTIME: Record = { + deviceId: 'deviceId', + deviceName: 'name', + deviceType: 'type', + 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 }) => { if (!listClauses || listClauses.length === 0) return true; for (const c of listClauses) { const fieldVal = (entry as Record)[c.key] ?? ''; @@ -129,11 +144,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 || '' }) ); 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 || '' }); }); printJson({ ok: true, deviceList: filteredDeviceList, infraredRemoteList: filteredIrList }); } else { @@ -150,7 +165,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 || '' })) continue; rows.push([ d.deviceId, d.deviceName, @@ -169,7 +184,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 || '' })) continue; rows.push([ d.deviceId, d.deviceName, @@ -193,9 +208,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') { diff --git a/src/schema/field-aliases.ts b/src/schema/field-aliases.ts new file mode 100644 index 0000000..1c5c71f --- /dev/null +++ b/src/schema/field-aliases.ts @@ -0,0 +1,46 @@ +import { UsageError } from '../utils/output.js'; + +export const FIELD_ALIASES: Record = { + deviceId: ['id'], + deviceName: ['name'], + deviceType: ['type'], + controlType: ['control', 'category'], + roomName: ['room'], + roomID: ['roomid'], + familyName: ['family'], + hubDeviceId: ['hub'], + enableCloudService: ['cloud'], + category: ['category'], + 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/tests/commands/devices.test.ts b/tests/commands/devices.test.ts index 485767d..d805067 100644 --- a/tests/commands/devices.test.ts +++ b/tests/commands/devices.test.ts @@ -453,6 +453,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']); @@ -1749,6 +1773,19 @@ 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('rejects a parameter on a no-param command with exit 2', async () => { const res = await runCmd('turnOn', 'someparam'); expect(res.exitCode).toBe(2); From 9d56d8b4658227594b59945166cbef6604f710f5 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Tue, 21 Apr 2026 14:17:39 +0800 Subject: [PATCH 02/10] fix(validation): align CLI and MCP command gating for read-only and unknown commands --- src/commands/mcp.ts | 97 +++++++++++++++++++++----------------- src/lib/devices.ts | 13 ++++- tests/commands/mcp.test.ts | 16 +++++++ 3 files changed, 82 insertions(+), 44 deletions(-) diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index 0e58c85..2cecb14 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -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,6 +930,13 @@ 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) { @@ -1188,6 +1193,14 @@ process_uptime_seconds ${Math.floor(process.uptime())} const server = createSwitchBotMcpServer({ eventManager }); const transport = new StdioServerTransport(); await server.connect(transport); + let eofHandled = false; + process.stdin.on('end', () => { + if (eofHandled) return; + eofHandled = true; + setTimeout(() => { + eventManager.shutdown().finally(() => process.exit(0)); + }, 500); + }); } catch (error) { handleError(error); } 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/tests/commands/mcp.test.ts b/tests/commands/mcp.test.ts index c4074d5..8c4b080 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({ From 72c021d1d98cfc1aa78e256fbce7e01301a18790 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Tue, 21 Apr 2026 14:17:47 +0800 Subject: [PATCH 03/10] fix(cli): emit structured JSON for commander usage errors under --json --- src/index.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/index.ts b/src/index.ts index 066b9fe..a44b8b3 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 } 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,14 @@ if (process.argv.includes('--no-color') || Boolean(process.env.NO_COLOR)) { } const program = new Command(); +const jsonModeRequested = process.argv.includes('--json') + || process.argv.includes('--format=json') + || process.argv.some((arg, idx) => arg === '--format' && process.argv[idx + 1] === 'json'); +if (jsonModeRequested) { + // 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 +150,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 +177,9 @@ try { if (err.code === 'commander.helpDisplayed' || err.code === 'commander.version') { process.exit(0); } + if (jsonModeRequested) { + emitJsonError({ code: 2, kind: 'usage', message: err.message }); + } process.exit(2); } throw err; From fabdf387c7176e65fcae02727e7836cba1ee738f Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Tue, 21 Apr 2026 14:18:00 +0800 Subject: [PATCH 04/10] fix(catalog): add Robot Vacuum alias resolution and show examples --- src/commands/catalog.ts | 8 ++++++++ src/devices/catalog.ts | 2 +- tests/commands/catalog.test.ts | 7 +++++++ 3 files changed, 16 insertions(+), 1 deletion(-) 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/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/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']); From e274aed19d2a3c55fe4e4598c72560340f64bf73 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Tue, 21 Apr 2026 14:18:07 +0800 Subject: [PATCH 05/10] fix(config): make config show --json parseable via structured summary --- src/commands/config.ts | 6 +++- src/config.ts | 67 +++++++++++++++++++++++++++-------- tests/commands/config.test.ts | 17 +++++++++ 3 files changed, 75 insertions(+), 15 deletions(-) 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/config.ts b/src/config.ts index 86cfe66..cc4fc91 100644 --- a/src/config.ts +++ b/src/config.ts @@ -13,6 +13,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(); @@ -171,34 +182,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/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', () => { From ba6ed6f202f4eaf40336bb89b99de3cf3d2645a8 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Tue, 21 Apr 2026 17:36:30 +0800 Subject: [PATCH 06/10] refactor/fix: exitWithError helper, isJsonMode dedup, filter+alias fixes, MCP shutdown, expand hints - output.ts: add exitWithError() overload (string | options) to eliminate ~17 repeated isJsonMode()/emitJsonError()/process.exit() blocks - index.ts, config.ts: replace private isJsonRequested() duplicates with shared isJsonMode() import - field-aliases.ts: remove 'category' from controlType aliases to prevent collision with the standalone category filter key - devices.ts: add controlType to LIST_FILTER_CANONICAL / LIST_FILTER_TO_RUNTIME / matchesFilter entry shape so --filter controlType=X works; replace 3 inline error blocks with exitWithError(); add EXPAND_HINTS map and tip line in renderCatalogEntry() + expandHint field in --json output for device types supported by 'devices expand' (AC, Curtain, Curtain 3, Blind Tilt, Relay Switch 2PM) - batch.ts: replace FilterSyntaxError and no-targets error blocks with exitWithError() - mcp.ts: remove dead loadConfig import; replace 2 inline error blocks with exitWithError(); replace EOF-only stdin handler with graceful shutdown (SIGTERM/SIGINT + stdin end, isShuttingDown guard, 30s force-exit timeout) --- src/commands/batch.ts | 16 +--- src/commands/devices.ts | 171 +++++++++++++++++------------------- src/commands/mcp.ts | 51 ++++++----- src/config.ts | 20 ++++- src/index.ts | 9 +- src/schema/field-aliases.ts | 3 +- src/utils/output.ts | 26 ++++++ 7 files changed, 158 insertions(+), 138 deletions(-) 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/devices.ts b/src/commands/devices.ts index 2c7b6c0..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'; @@ -29,6 +29,14 @@ 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; const devices = program @@ -95,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: deviceId/id, deviceName/name, deviceType/type, roomName/room, category.', 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(); @@ -107,12 +115,13 @@ Examples: // Parse --filter into a list of clauses. Shared grammar across // `devices list`, `devices batch`, and `events tail` / `mqtt-tail`. - const LIST_KEYS = ['deviceId', 'type', 'name', 'category', 'room'] as const; - const LIST_FILTER_CANONICAL = ['deviceId', 'deviceName', 'deviceType', 'roomName', 'category'] 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', }; @@ -132,7 +141,7 @@ Examples: } } - const matchesFilter = (entry: { deviceId: string; 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] ?? ''; @@ -144,11 +153,11 @@ Examples: if (fmt === 'json' && process.argv.includes('--json')) { if (listClauses) { const filteredDeviceList = deviceList.filter((d) => - matchesFilter({ deviceId: d.deviceId, 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({ deviceId: d.deviceId, 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 { @@ -165,7 +174,7 @@ Examples: for (const d of deviceList) { if (!options.showHidden && deviceMeta.devices[d.deviceId]?.hidden) continue; - if (!matchesFilter({ deviceId: d.deviceId, 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, @@ -184,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({ deviceId: d.deviceId, 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, @@ -464,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()) { @@ -765,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: @@ -786,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, @@ -793,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; } @@ -851,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); } @@ -921,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 2cecb14..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'; /** @@ -942,13 +942,7 @@ Examples: 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'; @@ -959,13 +953,7 @@ Examples: // 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'); @@ -1193,14 +1181,29 @@ process_uptime_seconds ${Math.floor(process.uptime())} const server = createSwitchBotMcpServer({ eventManager }); const transport = new StdioServerTransport(); await server.connect(transport); - let eofHandled = false; - process.stdin.on('end', () => { - if (eofHandled) return; - eofHandled = true; - setTimeout(() => { - eventManager.shutdown().finally(() => process.exit(0)); - }, 500); - }); + + 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 cc4fc91..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; @@ -74,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); } @@ -82,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); } } diff --git a/src/index.ts b/src/index.ts index a44b8b3..a37d5e8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +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 } from './utils/output.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'; @@ -32,10 +32,7 @@ if (process.argv.includes('--no-color') || Boolean(process.env.NO_COLOR)) { } const program = new Command(); -const jsonModeRequested = process.argv.includes('--json') - || process.argv.includes('--format=json') - || process.argv.some((arg, idx) => arg === '--format' && process.argv[idx + 1] === 'json'); -if (jsonModeRequested) { +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: () => {} }); @@ -177,7 +174,7 @@ try { if (err.code === 'commander.helpDisplayed' || err.code === 'commander.version') { process.exit(0); } - if (jsonModeRequested) { + if (isJsonMode()) { emitJsonError({ code: 2, kind: 'usage', message: err.message }); } process.exit(2); diff --git a/src/schema/field-aliases.ts b/src/schema/field-aliases.ts index 1c5c71f..b6327e0 100644 --- a/src/schema/field-aliases.ts +++ b/src/schema/field-aliases.ts @@ -4,13 +4,12 @@ export const FIELD_ALIASES: Record = { deviceId: ['id'], deviceName: ['name'], deviceType: ['type'], - controlType: ['control', 'category'], + controlType: ['control'], roomName: ['room'], roomID: ['roomid'], familyName: ['family'], hubDeviceId: ['hub'], enableCloudService: ['cloud'], - category: ['category'], alias: ['alias'], }; 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. From 3eb4687b5f371837e1a1a567683eeea194d95629 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Tue, 21 Apr 2026 17:37:16 +0800 Subject: [PATCH 07/10] test: add filter, MCP case normalization, and stdio shutdown coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - devices.test.ts: --filter controlType=Bot (verifies the new canonical key), --filter roomName=Living (verifies alias works + null roomName excluded), read-only device with --allow-unknown-device still rejected with exit 2 - mcp.test.ts: send_command case normalization (turnon → turnOn) - mcp-stdio.test.ts: MCP stdio graceful shutdown (stdin EOF, SIGTERM) exits cleanly without hanging --- tests/commands/devices.test.ts | 38 ++++++++++++++++++ tests/commands/mcp-stdio.test.ts | 67 ++++++++++++++++++++++++++++++++ tests/commands/mcp.test.ts | 17 ++++++++ 3 files changed, 122 insertions(+) create mode 100644 tests/commands/mcp-stdio.test.ts diff --git a/tests/commands/devices.test.ts b/tests/commands/devices.test.ts index d805067..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']); @@ -517,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'); + }); }); // ===================================================================== @@ -1786,6 +1811,19 @@ describe('devices command', () => { 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 8c4b080..c47b86a 100644 --- a/tests/commands/mcp.test.ts +++ b/tests/commands/mcp.test.ts @@ -311,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 } }); From 04b6f5ee8f1de57f1d87dd6a18270b3882bd5017 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Tue, 21 Apr 2026 17:37:49 +0800 Subject: [PATCH 08/10] fix(smoke-v3): remove double-count, tighten mutate pass criteria, add suite - analyze.py: remove redundant 'if dim == "real.mutate"' branch that was double-counting alongside meta.real_mutate; meta.real_mutate is now the sole counter - runner.py: extract check_real_mutate_success(); add --json to allowlist mutate commands so output is parseable; check ok:true in the CLI envelope instead of string-matching statusCode; add meta={real_mutate:True} so analyze.py counter fires correctly - .gitignore: ignore smoke-v3/results/ - smoke-v3/: initial commit of the v3 smoke test suite (runner, analyzer, fuzzer, common utilities) --- .gitignore | 1 + smoke-v3/README.md | 54 +++ smoke-v3/__pycache__/analyze.cpython-313.pyc | Bin 0 -> 11390 bytes smoke-v3/__pycache__/common.cpython-313.pyc | Bin 0 -> 8820 bytes smoke-v3/__pycache__/fuzz.cpython-313.pyc | Bin 0 -> 16413 bytes .../__pycache__/gen_allowlist.cpython-313.pyc | Bin 0 -> 4598 bytes smoke-v3/__pycache__/run_full.cpython-313.pyc | Bin 0 -> 6034 bytes smoke-v3/__pycache__/runner.cpython-313.pyc | Bin 0 -> 13677 bytes smoke-v3/analyze.py | 173 ++++++++ smoke-v3/common.py | 183 +++++++++ smoke-v3/fuzz.py | 371 ++++++++++++++++++ smoke-v3/gen_allowlist.py | 96 +++++ smoke-v3/mutate-allowlist.example.json | 12 + smoke-v3/run_full.py | 155 ++++++++ smoke-v3/runner.py | 289 ++++++++++++++ 15 files changed, 1334 insertions(+) create mode 100644 smoke-v3/README.md create mode 100644 smoke-v3/__pycache__/analyze.cpython-313.pyc create mode 100644 smoke-v3/__pycache__/common.cpython-313.pyc create mode 100644 smoke-v3/__pycache__/fuzz.cpython-313.pyc create mode 100644 smoke-v3/__pycache__/gen_allowlist.cpython-313.pyc create mode 100644 smoke-v3/__pycache__/run_full.cpython-313.pyc create mode 100644 smoke-v3/__pycache__/runner.cpython-313.pyc create mode 100644 smoke-v3/analyze.py create mode 100644 smoke-v3/common.py create mode 100644 smoke-v3/fuzz.py create mode 100644 smoke-v3/gen_allowlist.py create mode 100644 smoke-v3/mutate-allowlist.example.json create mode 100644 smoke-v3/run_full.py create mode 100644 smoke-v3/runner.py diff --git a/.gitignore b/.gitignore index 87f911f..07add42 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ CLAUDE.md # Init transcript 2026-04-10-155920-command-messageinitcommand-message.txt tmp/ +smoke-v3/results/ diff --git a/smoke-v3/README.md b/smoke-v3/README.md new file mode 100644 index 0000000..43b8b86 --- /dev/null +++ b/smoke-v3/README.md @@ -0,0 +1,54 @@ +# smoke-v3 + +AI-first / Dev-first smoke framework for SwitchBot CLI. + +## Goals + +- Deterministic, reproducible smoke runs (`--seed`) +- 1000+ mixed baseline + fuzz cases +- Windows-friendly path handling +- Safe mutating mode with explicit allowlist +- Report artifacts consumable by humans and agents + +## Files + +- `runner.py`: deterministic baseline suites (P0/P1 dimensions) +- `fuzz.py`: seed-driven random case generation +- `analyze.py`: aggregate JSONL results into report + feedback +- `run_full.py`: orchestrates baseline + fuzz + analyze +- `mutate-allowlist.example.json`: explicit real-mutate allowlist + +## Quick start + +```powershell +python smoke-v3\run_full.py --cli-bin "node dist/index.js" --seed 20260421 --target-cases 1200 +``` + +Enable small real-mutate sampling automatically: + +```powershell +python smoke-v3\run_full.py --seed 20260421 --target-cases 1200 --auto-mutate-count 4 +``` + +Outputs are written under `smoke-v3/results/`: + +- `results--seed.jsonl` +- `summary--seed.json` +- `report--seed.md` +- `feedback--seed.md` + +## Mutating safety + +Real mutating commands are **disabled by default**. +Enable via: + +```powershell +python smoke-v3\run_full.py --mutate-allowlist smoke-v3\mutate-allowlist.json +``` + +Allowlist rules: + +- exact `deviceId` +- explicit `allowedCommands` +- optional `maxRuns` and `cooldownMs` +- non-listed commands auto-degrade to `--dry-run` diff --git a/smoke-v3/__pycache__/analyze.cpython-313.pyc b/smoke-v3/__pycache__/analyze.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c770802eaafe81d4fe07bf5556ef235be8f40dce GIT binary patch literal 11390 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 W{BIF1bnh(BaXWq`b6gvf)c*rp?bC_? literal 0 HcmV?d00001 diff --git a/smoke-v3/__pycache__/fuzz.cpython-313.pyc b/smoke-v3/__pycache__/fuzz.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..eb39dde1c108ac76ace21633da054a5658c63e73 GIT binary patch literal 16413 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 NH{#<;Z50z~{~y+;{tN&B literal 0 HcmV?d00001 diff --git a/smoke-v3/__pycache__/gen_allowlist.cpython-313.pyc b/smoke-v3/__pycache__/gen_allowlist.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..497da1c67418e809aaa2f086d391468e05126266 GIT binary patch literal 4598 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 RRrc&E^1HU}i0?2B`Y-19=!F0P literal 0 HcmV?d00001 diff --git a/smoke-v3/__pycache__/run_full.cpython-313.pyc b/smoke-v3/__pycache__/run_full.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..832c523f6a89df07646ea4d96943bb9f89a0c247 GIT binary patch literal 6034 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 literal 0 HcmV?d00001 diff --git a/smoke-v3/__pycache__/runner.cpython-313.pyc b/smoke-v3/__pycache__/runner.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..021607822ccd5d6da38f04827a01fb9272f7c7b6 GIT binary patch literal 13677 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 Cf)5J- literal 0 HcmV?d00001 diff --git a/smoke-v3/analyze.py b/smoke-v3/analyze.py new file mode 100644 index 0000000..f881245 --- /dev/null +++ b/smoke-v3/analyze.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +from collections import Counter, defaultdict +from pathlib import Path +from typing import Any + + +def parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser(description="smoke-v3 analyzer") + p.add_argument("--inputs", nargs="+", required=True, help="jsonl files") + p.add_argument("--summary-out", required=True) + p.add_argument("--report-out", required=True) + p.add_argument("--feedback-out", required=True) + return p.parse_args() + + +def read_rows(files: list[str]) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + for f in files: + p = Path(f) + if not p.exists(): + continue + for line in p.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line: + continue + try: + obj = json.loads(line) + if isinstance(obj, dict): + rows.append(obj) + except Exception: + continue + return rows + + +def pass_rate(rows: list[dict[str, Any]]) -> float: + if not rows: + return 0.0 + ok = sum(1 for r in rows if r.get("pass") is True) + return ok / len(rows) + + +def percentile(values: list[int], p: float) -> int: + if not values: + return 0 + sorted_vals = sorted(values) + idx = int((len(sorted_vals) - 1) * p) + return sorted_vals[idx] + + +def build_summary(rows: list[dict[str, Any]]) -> dict[str, Any]: + by_dim: dict[str, dict[str, int]] = defaultdict(lambda: {"total": 0, "pass": 0, "fail": 0}) + failures: list[dict[str, Any]] = [] + durs: list[int] = [] + mutate = {"real": 0, "degraded": 0} + for r in rows: + dim = str(r.get("dim", "unknown")) + by_dim[dim]["total"] += 1 + if r.get("pass") is True: + by_dim[dim]["pass"] += 1 + else: + by_dim[dim]["fail"] += 1 + failures.append( + { + "id": r.get("id"), + "dim": dim, + "label": r.get("label"), + "rc": r.get("rc"), + "extra_note": r.get("extra_note"), + "args": r.get("args"), + } + ) + durs.append(int(r.get("dur_ms") or 0)) + meta = r.get("meta") + if isinstance(meta, dict): + if meta.get("real_mutate") is True: + mutate["real"] += 1 + if meta.get("degraded_to_dry_run") is True: + mutate["degraded"] += 1 + + dim_table = [ + {"dim": dim, **stats, "pass_rate": round(stats["pass"] / stats["total"], 4) if stats["total"] else 0.0} + for dim, stats in sorted(by_dim.items(), key=lambda x: x[0]) + ] + failures = failures[:200] + return { + "total": len(rows), + "pass": sum(1 for r in rows if r.get("pass") is True), + "fail": sum(1 for r in rows if r.get("pass") is not True), + "pass_rate": round(pass_rate(rows), 4), + "p50_ms": percentile(durs, 0.50), + "p95_ms": percentile(durs, 0.95), + "p99_ms": percentile(durs, 0.99), + "dims": dim_table, + "mutate": mutate, + "failures": failures, + } + + +def report_md(summary: dict[str, Any]) -> str: + lines: list[str] = [] + lines.append("# smoke-v3 report") + lines.append("") + lines.append(f"- total: **{summary['total']}**") + lines.append(f"- pass/fail: **{summary['pass']} / {summary['fail']}**") + lines.append(f"- pass rate: **{summary['pass_rate']*100:.2f}%**") + lines.append(f"- p50/p95/p99: **{summary['p50_ms']} / {summary['p95_ms']} / {summary['p99_ms']} ms**") + lines.append(f"- mutate(real/degraded): **{summary['mutate']['real']} / {summary['mutate']['degraded']}**") + lines.append("") + lines.append("## By Dimension") + lines.append("") + lines.append("| dim | total | pass | fail | pass_rate |") + lines.append("|---|---:|---:|---:|---:|") + for d in summary["dims"]: + lines.append(f"| {d['dim']} | {d['total']} | {d['pass']} | {d['fail']} | {d['pass_rate']*100:.2f}% |") + lines.append("") + lines.append("## Top Failures (first 50)") + lines.append("") + for f in summary["failures"][:50]: + lines.append(f"- [{f['dim']}] `{f['label']}` rc={f['rc']} note={f.get('extra_note')}") + lines.append("") + return "\n".join(lines) + + +def feedback_md(summary: dict[str, Any]) -> str: + by_dim = {d["dim"]: d for d in summary["dims"]} + critical = [] + for dim in ("consistency.field_naming", "safety.readonly", "safety.validation", "consistency.error_shape", "ai.mcp_lifecycle"): + d = by_dim.get(dim) + if d and d["fail"] > 0: + critical.append((dim, d["fail"], d["total"])) + + lines: list[str] = [] + lines.append("# smoke-v3 feedback") + lines.append("") + if critical: + lines.append("## Critical (P0)") + for dim, fail, total in critical: + lines.append(f"- `{dim}` failed **{fail}/{total}**: keep as hard gate in CI.") + else: + lines.append("## Critical (P0)") + lines.append("- Core AI-first dimensions are green in this run.") + lines.append("") + lines.append("## Recommendations") + lines.append("- Keep `field_naming`, `readonly/validation`, `error_shape`, `mcp_lifecycle` as mandatory regression gates.") + lines.append("- Preserve `--json` contract: all fail paths must emit machine-readable error objects.") + lines.append("- Continue using canonical API field names across `--fields`, `--filter`, and docs/examples.") + lines.append("- Use fixed-seed baseline + rotating-seed exploratory run in CI nightly.") + lines.append("- Keep mutating tests under explicit allowlist and report degraded dry-run ratio.") + lines.append("") + lines.append("## Data Snapshot") + lines.append(f"- total={summary['total']}, pass_rate={summary['pass_rate']*100:.2f}%") + lines.append(f"- perf p50/p95/p99={summary['p50_ms']}/{summary['p95_ms']}/{summary['p99_ms']} ms") + lines.append(f"- mutate real/degraded={summary['mutate']['real']}/{summary['mutate']['degraded']}") + return "\n".join(lines) + + +def main() -> int: + args = parse_args() + rows = read_rows(args.inputs) + summary = build_summary(rows) + Path(args.summary_out).write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8") + Path(args.report_out).write_text(report_md(summary), encoding="utf-8") + Path(args.feedback_out).write_text(feedback_md(summary), encoding="utf-8") + print(json.dumps({"ok": True, "summary": args.summary_out, "report": args.report_out, "feedback": args.feedback_out})) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/smoke-v3/common.py b/smoke-v3/common.py new file mode 100644 index 0000000..57b9af1 --- /dev/null +++ b/smoke-v3/common.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import hashlib +import json +import random +import subprocess +import time +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Callable + + +Json = dict[str, Any] | list[Any] | str | int | float | bool | None +CheckFn = Callable[[str, str, int, bool], tuple[bool, str | None]] + + +def utc_ts() -> str: + return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + + +def safe_json_loads(text: str) -> Json | None: + text = (text or "").strip() + if not text: + return None + try: + return json.loads(text) + except Exception: + return None + + +def parse_switchbot_envelope(text: str) -> Json | None: + obj = safe_json_loads(text) + if not isinstance(obj, dict): + return obj + if "data" in obj: + return obj.get("data") + return obj + + +def is_json_error_output(stdout: str, stderr: str) -> tuple[bool, str | None]: + for channel_name, channel in (("stdout", stdout), ("stderr", stderr)): + lines = [x.strip() for x in (channel or "").splitlines() if x.strip()] + if not lines: + continue + parsed = safe_json_loads(lines[-1]) + if isinstance(parsed, dict) and "error" in parsed: + return True, f"{channel_name}:json-error" + return False, "no-json-error-object" + + +@dataclass +class RunResult: + rc: int + stdout: str + stderr: str + timeout: bool + dur_ms: int + + +def run_cmd(args: list[str], timeout: int = 20, stdin: str | None = None) -> RunResult: + start = time.time() + try: + p = subprocess.run( + args, + input=stdin, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=timeout, + ) + return RunResult( + rc=p.returncode, + stdout=p.stdout or "", + stderr=p.stderr or "", + timeout=False, + dur_ms=int((time.time() - start) * 1000), + ) + except subprocess.TimeoutExpired as e: + out = e.stdout.decode("utf-8", "replace") if isinstance(e.stdout, (bytes, bytearray)) else (e.stdout or "") + err = e.stderr.decode("utf-8", "replace") if isinstance(e.stderr, (bytes, bytearray)) else (e.stderr or "") + return RunResult( + rc=124, + stdout=out, + stderr=err, + timeout=True, + dur_ms=int((time.time() - start) * 1000), + ) + except Exception as e: + return RunResult( + rc=-1, + stdout="", + stderr=f"ERR:{e}", + timeout=False, + dur_ms=int((time.time() - start) * 1000), + ) + + +class ResultWriter: + def __init__(self, out_path: Path, seed: int) -> None: + self._out_path = out_path + self._seed = seed + self._id = 0 + self._fh = out_path.open("w", encoding="utf-8") + + @property + def path(self) -> Path: + return self._out_path + + @property + def count(self) -> int: + return self._id + + def close(self) -> None: + self._fh.close() + + def record( + self, + *, + cat: str, + dim: str, + label: str, + expect: str, + args: list[str], + timeout: int = 20, + stdin: str | None = None, + check: CheckFn | None = None, + meta: dict[str, Any] | None = None, + ) -> dict[str, Any]: + self._id += 1 + res = run_cmd(args=args, timeout=timeout, stdin=stdin) + if expect == "ok": + rc_pass = res.rc == 0 + elif expect == "fail": + rc_pass = res.rc != 0 + else: + rc_pass = True + + extra_pass, note = True, None + if check is not None: + try: + extra_pass, note = check(res.stdout, res.stderr, res.rc, res.timeout) + except Exception as ex: + extra_pass, note = False, f"check-error:{ex}" + + passed = rc_pass and extra_pass + case_hash = hashlib.sha1( + json.dumps({"label": label, "args": args, "seed": self._seed}, ensure_ascii=False).encode("utf-8") + ).hexdigest()[:16] + row = { + "id": self._id, + "seed": self._seed, + "case_hash": case_hash, + "cat": cat, + "dim": dim, + "label": label, + "expect": expect, + "pass": passed, + "rc_pass": rc_pass, + "extra_pass": extra_pass, + "extra_note": note, + "rc": res.rc, + "dur_ms": res.dur_ms, + "timeout": res.timeout, + "args": args, + "stdin": stdin[:300] if isinstance(stdin, str) else None, + "out": (res.stdout + res.stderr)[:2000], + } + if meta: + row["meta"] = meta + self._fh.write(json.dumps(row, ensure_ascii=False) + "\n") + self._fh.flush() + return row + + +def pick_one(rng: random.Random, seq: list[Any]) -> Any: + return seq[rng.randrange(0, len(seq))] + + +def clamp(v: int, lo: int, hi: int) -> int: + return max(lo, min(hi, v)) diff --git a/smoke-v3/fuzz.py b/smoke-v3/fuzz.py new file mode 100644 index 0000000..cedfa32 --- /dev/null +++ b/smoke-v3/fuzz.py @@ -0,0 +1,371 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import random +import shlex +from pathlib import Path +from typing import Any + +from common import ResultWriter, is_json_error_output, parse_switchbot_envelope, pick_one, run_cmd + + +def parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser(description="smoke-v3 seed-driven fuzz runner") + p.add_argument("--cli-bin", default="node dist/index.js") + p.add_argument("--out", required=True, help="JSONL output path") + p.add_argument("--seed", type=int, default=20260421) + p.add_argument("--target-cases", type=int, default=1000) + p.add_argument("--state-in", default="") + p.add_argument("--mutate-allowlist", default="") + return p.parse_args() + + +def load_json_file(path: str) -> dict[str, Any]: + if not path: + return {} + p = Path(path) + if not p.exists(): + return {} + try: + obj = json.loads(p.read_text(encoding="utf-8")) + if isinstance(obj, dict): + return obj + except Exception: + pass + return {} + + +def load_context(cli_base: list[str], state_in: str) -> dict[str, Any]: + state = load_json_file(state_in) + if state.get("device_ids"): + return state + ls = run_cmd([*cli_base, "devices", "list", "--json"], timeout=30) + data = parse_switchbot_envelope(ls.stdout) if ls.rc == 0 else {} + if not isinstance(data, dict): + data = {} + device_ids = [d.get("deviceId") for d in data.get("deviceList", []) if d.get("deviceId")] + by_type: dict[str, list[str]] = {} + for d in data.get("deviceList", []): + dt = str(d.get("deviceType", "")) + did = d.get("deviceId") + if dt and did: + by_type.setdefault(dt, []).append(did) + return { + "device_ids": device_ids, + "by_type": by_type, + "device_count": len(device_ids), + } + + +def allowlist_map(path: str) -> dict[str, Any]: + cfg = load_json_file(path) + out: dict[str, Any] = {} + if not cfg.get("enabled"): + return out + for x in cfg.get("devices", []): + if not isinstance(x, dict): + continue + did = str(x.get("deviceId", "")).strip() + cmds = x.get("allowedCommands", []) + if did and isinstance(cmds, list): + out[did] = { + "allowedCommands": [str(c) for c in cmds], + "maxRuns": int(x.get("maxRuns", 1)), + } + return out + + +def gen_list_case(cli_base: list[str], rng: random.Random) -> tuple[str, list[str], str]: + formats = ["table", "json", "jsonl", "tsv", "yaml", "markdown", "id"] + fields_pool = [ + "deviceId", + "deviceName", + "type", + "deviceType", + "name,room", + "deviceId,type", + "deviceId,deviceName,type", + ] + filters = [ + "type~Hub", + "deviceType~Hub", + "name~room", + "deviceName~room", + "category=physical", + "category=ir", + "room~Living", + "roomName~Living", + ] + fmt = pick_one(rng, formats) + args = [*cli_base, "devices", "list", "--format", fmt] + if rng.random() < 0.8: + args.extend(["--fields", pick_one(rng, fields_pool)]) + if rng.random() < 0.7: + args.extend(["--filter", pick_one(rng, filters)]) + expect = "either" + if fmt == "id": + # id format requires id-like columns + args = [*cli_base, "devices", "list", "--format", "id", "--fields", "deviceId"] + return ("coverage.bulk", args, expect) + + +def gen_status_case(cli_base: list[str], rng: random.Random, device_ids: list[str]) -> tuple[str, list[str], str]: + did = pick_one(rng, device_ids) + args = [*cli_base, "devices", "status", did] + if rng.random() < 0.5: + args.extend(["--format", pick_one(rng, ["json", "jsonl", "tsv", "yaml"])]) + if rng.random() < 0.6: + args.extend(["--fields", pick_one(rng, ["battery", "temperature", "power", "deviceId"])]) + return ("coverage.bulk", args, "either") + + +def gen_json_error_case(cli_base: list[str], rng: random.Random) -> tuple[str, list[str], str]: + cases = [ + [*cli_base, "devices", "list", "--format", "qwerty", "--json"], + [*cli_base, "devices", "list", "--timeout", "0", "--json"], + [*cli_base, "devices", "list", "--backoff", "moonshot", "--json"], + [*cli_base, "devices", "list", "--filter", "==", "--json"], + ] + return ("consistency.error_shape", pick_one(rng, cases), "fail") + + +def gen_command_case( + cli_base: list[str], + rng: random.Random, + device_ids: list[str], + by_type: dict[str, list[str]], + allow_map: dict[str, Any], + mutate_counts: dict[str, int], +) -> tuple[str, list[str], str, dict[str, Any] | None]: + allow_ids = list(allow_map.keys()) + safe_types = [k for k in by_type.keys() if k in {"Strip Light 3", "Curtain", "Color Bulb", "Plug", "Plug Mini (US)"}] + if allow_ids and rng.random() < 0.75: + did = pick_one(rng, allow_ids) + elif safe_types and rng.random() < 0.7: + did = pick_one(rng, by_type[pick_one(rng, safe_types)]) + else: + did = pick_one(rng, device_ids) + + known_cmds = [ + ("turnOn", None), + ("turnOff", None), + ("toggle", None), + ("setBrightness", "30"), + ("setColor", "128:128:128"), + ] + cmd, param = pick_one(rng, known_cmds) + real_mutate = False + degraded = False + + if did in allow_map and rng.random() < 0.20: + allowed = allow_map[did]["allowedCommands"] + max_runs = int(allow_map[did].get("maxRuns", 1)) + already = int(mutate_counts.get(did, 0)) + if already < max_runs: + # Status-aware command selection for real mutate path. + st = run_cmd([*cli_base, "devices", "status", did, "--json"], timeout=15) + payload = parse_switchbot_envelope(st.stdout) if st.rc == 0 else {} + power = None + brightness = None + if isinstance(payload, dict): + power = payload.get("power") + brightness = payload.get("brightness") + if "setBrightness" in allowed and brightness is not None and rng.random() < 0.4: + cmd, param = "setBrightness", str(rng.randint(20, 80)) + real_mutate = True + elif "turnOn" in allowed and str(power).lower() in {"off", "false", "0"}: + cmd, param = "turnOn", None + real_mutate = True + elif "turnOff" in allowed and str(power).lower() in {"on", "true", "1"}: + cmd, param = "turnOff", None + real_mutate = True + elif allowed: + cmd = pick_one(rng, allowed) + param = str(rng.randint(20, 80)) if cmd == "setBrightness" else None + real_mutate = True + if real_mutate: + mutate_counts[did] = already + 1 + if not real_mutate: + degraded = True + + args = [*cli_base, "devices", "command", did, cmd] + if param is not None: + args.append(param) + if not real_mutate: + args.append("--dry-run") + else: + args.append("--json") + + meta = {"real_mutate": real_mutate, "degraded_to_dry_run": degraded} + return ("coverage.commands_random", args, "either", meta) + + +def check_real_mutate_success(stdout: str, stderr: str, rc: int, timeout: bool) -> tuple[bool, str | None]: + if timeout: + return False, "timeout" + if rc != 0: + return False, f"rc={rc}" + payload = parse_switchbot_envelope(stdout) + if not isinstance(payload, dict): + return False, "non-json-envelope" + if payload.get("ok") is not True: + return False, "data.ok!=true" + return True, None + + +def main() -> int: + args = parse_args() + rng = random.Random(args.seed) + cli_base = shlex.split(args.cli_bin) + if not cli_base: + print("--cli-bin is empty") + return 2 + ctx = load_context(cli_base=cli_base, state_in=args.state_in) + device_ids = [x for x in ctx.get("device_ids", []) if x] + by_type = {k: v for k, v in (ctx.get("by_type", {}) or {}).items() if isinstance(v, list)} + allow_map = allowlist_map(args.mutate_allowlist) + mutate_counts: dict[str, int] = {} + out_path = Path(args.out) + out_path.parent.mkdir(parents=True, exist_ok=True) + writer = ResultWriter(out_path=out_path, seed=args.seed) + + # Fixed sanity seed section + writer.record( + cat="bootstrap", + dim="bootstrap.sanity", + label="sanity_version", + expect="ok", + args=[*cli_base, "--version"], + ) + writer.record( + cat="bootstrap", + dim="bootstrap.sanity", + label="sanity_devices_list", + expect="ok", + args=[*cli_base, "devices", "list", "--json"], + ) + + mix = ["list", "status", "json_error", "command", "catalog", "help", "mcp", "scenes", "doctor", "schema"] + if not device_ids: + mix = ["list", "json_error", "catalog", "help", "mcp", "scenes", "doctor", "schema"] + while writer.count < args.target_cases: + kind = pick_one(rng, mix) + if kind == "list": + dim, cmd, expect = gen_list_case(cli_base=cli_base, rng=rng) + writer.record(cat="fuzz", dim=dim, label=f"fz_list_{writer.count}", expect=expect, args=cmd) + elif kind == "status" and device_ids: + dim, cmd, expect = gen_status_case(cli_base=cli_base, rng=rng, device_ids=device_ids) + writer.record(cat="fuzz", dim=dim, label=f"fz_status_{writer.count}", expect=expect, args=cmd) + elif kind == "json_error": + dim, cmd, expect = gen_json_error_case(cli_base=cli_base, rng=rng) + writer.record( + cat="fuzz", + dim=dim, + label=f"fz_json_error_{writer.count}", + expect=expect, + args=cmd, + check=lambda so, se, rc, to: is_json_error_output(so, se), + ) + elif kind == "command" and device_ids: + dim, cmd, expect, meta = gen_command_case( + cli_base=cli_base, + rng=rng, + device_ids=device_ids, + by_type=by_type, + allow_map=allow_map, + mutate_counts=mutate_counts, + ) + writer.record( + cat="fuzz", + dim=dim, + label=f"fz_command_{writer.count}", + expect=expect, + args=cmd, + meta=meta, + check=check_real_mutate_success if (isinstance(meta, dict) and meta.get("real_mutate") is True) else None, + ) + elif kind == "scenes": + writer.record( + cat="fuzz", + dim="coverage.bulk", + label=f"fz_scenes_{writer.count}", + expect="either", + args=[*cli_base, "scenes", "list", "--format", pick_one(rng, ["json", "yaml", "tsv", "markdown"])], + ) + elif kind == "doctor": + writer.record( + cat="fuzz", + dim="perf", + label=f"fz_doctor_{writer.count}", + expect="either", + args=[*cli_base, "doctor", "--format", pick_one(rng, ["json", "yaml"])], + ) + elif kind == "schema": + writer.record( + cat="fuzz", + dim="ai.catalog_coverage", + label=f"fz_schema_{writer.count}", + expect="either", + args=[*cli_base, "schema", "export", "--json"], + ) + elif kind == "catalog": + q = pick_one(rng, ["Hub", "Curtain", "Robot Vacuum", "Meter", "Lock", "Bulb"]) + writer.record( + cat="fuzz", + dim="ai.catalog_coverage", + label=f"fz_catalog_{writer.count}", + expect="either", + args=[*cli_base, "catalog", "show", q, "--format", pick_one(rng, ["json", "yaml", "tsv"])], + ) + elif kind == "help": + command = pick_one( + rng, + [ + [*cli_base, "devices", "command", "--help"], + [*cli_base, "devices", "list", "--help"], + [*cli_base, "mcp", "serve", "--help"], + [*cli_base, "catalog", "show", "--help"], + ], + ) + writer.record( + cat="fuzz", + dim="ai.instructions", + label=f"fz_help_{writer.count}", + expect="ok", + args=command, + check=lambda so, se, rc, to: ("Examples:" in (so + se), None), + ) + else: + # mcp quick parity ping + init = { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "smoke-v3", "version": "1"}, + }, + } + notify = {"jsonrpc": "2.0", "method": "notifications/initialized"} + call = {"jsonrpc": "2.0", "id": 2, "method": "tools/list"} + stdin = "\n".join(json.dumps(x) for x in [init, notify, call]) + "\n" + writer.record( + cat="fuzz", + dim="ai.mcp_parity", + label=f"fz_mcp_{writer.count}", + expect="ok", + args=[*cli_base, "mcp", "serve"], + stdin=stdin, + timeout=12, + check=lambda so, se, rc, to: (rc == 0 and not to and '"id":2' in so, None), + ) + + writer.close() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/smoke-v3/gen_allowlist.py b/smoke-v3/gen_allowlist.py new file mode 100644 index 0000000..779f4d0 --- /dev/null +++ b/smoke-v3/gen_allowlist.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import random +import shlex +from pathlib import Path +from typing import Any + +from common import parse_switchbot_envelope, run_cmd + + +SAFE_TYPE_COMMANDS: dict[str, list[str]] = { + "Strip Light 3": ["turnOn", "turnOff", "setBrightness"], + "Color Bulb": ["turnOn", "turnOff", "setBrightness"], + "Plug": ["turnOn", "turnOff"], + "Plug Mini (US)": ["turnOn", "turnOff"], + "Ceiling Light": ["turnOn", "turnOff", "setBrightness"], +} + + +def parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser(description="Generate safe mutate allowlist from live account devices") + p.add_argument("--cli-bin", default="node dist/index.js") + p.add_argument("--out", required=True) + p.add_argument("--seed", type=int, default=20260421) + p.add_argument("--count", type=int, default=4, help="max number of devices to include") + p.add_argument("--max-runs", type=int, default=6) + p.add_argument("--cooldown-ms", type=int, default=1200) + return p.parse_args() + + +def main() -> int: + args = parse_args() + rng = random.Random(args.seed) + cli_base = shlex.split(args.cli_bin) + if not cli_base: + print("--cli-bin is empty") + return 2 + + ls = run_cmd([*cli_base, "devices", "list", "--json"], timeout=30) + if ls.rc != 0: + print("failed to query devices list; keep mutate disabled") + cfg = {"enabled": False, "devices": []} + Path(args.out).write_text(json.dumps(cfg, ensure_ascii=False, indent=2), encoding="utf-8") + return 0 + + data = parse_switchbot_envelope(ls.stdout) + if not isinstance(data, dict): + cfg = {"enabled": False, "devices": []} + Path(args.out).write_text(json.dumps(cfg, ensure_ascii=False, indent=2), encoding="utf-8") + return 0 + + candidates: list[dict[str, Any]] = [] + for d in data.get("deviceList", []): + if not isinstance(d, dict): + continue + device_type = str(d.get("deviceType", "")) + if device_type not in SAFE_TYPE_COMMANDS: + continue + if d.get("enableCloudService") is False: + continue + did = str(d.get("deviceId", "")).strip() + if not did: + continue + candidates.append( + { + "deviceId": did, + "deviceType": device_type, + "deviceName": d.get("deviceName"), + "allowedCommands": SAFE_TYPE_COMMANDS[device_type], + } + ) + + rng.shuffle(candidates) + selected = candidates[: max(0, args.count)] + devices = [ + { + "deviceId": x["deviceId"], + "allowedCommands": x["allowedCommands"], + "maxRuns": args.max_runs, + "cooldownMs": args.cooldown_ms, + "meta": {"deviceType": x["deviceType"], "deviceName": x.get("deviceName")}, + } + for x in selected + ] + cfg = {"enabled": len(devices) > 0, "devices": devices} + Path(args.out).write_text(json.dumps(cfg, ensure_ascii=False, indent=2), encoding="utf-8") + print(json.dumps({"enabled": cfg["enabled"], "selected": len(devices), "out": args.out}, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) + diff --git a/smoke-v3/mutate-allowlist.example.json b/smoke-v3/mutate-allowlist.example.json new file mode 100644 index 0000000..0939915 --- /dev/null +++ b/smoke-v3/mutate-allowlist.example.json @@ -0,0 +1,12 @@ +{ + "enabled": false, + "devices": [ + { + "deviceId": "PUT_DEVICE_ID_HERE", + "allowedCommands": ["turnOn", "turnOff", "setBrightness"], + "maxRuns": 20, + "cooldownMs": 1000 + } + ] +} + diff --git a/smoke-v3/run_full.py b/smoke-v3/run_full.py new file mode 100644 index 0000000..396f1c3 --- /dev/null +++ b/smoke-v3/run_full.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import subprocess +import sys +from pathlib import Path + +from common import utc_ts + + +def parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser(description="smoke-v3 full orchestrator") + p.add_argument("--cli-bin", default="node dist/index.js") + p.add_argument("--seed", type=int, default=20260421) + p.add_argument("--target-cases", type=int, default=1200) + p.add_argument("--results-dir", default="") + p.add_argument("--mutate-allowlist", default="") + p.add_argument("--auto-mutate-count", type=int, default=0, help="auto-generate safe mutate allowlist with up to N devices") + return p.parse_args() + + +def count_lines(path: Path) -> int: + if not path.exists(): + return 0 + return sum(1 for _ in path.open("r", encoding="utf-8")) + + +def run(cmd: list[str]) -> int: + print(">", " ".join(cmd)) + p = subprocess.run(cmd) + return p.returncode + + +def main() -> int: + args = parse_args() + base = Path(__file__).resolve().parent + results_dir = Path(args.results_dir) if args.results_dir else (base / "results") + results_dir.mkdir(parents=True, exist_ok=True) + + ts = utc_ts() + seed = args.seed + baseline_jsonl = results_dir / f"baseline-{ts}-seed{seed}.jsonl" + fuzz_jsonl = results_dir / f"fuzz-{ts}-seed{seed}.jsonl" + state_json = results_dir / f"state-{ts}-seed{seed}.json" + merged_jsonl = results_dir / f"results-{ts}-seed{seed}.jsonl" + summary_json = results_dir / f"summary-{ts}-seed{seed}.json" + report_md = results_dir / f"report-{ts}-seed{seed}.md" + feedback_md = results_dir / f"feedback-{ts}-seed{seed}.md" + latest_txt = results_dir / "latest.txt" + + py = sys.executable + + effective_allowlist = args.mutate_allowlist + if not effective_allowlist and args.auto_mutate_count > 0: + auto_allowlist = results_dir / f"mutate-allowlist-{ts}-seed{seed}.json" + rc = run( + [ + py, + str(base / "gen_allowlist.py"), + "--cli-bin", + args.cli_bin, + "--out", + str(auto_allowlist), + "--seed", + str(seed), + "--count", + str(args.auto_mutate_count), + ] + ) + if rc != 0: + print("auto allowlist generation failed") + return rc + effective_allowlist = str(auto_allowlist) + + rc = run( + [ + py, + str(base / "runner.py"), + "--cli-bin", + args.cli_bin, + "--out", + str(baseline_jsonl), + "--seed", + str(seed), + "--state-out", + str(state_json), + "--mutate-allowlist", + effective_allowlist, + ] + ) + if rc != 0: + print("baseline failed") + return rc + + baseline_count = count_lines(baseline_jsonl) + fuzz_target = max(args.target_cases - baseline_count, 0) + if fuzz_target > 0: + rc = run( + [ + py, + str(base / "fuzz.py"), + "--cli-bin", + args.cli_bin, + "--out", + str(fuzz_jsonl), + "--seed", + str(seed), + "--target-cases", + str(fuzz_target), + "--state-in", + str(state_json), + "--mutate-allowlist", + effective_allowlist, + ] + ) + if rc != 0: + print("fuzz failed") + return rc + + # merge + with merged_jsonl.open("w", encoding="utf-8") as out: + for src in [baseline_jsonl, fuzz_jsonl]: + if not src.exists(): + continue + out.write(src.read_text(encoding="utf-8")) + + rc = run( + [ + py, + str(base / "analyze.py"), + "--inputs", + str(baseline_jsonl), + *( [str(fuzz_jsonl)] if fuzz_jsonl.exists() else [] ), + "--summary-out", + str(summary_json), + "--report-out", + str(report_md), + "--feedback-out", + str(feedback_md), + ] + ) + if rc != 0: + print("analyze failed") + return rc + + latest_txt.write_text(str(merged_jsonl), encoding="utf-8") + print(f"done: {merged_jsonl}") + print(f"report: {report_md}") + print(f"feedback: {feedback_md}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/smoke-v3/runner.py b/smoke-v3/runner.py new file mode 100644 index 0000000..9ec344b --- /dev/null +++ b/smoke-v3/runner.py @@ -0,0 +1,289 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import shlex +from pathlib import Path +from typing import Any + +from common import ResultWriter, is_json_error_output, parse_switchbot_envelope, run_cmd + + +def parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser(description="smoke-v3 deterministic baseline runner") + p.add_argument("--cli-bin", default="node dist/index.js") + p.add_argument("--out", required=True, help="JSONL output path") + p.add_argument("--seed", type=int, default=20260421) + p.add_argument("--mutate-allowlist", default="", help="allowlist json path") + p.add_argument("--state-out", default="", help="optional state json output") + return p.parse_args() + + +def parse_data(stdout: str) -> Any: + return parse_switchbot_envelope(stdout) + + +def load_allowlist(path: str) -> dict[str, Any]: + if not path: + return {"enabled": False, "devices": []} + p = Path(path) + if not p.exists(): + return {"enabled": False, "devices": []} + try: + obj = json.loads(p.read_text(encoding="utf-8")) + if isinstance(obj, dict): + return obj + except Exception: + pass + return {"enabled": False, "devices": []} + + +def parse_jsonrpc_lines(text: str) -> dict[int, dict[str, Any]]: + out: dict[int, dict[str, Any]] = {} + for line in (text or "").splitlines(): + line = line.strip() + if not line: + continue + try: + obj = json.loads(line) + except Exception: + continue + if isinstance(obj, dict) and isinstance(obj.get("id"), int): + out[obj["id"]] = obj + return out + + +def check_real_mutate_success(stdout: str, stderr: str, rc: int, timeout: bool) -> tuple[bool, str | None]: + if timeout: + return False, "timeout" + if rc != 0: + return False, f"rc={rc}" + payload = parse_switchbot_envelope(stdout) + if not isinstance(payload, dict): + return False, "non-json-envelope" + if payload.get("ok") is not True: + return False, "data.ok!=true" + return True, None + + +def main() -> int: + args = parse_args() + out_path = Path(args.out) + out_path.parent.mkdir(parents=True, exist_ok=True) + writer = ResultWriter(out_path=out_path, seed=args.seed) + cli_base = shlex.split(args.cli_bin) + if not cli_base: + print("--cli-bin is empty") + return 2 + + def cli_args(*parts: str) -> list[str]: + return [*cli_base, *list(parts)] + + allowlist = load_allowlist(args.mutate_allowlist) + + # ---- load live context ---- + list_res = run_cmd(cli_args("devices", "list", "--json"), timeout=30) + if list_res.rc != 0: + writer.record( + cat="bootstrap", + dim="bootstrap.devices", + label="devices_list_bootstrap", + expect="ok", + args=cli_args("devices", "list", "--json"), + check=lambda so, se, rc, to: (False, "bootstrap-failed"), + ) + writer.close() + return 1 + + data = parse_data(list_res.stdout) or {} + device_list = data.get("deviceList", []) if isinstance(data, dict) else [] + ir_list = data.get("infraredRemoteList", []) if isinstance(data, dict) else [] + by_type: dict[str, list[dict[str, Any]]] = {} + for d in device_list: + by_type.setdefault(str(d.get("deviceType", "")), []).append(d) + + # ---- S1 field naming consistency ---- + canonical = [ + "deviceId", + "deviceName", + "deviceType", + "controlType", + "roomName", + "familyName", + "roomID", + "hubDeviceId", + "enableCloudService", + "category", + ] + aliases = ["id", "name", "type", "room", "family", "hub", "cloud", "alias"] + for field in canonical + aliases: + writer.record( + cat="S1", + dim="consistency.field_naming", + label=f"list_filter_{field}", + expect="either", + args=cli_args("devices", "list", "--filter", f"{field}=x", "--format", "json"), + check=lambda so, se, rc, t, _f=field: (f'Unknown filter key "{_f}"' not in (so + se), None), + ) + for field in canonical + aliases: + writer.record( + cat="S1", + dim="consistency.field_naming", + label=f"list_fields_{field}", + expect="either", + args=cli_args("devices", "list", "--fields", field, "--format", "tsv"), + check=lambda so, se, rc, t: (rc == 0, None), + ) + + # ---- S2 json error shape under --json ---- + error_cases = [ + ("bad_format", cli_args("devices", "list", "--format", "qwerty", "--json")), + ("bad_timeout", cli_args("devices", "list", "--timeout", "0", "--json")), + ("bad_backoff", cli_args("devices", "list", "--backoff", "moonshot", "--json")), + ("bad_filter", cli_args("devices", "list", "--filter", "==", "--json")), + ("bad_profile", cli_args("--profile", "__smoke_missing__", "devices", "list", "--json")), + ] + for label, cmd in error_cases: + writer.record( + cat="S2", + dim="consistency.error_shape", + label=f"err_shape_{label}", + expect="fail", + args=cmd, + check=lambda so, se, rc, to: is_json_error_output(so, se), + ) + + # ---- S3 readonly and unknown command validation ---- + ro_types = ["Meter", "MeterPro", "Contact Sensor", "Wallet Finder Card"] + for ro_t in ro_types: + devs = by_type.get(ro_t, []) + if not devs: + continue + did = devs[0].get("deviceId") + if not did: + continue + for ro_cmd in ("turnOn", "turnOff", "toggle"): + writer.record( + cat="S3", + dim="safety.readonly", + label=f"readonly_cli_{ro_t}_{ro_cmd}", + expect="fail", + args=cli_args("devices", "command", did, ro_cmd, "--dry-run"), + check=lambda so, se, rc, to: ( + rc != 0 and ("read-only" in (so + se).lower() or "no control commands" in (so + se).lower()), + None, + ), + ) + + writable_types = ["Strip Light 3", "Curtain", "Color Bulb", "Plug", "Plug Mini (US)", "Smart Lock"] + for ht in writable_types: + devs = by_type.get(ht, []) + if not devs: + continue + did = devs[0].get("deviceId") + if not did: + continue + writer.record( + cat="S3", + dim="safety.validation", + label=f"unknown_cmd_cli_{ht}", + expect="fail", + args=cli_args("devices", "command", did, "fakeCmdXYZ", "--dry-run"), + check=lambda so, se, rc, to: (rc != 0 and "supported command" in (so + se).lower(), None), + ) + + # ---- S4 mcp lifecycle + parity ---- + init = { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "smoke-v3", "version": "1"}, + }, + } + notify = {"jsonrpc": "2.0", "method": "notifications/initialized"} + tool_list = {"jsonrpc": "2.0", "id": 2, "method": "tools/list"} + mcp_res = run_cmd([*cli_base, "mcp", "serve"], timeout=12, stdin="\n".join(json.dumps(x, ensure_ascii=False) for x in [init, notify, tool_list]) + "\n") + parsed = parse_jsonrpc_lines(mcp_res.stdout) + writer.record( + cat="S4", + dim="ai.mcp_lifecycle", + label="mcp_stdio_eof_exit", + expect="ok", + args=[*cli_base, "mcp", "serve", ""], + check=lambda so, se, rc, to: (mcp_res.rc == 0 and not mcp_res.timeout, f"rc={mcp_res.rc} timeout={mcp_res.timeout}"), + meta={"mcp_stdout_sample": mcp_res.stdout[:400]}, + ) + writer.record( + cat="S4", + dim="ai.mcp_schema", + label="mcp_tools_list_shape", + expect="ok", + args=[*cli_base, "mcp", "serve", ""], + check=lambda so, se, rc, to: ( + isinstance(parsed.get(2, {}).get("result", {}).get("tools", []), list), + "tools-list-parsed", + ), + ) + + # ---- S5 help quality ---- + help_cases = [ + ([*cli_base, "mcp", "serve", "--help"], "help_mcp_serve_examples"), + ([*cli_base, "catalog", "show", "--help"], "help_catalog_show_examples"), + ] + for cmd, label in help_cases: + writer.record( + cat="S5", + dim="ai.instructions", + label=label, + expect="ok", + args=cmd, + check=lambda so, se, rc, to: ("Examples:" in (so + se), None), + ) + + # ---- S6 mutate allowlist (limited real writes) ---- + if bool(allowlist.get("enabled")) and isinstance(allowlist.get("devices"), list): + for entry in allowlist["devices"]: + if not isinstance(entry, dict): + continue + did = str(entry.get("deviceId", "")).strip() + cmds = entry.get("allowedCommands", []) + if not did or not isinstance(cmds, list): + continue + max_runs = int(entry.get("maxRuns", 1)) + for idx, cmd in enumerate(cmds[:max_runs]): + real_cmd = [*cli_base, "devices", "command", did, str(cmd), "--json"] + if str(cmd) == "setBrightness": + real_cmd.append("30") + writer.record( + cat="S6", + dim="real.mutate", + label=f"allowlist_mutate_{did[-6:]}_{cmd}_{idx}", + expect="either", + args=real_cmd, + meta={"real_mutate": True}, + check=check_real_mutate_success, + ) + + # ---- write run state ---- + if args.state_out: + state = { + "cli_bin": args.cli_bin, + "seed": args.seed, + "device_count": len(device_list), + "ir_count": len(ir_list), + "device_ids": [d.get("deviceId") for d in device_list if d.get("deviceId")], + "by_type": {k: [x.get("deviceId") for x in v if x.get("deviceId")] for k, v in by_type.items()}, + "allowlist_enabled": bool(allowlist.get("enabled")), + } + Path(args.state_out).write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8") + + writer.close() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 4aacfad23880ad2c9b52308b9741493005c48848 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Tue, 21 Apr 2026 17:39:01 +0800 Subject: [PATCH 09/10] chore: exclude __pycache__ and .pyc from smoke-v3 --- .gitignore | 2 ++ smoke-v3/__pycache__/analyze.cpython-313.pyc | Bin 11390 -> 0 bytes smoke-v3/__pycache__/common.cpython-313.pyc | Bin 8820 -> 0 bytes smoke-v3/__pycache__/fuzz.cpython-313.pyc | Bin 16413 -> 0 bytes .../__pycache__/gen_allowlist.cpython-313.pyc | Bin 4598 -> 0 bytes smoke-v3/__pycache__/run_full.cpython-313.pyc | Bin 6034 -> 0 bytes smoke-v3/__pycache__/runner.cpython-313.pyc | Bin 13677 -> 0 bytes 7 files changed, 2 insertions(+) delete mode 100644 smoke-v3/__pycache__/analyze.cpython-313.pyc delete mode 100644 smoke-v3/__pycache__/common.cpython-313.pyc delete mode 100644 smoke-v3/__pycache__/fuzz.cpython-313.pyc delete mode 100644 smoke-v3/__pycache__/gen_allowlist.cpython-313.pyc delete mode 100644 smoke-v3/__pycache__/run_full.cpython-313.pyc delete mode 100644 smoke-v3/__pycache__/runner.cpython-313.pyc diff --git a/.gitignore b/.gitignore index 07add42..7b7eb43 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,5 @@ CLAUDE.md 2026-04-10-155920-command-messageinitcommand-message.txt tmp/ smoke-v3/results/ +smoke-v3/__pycache__/ +*.pyc diff --git a/smoke-v3/__pycache__/analyze.cpython-313.pyc b/smoke-v3/__pycache__/analyze.cpython-313.pyc deleted file mode 100644 index c770802eaafe81d4fe07bf5556ef235be8f40dce..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11390 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 W{BIF1bnh(BaXWq`b6gvf)c*rp?bC_? diff --git a/smoke-v3/__pycache__/fuzz.cpython-313.pyc b/smoke-v3/__pycache__/fuzz.cpython-313.pyc deleted file mode 100644 index eb39dde1c108ac76ace21633da054a5658c63e73..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16413 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 NH{#<;Z50z~{~y+;{tN&B diff --git a/smoke-v3/__pycache__/gen_allowlist.cpython-313.pyc b/smoke-v3/__pycache__/gen_allowlist.cpython-313.pyc deleted file mode 100644 index 497da1c67418e809aaa2f086d391468e05126266..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4598 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 RRrc&E^1HU}i0?2B`Y-19=!F0P diff --git a/smoke-v3/__pycache__/run_full.cpython-313.pyc b/smoke-v3/__pycache__/run_full.cpython-313.pyc deleted file mode 100644 index 832c523f6a89df07646ea4d96943bb9f89a0c247..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6034 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 diff --git a/smoke-v3/__pycache__/runner.cpython-313.pyc b/smoke-v3/__pycache__/runner.cpython-313.pyc deleted file mode 100644 index 021607822ccd5d6da38f04827a01fb9272f7c7b6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13677 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 Cf)5J- From 3a7fe848a5c31d2f175ceeb252a0b1c457811623 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Tue, 21 Apr 2026 17:42:38 +0800 Subject: [PATCH 10/10] chore: remove smoke-v3 from tracking, add to .gitignore --- .gitignore | 4 +- smoke-v3/README.md | 54 ---- smoke-v3/analyze.py | 173 ------------ smoke-v3/common.py | 183 ------------ smoke-v3/fuzz.py | 371 ------------------------- smoke-v3/gen_allowlist.py | 96 ------- smoke-v3/mutate-allowlist.example.json | 12 - smoke-v3/run_full.py | 155 ----------- smoke-v3/runner.py | 289 ------------------- 9 files changed, 1 insertion(+), 1336 deletions(-) delete mode 100644 smoke-v3/README.md delete mode 100644 smoke-v3/analyze.py delete mode 100644 smoke-v3/common.py delete mode 100644 smoke-v3/fuzz.py delete mode 100644 smoke-v3/gen_allowlist.py delete mode 100644 smoke-v3/mutate-allowlist.example.json delete mode 100644 smoke-v3/run_full.py delete mode 100644 smoke-v3/runner.py diff --git a/.gitignore b/.gitignore index 7b7eb43..df3b0df 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,4 @@ CLAUDE.md # Init transcript 2026-04-10-155920-command-messageinitcommand-message.txt tmp/ -smoke-v3/results/ -smoke-v3/__pycache__/ -*.pyc +smoke-v3/ diff --git a/smoke-v3/README.md b/smoke-v3/README.md deleted file mode 100644 index 43b8b86..0000000 --- a/smoke-v3/README.md +++ /dev/null @@ -1,54 +0,0 @@ -# smoke-v3 - -AI-first / Dev-first smoke framework for SwitchBot CLI. - -## Goals - -- Deterministic, reproducible smoke runs (`--seed`) -- 1000+ mixed baseline + fuzz cases -- Windows-friendly path handling -- Safe mutating mode with explicit allowlist -- Report artifacts consumable by humans and agents - -## Files - -- `runner.py`: deterministic baseline suites (P0/P1 dimensions) -- `fuzz.py`: seed-driven random case generation -- `analyze.py`: aggregate JSONL results into report + feedback -- `run_full.py`: orchestrates baseline + fuzz + analyze -- `mutate-allowlist.example.json`: explicit real-mutate allowlist - -## Quick start - -```powershell -python smoke-v3\run_full.py --cli-bin "node dist/index.js" --seed 20260421 --target-cases 1200 -``` - -Enable small real-mutate sampling automatically: - -```powershell -python smoke-v3\run_full.py --seed 20260421 --target-cases 1200 --auto-mutate-count 4 -``` - -Outputs are written under `smoke-v3/results/`: - -- `results--seed.jsonl` -- `summary--seed.json` -- `report--seed.md` -- `feedback--seed.md` - -## Mutating safety - -Real mutating commands are **disabled by default**. -Enable via: - -```powershell -python smoke-v3\run_full.py --mutate-allowlist smoke-v3\mutate-allowlist.json -``` - -Allowlist rules: - -- exact `deviceId` -- explicit `allowedCommands` -- optional `maxRuns` and `cooldownMs` -- non-listed commands auto-degrade to `--dry-run` diff --git a/smoke-v3/analyze.py b/smoke-v3/analyze.py deleted file mode 100644 index f881245..0000000 --- a/smoke-v3/analyze.py +++ /dev/null @@ -1,173 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import argparse -import json -from collections import Counter, defaultdict -from pathlib import Path -from typing import Any - - -def parse_args() -> argparse.Namespace: - p = argparse.ArgumentParser(description="smoke-v3 analyzer") - p.add_argument("--inputs", nargs="+", required=True, help="jsonl files") - p.add_argument("--summary-out", required=True) - p.add_argument("--report-out", required=True) - p.add_argument("--feedback-out", required=True) - return p.parse_args() - - -def read_rows(files: list[str]) -> list[dict[str, Any]]: - rows: list[dict[str, Any]] = [] - for f in files: - p = Path(f) - if not p.exists(): - continue - for line in p.read_text(encoding="utf-8").splitlines(): - line = line.strip() - if not line: - continue - try: - obj = json.loads(line) - if isinstance(obj, dict): - rows.append(obj) - except Exception: - continue - return rows - - -def pass_rate(rows: list[dict[str, Any]]) -> float: - if not rows: - return 0.0 - ok = sum(1 for r in rows if r.get("pass") is True) - return ok / len(rows) - - -def percentile(values: list[int], p: float) -> int: - if not values: - return 0 - sorted_vals = sorted(values) - idx = int((len(sorted_vals) - 1) * p) - return sorted_vals[idx] - - -def build_summary(rows: list[dict[str, Any]]) -> dict[str, Any]: - by_dim: dict[str, dict[str, int]] = defaultdict(lambda: {"total": 0, "pass": 0, "fail": 0}) - failures: list[dict[str, Any]] = [] - durs: list[int] = [] - mutate = {"real": 0, "degraded": 0} - for r in rows: - dim = str(r.get("dim", "unknown")) - by_dim[dim]["total"] += 1 - if r.get("pass") is True: - by_dim[dim]["pass"] += 1 - else: - by_dim[dim]["fail"] += 1 - failures.append( - { - "id": r.get("id"), - "dim": dim, - "label": r.get("label"), - "rc": r.get("rc"), - "extra_note": r.get("extra_note"), - "args": r.get("args"), - } - ) - durs.append(int(r.get("dur_ms") or 0)) - meta = r.get("meta") - if isinstance(meta, dict): - if meta.get("real_mutate") is True: - mutate["real"] += 1 - if meta.get("degraded_to_dry_run") is True: - mutate["degraded"] += 1 - - dim_table = [ - {"dim": dim, **stats, "pass_rate": round(stats["pass"] / stats["total"], 4) if stats["total"] else 0.0} - for dim, stats in sorted(by_dim.items(), key=lambda x: x[0]) - ] - failures = failures[:200] - return { - "total": len(rows), - "pass": sum(1 for r in rows if r.get("pass") is True), - "fail": sum(1 for r in rows if r.get("pass") is not True), - "pass_rate": round(pass_rate(rows), 4), - "p50_ms": percentile(durs, 0.50), - "p95_ms": percentile(durs, 0.95), - "p99_ms": percentile(durs, 0.99), - "dims": dim_table, - "mutate": mutate, - "failures": failures, - } - - -def report_md(summary: dict[str, Any]) -> str: - lines: list[str] = [] - lines.append("# smoke-v3 report") - lines.append("") - lines.append(f"- total: **{summary['total']}**") - lines.append(f"- pass/fail: **{summary['pass']} / {summary['fail']}**") - lines.append(f"- pass rate: **{summary['pass_rate']*100:.2f}%**") - lines.append(f"- p50/p95/p99: **{summary['p50_ms']} / {summary['p95_ms']} / {summary['p99_ms']} ms**") - lines.append(f"- mutate(real/degraded): **{summary['mutate']['real']} / {summary['mutate']['degraded']}**") - lines.append("") - lines.append("## By Dimension") - lines.append("") - lines.append("| dim | total | pass | fail | pass_rate |") - lines.append("|---|---:|---:|---:|---:|") - for d in summary["dims"]: - lines.append(f"| {d['dim']} | {d['total']} | {d['pass']} | {d['fail']} | {d['pass_rate']*100:.2f}% |") - lines.append("") - lines.append("## Top Failures (first 50)") - lines.append("") - for f in summary["failures"][:50]: - lines.append(f"- [{f['dim']}] `{f['label']}` rc={f['rc']} note={f.get('extra_note')}") - lines.append("") - return "\n".join(lines) - - -def feedback_md(summary: dict[str, Any]) -> str: - by_dim = {d["dim"]: d for d in summary["dims"]} - critical = [] - for dim in ("consistency.field_naming", "safety.readonly", "safety.validation", "consistency.error_shape", "ai.mcp_lifecycle"): - d = by_dim.get(dim) - if d and d["fail"] > 0: - critical.append((dim, d["fail"], d["total"])) - - lines: list[str] = [] - lines.append("# smoke-v3 feedback") - lines.append("") - if critical: - lines.append("## Critical (P0)") - for dim, fail, total in critical: - lines.append(f"- `{dim}` failed **{fail}/{total}**: keep as hard gate in CI.") - else: - lines.append("## Critical (P0)") - lines.append("- Core AI-first dimensions are green in this run.") - lines.append("") - lines.append("## Recommendations") - lines.append("- Keep `field_naming`, `readonly/validation`, `error_shape`, `mcp_lifecycle` as mandatory regression gates.") - lines.append("- Preserve `--json` contract: all fail paths must emit machine-readable error objects.") - lines.append("- Continue using canonical API field names across `--fields`, `--filter`, and docs/examples.") - lines.append("- Use fixed-seed baseline + rotating-seed exploratory run in CI nightly.") - lines.append("- Keep mutating tests under explicit allowlist and report degraded dry-run ratio.") - lines.append("") - lines.append("## Data Snapshot") - lines.append(f"- total={summary['total']}, pass_rate={summary['pass_rate']*100:.2f}%") - lines.append(f"- perf p50/p95/p99={summary['p50_ms']}/{summary['p95_ms']}/{summary['p99_ms']} ms") - lines.append(f"- mutate real/degraded={summary['mutate']['real']}/{summary['mutate']['degraded']}") - return "\n".join(lines) - - -def main() -> int: - args = parse_args() - rows = read_rows(args.inputs) - summary = build_summary(rows) - Path(args.summary_out).write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8") - Path(args.report_out).write_text(report_md(summary), encoding="utf-8") - Path(args.feedback_out).write_text(feedback_md(summary), encoding="utf-8") - print(json.dumps({"ok": True, "summary": args.summary_out, "report": args.report_out, "feedback": args.feedback_out})) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/smoke-v3/common.py b/smoke-v3/common.py deleted file mode 100644 index 57b9af1..0000000 --- a/smoke-v3/common.py +++ /dev/null @@ -1,183 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import hashlib -import json -import random -import subprocess -import time -from dataclasses import dataclass -from datetime import datetime, timezone -from pathlib import Path -from typing import Any, Callable - - -Json = dict[str, Any] | list[Any] | str | int | float | bool | None -CheckFn = Callable[[str, str, int, bool], tuple[bool, str | None]] - - -def utc_ts() -> str: - return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") - - -def safe_json_loads(text: str) -> Json | None: - text = (text or "").strip() - if not text: - return None - try: - return json.loads(text) - except Exception: - return None - - -def parse_switchbot_envelope(text: str) -> Json | None: - obj = safe_json_loads(text) - if not isinstance(obj, dict): - return obj - if "data" in obj: - return obj.get("data") - return obj - - -def is_json_error_output(stdout: str, stderr: str) -> tuple[bool, str | None]: - for channel_name, channel in (("stdout", stdout), ("stderr", stderr)): - lines = [x.strip() for x in (channel or "").splitlines() if x.strip()] - if not lines: - continue - parsed = safe_json_loads(lines[-1]) - if isinstance(parsed, dict) and "error" in parsed: - return True, f"{channel_name}:json-error" - return False, "no-json-error-object" - - -@dataclass -class RunResult: - rc: int - stdout: str - stderr: str - timeout: bool - dur_ms: int - - -def run_cmd(args: list[str], timeout: int = 20, stdin: str | None = None) -> RunResult: - start = time.time() - try: - p = subprocess.run( - args, - input=stdin, - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - timeout=timeout, - ) - return RunResult( - rc=p.returncode, - stdout=p.stdout or "", - stderr=p.stderr or "", - timeout=False, - dur_ms=int((time.time() - start) * 1000), - ) - except subprocess.TimeoutExpired as e: - out = e.stdout.decode("utf-8", "replace") if isinstance(e.stdout, (bytes, bytearray)) else (e.stdout or "") - err = e.stderr.decode("utf-8", "replace") if isinstance(e.stderr, (bytes, bytearray)) else (e.stderr or "") - return RunResult( - rc=124, - stdout=out, - stderr=err, - timeout=True, - dur_ms=int((time.time() - start) * 1000), - ) - except Exception as e: - return RunResult( - rc=-1, - stdout="", - stderr=f"ERR:{e}", - timeout=False, - dur_ms=int((time.time() - start) * 1000), - ) - - -class ResultWriter: - def __init__(self, out_path: Path, seed: int) -> None: - self._out_path = out_path - self._seed = seed - self._id = 0 - self._fh = out_path.open("w", encoding="utf-8") - - @property - def path(self) -> Path: - return self._out_path - - @property - def count(self) -> int: - return self._id - - def close(self) -> None: - self._fh.close() - - def record( - self, - *, - cat: str, - dim: str, - label: str, - expect: str, - args: list[str], - timeout: int = 20, - stdin: str | None = None, - check: CheckFn | None = None, - meta: dict[str, Any] | None = None, - ) -> dict[str, Any]: - self._id += 1 - res = run_cmd(args=args, timeout=timeout, stdin=stdin) - if expect == "ok": - rc_pass = res.rc == 0 - elif expect == "fail": - rc_pass = res.rc != 0 - else: - rc_pass = True - - extra_pass, note = True, None - if check is not None: - try: - extra_pass, note = check(res.stdout, res.stderr, res.rc, res.timeout) - except Exception as ex: - extra_pass, note = False, f"check-error:{ex}" - - passed = rc_pass and extra_pass - case_hash = hashlib.sha1( - json.dumps({"label": label, "args": args, "seed": self._seed}, ensure_ascii=False).encode("utf-8") - ).hexdigest()[:16] - row = { - "id": self._id, - "seed": self._seed, - "case_hash": case_hash, - "cat": cat, - "dim": dim, - "label": label, - "expect": expect, - "pass": passed, - "rc_pass": rc_pass, - "extra_pass": extra_pass, - "extra_note": note, - "rc": res.rc, - "dur_ms": res.dur_ms, - "timeout": res.timeout, - "args": args, - "stdin": stdin[:300] if isinstance(stdin, str) else None, - "out": (res.stdout + res.stderr)[:2000], - } - if meta: - row["meta"] = meta - self._fh.write(json.dumps(row, ensure_ascii=False) + "\n") - self._fh.flush() - return row - - -def pick_one(rng: random.Random, seq: list[Any]) -> Any: - return seq[rng.randrange(0, len(seq))] - - -def clamp(v: int, lo: int, hi: int) -> int: - return max(lo, min(hi, v)) diff --git a/smoke-v3/fuzz.py b/smoke-v3/fuzz.py deleted file mode 100644 index cedfa32..0000000 --- a/smoke-v3/fuzz.py +++ /dev/null @@ -1,371 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import argparse -import json -import random -import shlex -from pathlib import Path -from typing import Any - -from common import ResultWriter, is_json_error_output, parse_switchbot_envelope, pick_one, run_cmd - - -def parse_args() -> argparse.Namespace: - p = argparse.ArgumentParser(description="smoke-v3 seed-driven fuzz runner") - p.add_argument("--cli-bin", default="node dist/index.js") - p.add_argument("--out", required=True, help="JSONL output path") - p.add_argument("--seed", type=int, default=20260421) - p.add_argument("--target-cases", type=int, default=1000) - p.add_argument("--state-in", default="") - p.add_argument("--mutate-allowlist", default="") - return p.parse_args() - - -def load_json_file(path: str) -> dict[str, Any]: - if not path: - return {} - p = Path(path) - if not p.exists(): - return {} - try: - obj = json.loads(p.read_text(encoding="utf-8")) - if isinstance(obj, dict): - return obj - except Exception: - pass - return {} - - -def load_context(cli_base: list[str], state_in: str) -> dict[str, Any]: - state = load_json_file(state_in) - if state.get("device_ids"): - return state - ls = run_cmd([*cli_base, "devices", "list", "--json"], timeout=30) - data = parse_switchbot_envelope(ls.stdout) if ls.rc == 0 else {} - if not isinstance(data, dict): - data = {} - device_ids = [d.get("deviceId") for d in data.get("deviceList", []) if d.get("deviceId")] - by_type: dict[str, list[str]] = {} - for d in data.get("deviceList", []): - dt = str(d.get("deviceType", "")) - did = d.get("deviceId") - if dt and did: - by_type.setdefault(dt, []).append(did) - return { - "device_ids": device_ids, - "by_type": by_type, - "device_count": len(device_ids), - } - - -def allowlist_map(path: str) -> dict[str, Any]: - cfg = load_json_file(path) - out: dict[str, Any] = {} - if not cfg.get("enabled"): - return out - for x in cfg.get("devices", []): - if not isinstance(x, dict): - continue - did = str(x.get("deviceId", "")).strip() - cmds = x.get("allowedCommands", []) - if did and isinstance(cmds, list): - out[did] = { - "allowedCommands": [str(c) for c in cmds], - "maxRuns": int(x.get("maxRuns", 1)), - } - return out - - -def gen_list_case(cli_base: list[str], rng: random.Random) -> tuple[str, list[str], str]: - formats = ["table", "json", "jsonl", "tsv", "yaml", "markdown", "id"] - fields_pool = [ - "deviceId", - "deviceName", - "type", - "deviceType", - "name,room", - "deviceId,type", - "deviceId,deviceName,type", - ] - filters = [ - "type~Hub", - "deviceType~Hub", - "name~room", - "deviceName~room", - "category=physical", - "category=ir", - "room~Living", - "roomName~Living", - ] - fmt = pick_one(rng, formats) - args = [*cli_base, "devices", "list", "--format", fmt] - if rng.random() < 0.8: - args.extend(["--fields", pick_one(rng, fields_pool)]) - if rng.random() < 0.7: - args.extend(["--filter", pick_one(rng, filters)]) - expect = "either" - if fmt == "id": - # id format requires id-like columns - args = [*cli_base, "devices", "list", "--format", "id", "--fields", "deviceId"] - return ("coverage.bulk", args, expect) - - -def gen_status_case(cli_base: list[str], rng: random.Random, device_ids: list[str]) -> tuple[str, list[str], str]: - did = pick_one(rng, device_ids) - args = [*cli_base, "devices", "status", did] - if rng.random() < 0.5: - args.extend(["--format", pick_one(rng, ["json", "jsonl", "tsv", "yaml"])]) - if rng.random() < 0.6: - args.extend(["--fields", pick_one(rng, ["battery", "temperature", "power", "deviceId"])]) - return ("coverage.bulk", args, "either") - - -def gen_json_error_case(cli_base: list[str], rng: random.Random) -> tuple[str, list[str], str]: - cases = [ - [*cli_base, "devices", "list", "--format", "qwerty", "--json"], - [*cli_base, "devices", "list", "--timeout", "0", "--json"], - [*cli_base, "devices", "list", "--backoff", "moonshot", "--json"], - [*cli_base, "devices", "list", "--filter", "==", "--json"], - ] - return ("consistency.error_shape", pick_one(rng, cases), "fail") - - -def gen_command_case( - cli_base: list[str], - rng: random.Random, - device_ids: list[str], - by_type: dict[str, list[str]], - allow_map: dict[str, Any], - mutate_counts: dict[str, int], -) -> tuple[str, list[str], str, dict[str, Any] | None]: - allow_ids = list(allow_map.keys()) - safe_types = [k for k in by_type.keys() if k in {"Strip Light 3", "Curtain", "Color Bulb", "Plug", "Plug Mini (US)"}] - if allow_ids and rng.random() < 0.75: - did = pick_one(rng, allow_ids) - elif safe_types and rng.random() < 0.7: - did = pick_one(rng, by_type[pick_one(rng, safe_types)]) - else: - did = pick_one(rng, device_ids) - - known_cmds = [ - ("turnOn", None), - ("turnOff", None), - ("toggle", None), - ("setBrightness", "30"), - ("setColor", "128:128:128"), - ] - cmd, param = pick_one(rng, known_cmds) - real_mutate = False - degraded = False - - if did in allow_map and rng.random() < 0.20: - allowed = allow_map[did]["allowedCommands"] - max_runs = int(allow_map[did].get("maxRuns", 1)) - already = int(mutate_counts.get(did, 0)) - if already < max_runs: - # Status-aware command selection for real mutate path. - st = run_cmd([*cli_base, "devices", "status", did, "--json"], timeout=15) - payload = parse_switchbot_envelope(st.stdout) if st.rc == 0 else {} - power = None - brightness = None - if isinstance(payload, dict): - power = payload.get("power") - brightness = payload.get("brightness") - if "setBrightness" in allowed and brightness is not None and rng.random() < 0.4: - cmd, param = "setBrightness", str(rng.randint(20, 80)) - real_mutate = True - elif "turnOn" in allowed and str(power).lower() in {"off", "false", "0"}: - cmd, param = "turnOn", None - real_mutate = True - elif "turnOff" in allowed and str(power).lower() in {"on", "true", "1"}: - cmd, param = "turnOff", None - real_mutate = True - elif allowed: - cmd = pick_one(rng, allowed) - param = str(rng.randint(20, 80)) if cmd == "setBrightness" else None - real_mutate = True - if real_mutate: - mutate_counts[did] = already + 1 - if not real_mutate: - degraded = True - - args = [*cli_base, "devices", "command", did, cmd] - if param is not None: - args.append(param) - if not real_mutate: - args.append("--dry-run") - else: - args.append("--json") - - meta = {"real_mutate": real_mutate, "degraded_to_dry_run": degraded} - return ("coverage.commands_random", args, "either", meta) - - -def check_real_mutate_success(stdout: str, stderr: str, rc: int, timeout: bool) -> tuple[bool, str | None]: - if timeout: - return False, "timeout" - if rc != 0: - return False, f"rc={rc}" - payload = parse_switchbot_envelope(stdout) - if not isinstance(payload, dict): - return False, "non-json-envelope" - if payload.get("ok") is not True: - return False, "data.ok!=true" - return True, None - - -def main() -> int: - args = parse_args() - rng = random.Random(args.seed) - cli_base = shlex.split(args.cli_bin) - if not cli_base: - print("--cli-bin is empty") - return 2 - ctx = load_context(cli_base=cli_base, state_in=args.state_in) - device_ids = [x for x in ctx.get("device_ids", []) if x] - by_type = {k: v for k, v in (ctx.get("by_type", {}) or {}).items() if isinstance(v, list)} - allow_map = allowlist_map(args.mutate_allowlist) - mutate_counts: dict[str, int] = {} - out_path = Path(args.out) - out_path.parent.mkdir(parents=True, exist_ok=True) - writer = ResultWriter(out_path=out_path, seed=args.seed) - - # Fixed sanity seed section - writer.record( - cat="bootstrap", - dim="bootstrap.sanity", - label="sanity_version", - expect="ok", - args=[*cli_base, "--version"], - ) - writer.record( - cat="bootstrap", - dim="bootstrap.sanity", - label="sanity_devices_list", - expect="ok", - args=[*cli_base, "devices", "list", "--json"], - ) - - mix = ["list", "status", "json_error", "command", "catalog", "help", "mcp", "scenes", "doctor", "schema"] - if not device_ids: - mix = ["list", "json_error", "catalog", "help", "mcp", "scenes", "doctor", "schema"] - while writer.count < args.target_cases: - kind = pick_one(rng, mix) - if kind == "list": - dim, cmd, expect = gen_list_case(cli_base=cli_base, rng=rng) - writer.record(cat="fuzz", dim=dim, label=f"fz_list_{writer.count}", expect=expect, args=cmd) - elif kind == "status" and device_ids: - dim, cmd, expect = gen_status_case(cli_base=cli_base, rng=rng, device_ids=device_ids) - writer.record(cat="fuzz", dim=dim, label=f"fz_status_{writer.count}", expect=expect, args=cmd) - elif kind == "json_error": - dim, cmd, expect = gen_json_error_case(cli_base=cli_base, rng=rng) - writer.record( - cat="fuzz", - dim=dim, - label=f"fz_json_error_{writer.count}", - expect=expect, - args=cmd, - check=lambda so, se, rc, to: is_json_error_output(so, se), - ) - elif kind == "command" and device_ids: - dim, cmd, expect, meta = gen_command_case( - cli_base=cli_base, - rng=rng, - device_ids=device_ids, - by_type=by_type, - allow_map=allow_map, - mutate_counts=mutate_counts, - ) - writer.record( - cat="fuzz", - dim=dim, - label=f"fz_command_{writer.count}", - expect=expect, - args=cmd, - meta=meta, - check=check_real_mutate_success if (isinstance(meta, dict) and meta.get("real_mutate") is True) else None, - ) - elif kind == "scenes": - writer.record( - cat="fuzz", - dim="coverage.bulk", - label=f"fz_scenes_{writer.count}", - expect="either", - args=[*cli_base, "scenes", "list", "--format", pick_one(rng, ["json", "yaml", "tsv", "markdown"])], - ) - elif kind == "doctor": - writer.record( - cat="fuzz", - dim="perf", - label=f"fz_doctor_{writer.count}", - expect="either", - args=[*cli_base, "doctor", "--format", pick_one(rng, ["json", "yaml"])], - ) - elif kind == "schema": - writer.record( - cat="fuzz", - dim="ai.catalog_coverage", - label=f"fz_schema_{writer.count}", - expect="either", - args=[*cli_base, "schema", "export", "--json"], - ) - elif kind == "catalog": - q = pick_one(rng, ["Hub", "Curtain", "Robot Vacuum", "Meter", "Lock", "Bulb"]) - writer.record( - cat="fuzz", - dim="ai.catalog_coverage", - label=f"fz_catalog_{writer.count}", - expect="either", - args=[*cli_base, "catalog", "show", q, "--format", pick_one(rng, ["json", "yaml", "tsv"])], - ) - elif kind == "help": - command = pick_one( - rng, - [ - [*cli_base, "devices", "command", "--help"], - [*cli_base, "devices", "list", "--help"], - [*cli_base, "mcp", "serve", "--help"], - [*cli_base, "catalog", "show", "--help"], - ], - ) - writer.record( - cat="fuzz", - dim="ai.instructions", - label=f"fz_help_{writer.count}", - expect="ok", - args=command, - check=lambda so, se, rc, to: ("Examples:" in (so + se), None), - ) - else: - # mcp quick parity ping - init = { - "jsonrpc": "2.0", - "id": 1, - "method": "initialize", - "params": { - "protocolVersion": "2024-11-05", - "capabilities": {}, - "clientInfo": {"name": "smoke-v3", "version": "1"}, - }, - } - notify = {"jsonrpc": "2.0", "method": "notifications/initialized"} - call = {"jsonrpc": "2.0", "id": 2, "method": "tools/list"} - stdin = "\n".join(json.dumps(x) for x in [init, notify, call]) + "\n" - writer.record( - cat="fuzz", - dim="ai.mcp_parity", - label=f"fz_mcp_{writer.count}", - expect="ok", - args=[*cli_base, "mcp", "serve"], - stdin=stdin, - timeout=12, - check=lambda so, se, rc, to: (rc == 0 and not to and '"id":2' in so, None), - ) - - writer.close() - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/smoke-v3/gen_allowlist.py b/smoke-v3/gen_allowlist.py deleted file mode 100644 index 779f4d0..0000000 --- a/smoke-v3/gen_allowlist.py +++ /dev/null @@ -1,96 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import argparse -import json -import random -import shlex -from pathlib import Path -from typing import Any - -from common import parse_switchbot_envelope, run_cmd - - -SAFE_TYPE_COMMANDS: dict[str, list[str]] = { - "Strip Light 3": ["turnOn", "turnOff", "setBrightness"], - "Color Bulb": ["turnOn", "turnOff", "setBrightness"], - "Plug": ["turnOn", "turnOff"], - "Plug Mini (US)": ["turnOn", "turnOff"], - "Ceiling Light": ["turnOn", "turnOff", "setBrightness"], -} - - -def parse_args() -> argparse.Namespace: - p = argparse.ArgumentParser(description="Generate safe mutate allowlist from live account devices") - p.add_argument("--cli-bin", default="node dist/index.js") - p.add_argument("--out", required=True) - p.add_argument("--seed", type=int, default=20260421) - p.add_argument("--count", type=int, default=4, help="max number of devices to include") - p.add_argument("--max-runs", type=int, default=6) - p.add_argument("--cooldown-ms", type=int, default=1200) - return p.parse_args() - - -def main() -> int: - args = parse_args() - rng = random.Random(args.seed) - cli_base = shlex.split(args.cli_bin) - if not cli_base: - print("--cli-bin is empty") - return 2 - - ls = run_cmd([*cli_base, "devices", "list", "--json"], timeout=30) - if ls.rc != 0: - print("failed to query devices list; keep mutate disabled") - cfg = {"enabled": False, "devices": []} - Path(args.out).write_text(json.dumps(cfg, ensure_ascii=False, indent=2), encoding="utf-8") - return 0 - - data = parse_switchbot_envelope(ls.stdout) - if not isinstance(data, dict): - cfg = {"enabled": False, "devices": []} - Path(args.out).write_text(json.dumps(cfg, ensure_ascii=False, indent=2), encoding="utf-8") - return 0 - - candidates: list[dict[str, Any]] = [] - for d in data.get("deviceList", []): - if not isinstance(d, dict): - continue - device_type = str(d.get("deviceType", "")) - if device_type not in SAFE_TYPE_COMMANDS: - continue - if d.get("enableCloudService") is False: - continue - did = str(d.get("deviceId", "")).strip() - if not did: - continue - candidates.append( - { - "deviceId": did, - "deviceType": device_type, - "deviceName": d.get("deviceName"), - "allowedCommands": SAFE_TYPE_COMMANDS[device_type], - } - ) - - rng.shuffle(candidates) - selected = candidates[: max(0, args.count)] - devices = [ - { - "deviceId": x["deviceId"], - "allowedCommands": x["allowedCommands"], - "maxRuns": args.max_runs, - "cooldownMs": args.cooldown_ms, - "meta": {"deviceType": x["deviceType"], "deviceName": x.get("deviceName")}, - } - for x in selected - ] - cfg = {"enabled": len(devices) > 0, "devices": devices} - Path(args.out).write_text(json.dumps(cfg, ensure_ascii=False, indent=2), encoding="utf-8") - print(json.dumps({"enabled": cfg["enabled"], "selected": len(devices), "out": args.out}, ensure_ascii=False)) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) - diff --git a/smoke-v3/mutate-allowlist.example.json b/smoke-v3/mutate-allowlist.example.json deleted file mode 100644 index 0939915..0000000 --- a/smoke-v3/mutate-allowlist.example.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "enabled": false, - "devices": [ - { - "deviceId": "PUT_DEVICE_ID_HERE", - "allowedCommands": ["turnOn", "turnOff", "setBrightness"], - "maxRuns": 20, - "cooldownMs": 1000 - } - ] -} - diff --git a/smoke-v3/run_full.py b/smoke-v3/run_full.py deleted file mode 100644 index 396f1c3..0000000 --- a/smoke-v3/run_full.py +++ /dev/null @@ -1,155 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import argparse -import subprocess -import sys -from pathlib import Path - -from common import utc_ts - - -def parse_args() -> argparse.Namespace: - p = argparse.ArgumentParser(description="smoke-v3 full orchestrator") - p.add_argument("--cli-bin", default="node dist/index.js") - p.add_argument("--seed", type=int, default=20260421) - p.add_argument("--target-cases", type=int, default=1200) - p.add_argument("--results-dir", default="") - p.add_argument("--mutate-allowlist", default="") - p.add_argument("--auto-mutate-count", type=int, default=0, help="auto-generate safe mutate allowlist with up to N devices") - return p.parse_args() - - -def count_lines(path: Path) -> int: - if not path.exists(): - return 0 - return sum(1 for _ in path.open("r", encoding="utf-8")) - - -def run(cmd: list[str]) -> int: - print(">", " ".join(cmd)) - p = subprocess.run(cmd) - return p.returncode - - -def main() -> int: - args = parse_args() - base = Path(__file__).resolve().parent - results_dir = Path(args.results_dir) if args.results_dir else (base / "results") - results_dir.mkdir(parents=True, exist_ok=True) - - ts = utc_ts() - seed = args.seed - baseline_jsonl = results_dir / f"baseline-{ts}-seed{seed}.jsonl" - fuzz_jsonl = results_dir / f"fuzz-{ts}-seed{seed}.jsonl" - state_json = results_dir / f"state-{ts}-seed{seed}.json" - merged_jsonl = results_dir / f"results-{ts}-seed{seed}.jsonl" - summary_json = results_dir / f"summary-{ts}-seed{seed}.json" - report_md = results_dir / f"report-{ts}-seed{seed}.md" - feedback_md = results_dir / f"feedback-{ts}-seed{seed}.md" - latest_txt = results_dir / "latest.txt" - - py = sys.executable - - effective_allowlist = args.mutate_allowlist - if not effective_allowlist and args.auto_mutate_count > 0: - auto_allowlist = results_dir / f"mutate-allowlist-{ts}-seed{seed}.json" - rc = run( - [ - py, - str(base / "gen_allowlist.py"), - "--cli-bin", - args.cli_bin, - "--out", - str(auto_allowlist), - "--seed", - str(seed), - "--count", - str(args.auto_mutate_count), - ] - ) - if rc != 0: - print("auto allowlist generation failed") - return rc - effective_allowlist = str(auto_allowlist) - - rc = run( - [ - py, - str(base / "runner.py"), - "--cli-bin", - args.cli_bin, - "--out", - str(baseline_jsonl), - "--seed", - str(seed), - "--state-out", - str(state_json), - "--mutate-allowlist", - effective_allowlist, - ] - ) - if rc != 0: - print("baseline failed") - return rc - - baseline_count = count_lines(baseline_jsonl) - fuzz_target = max(args.target_cases - baseline_count, 0) - if fuzz_target > 0: - rc = run( - [ - py, - str(base / "fuzz.py"), - "--cli-bin", - args.cli_bin, - "--out", - str(fuzz_jsonl), - "--seed", - str(seed), - "--target-cases", - str(fuzz_target), - "--state-in", - str(state_json), - "--mutate-allowlist", - effective_allowlist, - ] - ) - if rc != 0: - print("fuzz failed") - return rc - - # merge - with merged_jsonl.open("w", encoding="utf-8") as out: - for src in [baseline_jsonl, fuzz_jsonl]: - if not src.exists(): - continue - out.write(src.read_text(encoding="utf-8")) - - rc = run( - [ - py, - str(base / "analyze.py"), - "--inputs", - str(baseline_jsonl), - *( [str(fuzz_jsonl)] if fuzz_jsonl.exists() else [] ), - "--summary-out", - str(summary_json), - "--report-out", - str(report_md), - "--feedback-out", - str(feedback_md), - ] - ) - if rc != 0: - print("analyze failed") - return rc - - latest_txt.write_text(str(merged_jsonl), encoding="utf-8") - print(f"done: {merged_jsonl}") - print(f"report: {report_md}") - print(f"feedback: {feedback_md}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/smoke-v3/runner.py b/smoke-v3/runner.py deleted file mode 100644 index 9ec344b..0000000 --- a/smoke-v3/runner.py +++ /dev/null @@ -1,289 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import argparse -import json -import shlex -from pathlib import Path -from typing import Any - -from common import ResultWriter, is_json_error_output, parse_switchbot_envelope, run_cmd - - -def parse_args() -> argparse.Namespace: - p = argparse.ArgumentParser(description="smoke-v3 deterministic baseline runner") - p.add_argument("--cli-bin", default="node dist/index.js") - p.add_argument("--out", required=True, help="JSONL output path") - p.add_argument("--seed", type=int, default=20260421) - p.add_argument("--mutate-allowlist", default="", help="allowlist json path") - p.add_argument("--state-out", default="", help="optional state json output") - return p.parse_args() - - -def parse_data(stdout: str) -> Any: - return parse_switchbot_envelope(stdout) - - -def load_allowlist(path: str) -> dict[str, Any]: - if not path: - return {"enabled": False, "devices": []} - p = Path(path) - if not p.exists(): - return {"enabled": False, "devices": []} - try: - obj = json.loads(p.read_text(encoding="utf-8")) - if isinstance(obj, dict): - return obj - except Exception: - pass - return {"enabled": False, "devices": []} - - -def parse_jsonrpc_lines(text: str) -> dict[int, dict[str, Any]]: - out: dict[int, dict[str, Any]] = {} - for line in (text or "").splitlines(): - line = line.strip() - if not line: - continue - try: - obj = json.loads(line) - except Exception: - continue - if isinstance(obj, dict) and isinstance(obj.get("id"), int): - out[obj["id"]] = obj - return out - - -def check_real_mutate_success(stdout: str, stderr: str, rc: int, timeout: bool) -> tuple[bool, str | None]: - if timeout: - return False, "timeout" - if rc != 0: - return False, f"rc={rc}" - payload = parse_switchbot_envelope(stdout) - if not isinstance(payload, dict): - return False, "non-json-envelope" - if payload.get("ok") is not True: - return False, "data.ok!=true" - return True, None - - -def main() -> int: - args = parse_args() - out_path = Path(args.out) - out_path.parent.mkdir(parents=True, exist_ok=True) - writer = ResultWriter(out_path=out_path, seed=args.seed) - cli_base = shlex.split(args.cli_bin) - if not cli_base: - print("--cli-bin is empty") - return 2 - - def cli_args(*parts: str) -> list[str]: - return [*cli_base, *list(parts)] - - allowlist = load_allowlist(args.mutate_allowlist) - - # ---- load live context ---- - list_res = run_cmd(cli_args("devices", "list", "--json"), timeout=30) - if list_res.rc != 0: - writer.record( - cat="bootstrap", - dim="bootstrap.devices", - label="devices_list_bootstrap", - expect="ok", - args=cli_args("devices", "list", "--json"), - check=lambda so, se, rc, to: (False, "bootstrap-failed"), - ) - writer.close() - return 1 - - data = parse_data(list_res.stdout) or {} - device_list = data.get("deviceList", []) if isinstance(data, dict) else [] - ir_list = data.get("infraredRemoteList", []) if isinstance(data, dict) else [] - by_type: dict[str, list[dict[str, Any]]] = {} - for d in device_list: - by_type.setdefault(str(d.get("deviceType", "")), []).append(d) - - # ---- S1 field naming consistency ---- - canonical = [ - "deviceId", - "deviceName", - "deviceType", - "controlType", - "roomName", - "familyName", - "roomID", - "hubDeviceId", - "enableCloudService", - "category", - ] - aliases = ["id", "name", "type", "room", "family", "hub", "cloud", "alias"] - for field in canonical + aliases: - writer.record( - cat="S1", - dim="consistency.field_naming", - label=f"list_filter_{field}", - expect="either", - args=cli_args("devices", "list", "--filter", f"{field}=x", "--format", "json"), - check=lambda so, se, rc, t, _f=field: (f'Unknown filter key "{_f}"' not in (so + se), None), - ) - for field in canonical + aliases: - writer.record( - cat="S1", - dim="consistency.field_naming", - label=f"list_fields_{field}", - expect="either", - args=cli_args("devices", "list", "--fields", field, "--format", "tsv"), - check=lambda so, se, rc, t: (rc == 0, None), - ) - - # ---- S2 json error shape under --json ---- - error_cases = [ - ("bad_format", cli_args("devices", "list", "--format", "qwerty", "--json")), - ("bad_timeout", cli_args("devices", "list", "--timeout", "0", "--json")), - ("bad_backoff", cli_args("devices", "list", "--backoff", "moonshot", "--json")), - ("bad_filter", cli_args("devices", "list", "--filter", "==", "--json")), - ("bad_profile", cli_args("--profile", "__smoke_missing__", "devices", "list", "--json")), - ] - for label, cmd in error_cases: - writer.record( - cat="S2", - dim="consistency.error_shape", - label=f"err_shape_{label}", - expect="fail", - args=cmd, - check=lambda so, se, rc, to: is_json_error_output(so, se), - ) - - # ---- S3 readonly and unknown command validation ---- - ro_types = ["Meter", "MeterPro", "Contact Sensor", "Wallet Finder Card"] - for ro_t in ro_types: - devs = by_type.get(ro_t, []) - if not devs: - continue - did = devs[0].get("deviceId") - if not did: - continue - for ro_cmd in ("turnOn", "turnOff", "toggle"): - writer.record( - cat="S3", - dim="safety.readonly", - label=f"readonly_cli_{ro_t}_{ro_cmd}", - expect="fail", - args=cli_args("devices", "command", did, ro_cmd, "--dry-run"), - check=lambda so, se, rc, to: ( - rc != 0 and ("read-only" in (so + se).lower() or "no control commands" in (so + se).lower()), - None, - ), - ) - - writable_types = ["Strip Light 3", "Curtain", "Color Bulb", "Plug", "Plug Mini (US)", "Smart Lock"] - for ht in writable_types: - devs = by_type.get(ht, []) - if not devs: - continue - did = devs[0].get("deviceId") - if not did: - continue - writer.record( - cat="S3", - dim="safety.validation", - label=f"unknown_cmd_cli_{ht}", - expect="fail", - args=cli_args("devices", "command", did, "fakeCmdXYZ", "--dry-run"), - check=lambda so, se, rc, to: (rc != 0 and "supported command" in (so + se).lower(), None), - ) - - # ---- S4 mcp lifecycle + parity ---- - init = { - "jsonrpc": "2.0", - "id": 1, - "method": "initialize", - "params": { - "protocolVersion": "2024-11-05", - "capabilities": {}, - "clientInfo": {"name": "smoke-v3", "version": "1"}, - }, - } - notify = {"jsonrpc": "2.0", "method": "notifications/initialized"} - tool_list = {"jsonrpc": "2.0", "id": 2, "method": "tools/list"} - mcp_res = run_cmd([*cli_base, "mcp", "serve"], timeout=12, stdin="\n".join(json.dumps(x, ensure_ascii=False) for x in [init, notify, tool_list]) + "\n") - parsed = parse_jsonrpc_lines(mcp_res.stdout) - writer.record( - cat="S4", - dim="ai.mcp_lifecycle", - label="mcp_stdio_eof_exit", - expect="ok", - args=[*cli_base, "mcp", "serve", ""], - check=lambda so, se, rc, to: (mcp_res.rc == 0 and not mcp_res.timeout, f"rc={mcp_res.rc} timeout={mcp_res.timeout}"), - meta={"mcp_stdout_sample": mcp_res.stdout[:400]}, - ) - writer.record( - cat="S4", - dim="ai.mcp_schema", - label="mcp_tools_list_shape", - expect="ok", - args=[*cli_base, "mcp", "serve", ""], - check=lambda so, se, rc, to: ( - isinstance(parsed.get(2, {}).get("result", {}).get("tools", []), list), - "tools-list-parsed", - ), - ) - - # ---- S5 help quality ---- - help_cases = [ - ([*cli_base, "mcp", "serve", "--help"], "help_mcp_serve_examples"), - ([*cli_base, "catalog", "show", "--help"], "help_catalog_show_examples"), - ] - for cmd, label in help_cases: - writer.record( - cat="S5", - dim="ai.instructions", - label=label, - expect="ok", - args=cmd, - check=lambda so, se, rc, to: ("Examples:" in (so + se), None), - ) - - # ---- S6 mutate allowlist (limited real writes) ---- - if bool(allowlist.get("enabled")) and isinstance(allowlist.get("devices"), list): - for entry in allowlist["devices"]: - if not isinstance(entry, dict): - continue - did = str(entry.get("deviceId", "")).strip() - cmds = entry.get("allowedCommands", []) - if not did or not isinstance(cmds, list): - continue - max_runs = int(entry.get("maxRuns", 1)) - for idx, cmd in enumerate(cmds[:max_runs]): - real_cmd = [*cli_base, "devices", "command", did, str(cmd), "--json"] - if str(cmd) == "setBrightness": - real_cmd.append("30") - writer.record( - cat="S6", - dim="real.mutate", - label=f"allowlist_mutate_{did[-6:]}_{cmd}_{idx}", - expect="either", - args=real_cmd, - meta={"real_mutate": True}, - check=check_real_mutate_success, - ) - - # ---- write run state ---- - if args.state_out: - state = { - "cli_bin": args.cli_bin, - "seed": args.seed, - "device_count": len(device_list), - "ir_count": len(ir_list), - "device_ids": [d.get("deviceId") for d in device_list if d.get("deviceId")], - "by_type": {k: [x.get("deviceId") for x in v if x.get("deviceId")] for k, v in by_type.items()}, - "allowlist_enabled": bool(allowlist.get("enabled")), - } - Path(args.state_out).write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8") - - writer.close() - return 0 - - -if __name__ == "__main__": - raise SystemExit(main())