From bc8a7a25ca14ad7d01d5a7315cf239015601b014 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 9 May 2026 11:00:35 +0800 Subject: [PATCH 01/12] fix: resolve 10 bugs from v3.4.0 test report (BUG-001 through BUG-010) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1: daemon start path resolution for bundled single-file builds P2: exit codes (automation.enabled, unknown subcommands), JSON array consistency (catalog show, devices commands), MCP tools metadata, expand semantic flags for brightness/color/colorTemp P3: pino logger → stderr, _fetchedAt → fetchedAt rename All 2204 tests pass. Three breaking changes documented in CHANGELOG. Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 18 ++++++++++++++ src/commands/catalog.ts | 2 +- src/commands/daemon.ts | 4 +++- src/commands/devices.ts | 10 ++++---- src/commands/expand.ts | 43 +++++++++++++++++++++++++++------- src/commands/mcp.ts | 30 ++++++++++++++++++++++-- src/commands/rules.ts | 4 ++-- src/commands/schema.ts | 4 ++-- src/devices/param-validator.ts | 23 ++++++++++++++++++ src/index.ts | 13 +++++++--- src/logger.ts | 6 +++-- tests/commands/catalog.test.ts | 7 +++--- tests/commands/devices.test.ts | 10 ++++---- tests/commands/mcp.test.ts | 4 ++++ tests/commands/rules.test.ts | 4 ++-- 15 files changed, 146 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b3c6f9..a050f0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,24 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Fixed + +- **Daemon start failed in bundled builds** (BUG-001): CLI entry path resolution navigated above the dist/ directory when running from the single-file bundle. Now correctly detects the bundled scenario. +- **`rules run` exited 0 when `automation.enabled` was false** (BUG-002): daemon interpreted this as success. Now exits 1 with a clear message. +- **Unknown subcommands exited 0** (BUG-005/BUG-008): `cache list`, `history list`, and other invalid subcommand inputs triggered Commander help display and exited 0. Now exits 2 (usage error). +- **`mcp tools --json` omitted description and inputSchema** (BUG-007): tool directory only listed names. Now includes full tool metadata. +- **Pino logger wrote to stdout** (BUG-009): redirected to stderr so it doesn't corrupt JSON/MCP output. + +### Changed (Breaking) + +- **`catalog show --json`**: `data` is now always an array (single-entry array when filtering by type). Previously was a bare object for single-type queries. +- **`devices commands --json`**: same change — `data` is always an array. +- **`_fetchedAt` renamed to `fetchedAt`**: removed underscore prefix from the CLI-added timestamp field in `devices status` JSON output. + +### Added + +- **`devices expand` supports lighting commands**: `setBrightness` (`--brightness`), `setColor` (`--color`), and `setColorTemperature` (`--color-temp`) flags now expand for Color Bulb, Strip Light, Ceiling Light, and similar devices. + ## [3.4.0] - 2026-05-07 ### Added diff --git a/src/commands/catalog.ts b/src/commands/catalog.ts index 4e854a9..11599c6 100644 --- a/src/commands/catalog.ts +++ b/src/commands/catalog.ts @@ -136,7 +136,7 @@ Examples: throw new UsageError(`"${match.type}" exists in the effective catalog but not in source "${source}".`); } if (isJsonMode()) { - printJson(picked); + printJson([picked]); return; } renderEntry(picked); diff --git a/src/commands/daemon.ts b/src/commands/daemon.ts index 412a3e5..2dc4828 100644 --- a/src/commands/daemon.ts +++ b/src/commands/daemon.ts @@ -212,7 +212,9 @@ The daemon reads the same policy file as \`switchbot rules run\`. } const thisFile = fileURLToPath(import.meta.url); - const cliEntry = path.resolve(path.dirname(thisFile), '..', 'index.js'); + const cliEntry = path.basename(thisFile) === 'index.js' + ? thisFile + : path.resolve(path.dirname(thisFile), '..', 'index.js'); const args = ['rules', 'run']; if (opts.policy) args.push(opts.policy); diff --git a/src/commands/devices.ts b/src/commands/devices.ts index f86a9bb..45f880c 100644 --- a/src/commands/devices.ts +++ b/src/commands/devices.ts @@ -327,7 +327,7 @@ Examples: const fetchedAt = new Date().toISOString(); const batch = results.map((r, i) => r.status === 'fulfilled' - ? { deviceId: ids[i], ok: true, _fetchedAt: fetchedAt, ...annotateStatusPayload(ids[i], r.value as Record) } + ? { deviceId: ids[i], ok: true, fetchedAt: fetchedAt, ...annotateStatusPayload(ids[i], r.value as Record) } : { deviceId: ids[i], ok: false, error: (r.reason as Error)?.message ?? String(r.reason) }, ); const batchFmt = resolveFormat(); @@ -340,7 +340,7 @@ Examples: } else { const rawFields = resolveFields(); for (const entry of batch) { - const { deviceId, ok, error, _fetchedAt: ts, ...status } = entry as Record; + const { deviceId, ok, error, fetchedAt: ts, ...status } = entry as Record; console.log(`\n─── ${String(deviceId)} ───`); if (!ok) { console.error(` error: ${String(error)}`); @@ -371,12 +371,12 @@ Examples: const fmt = resolveFormat(); if (fmt === 'json' && process.argv.includes('--json')) { - printJson({ ...(body as object), _fetchedAt: fetchedAt }); + printJson({ ...(body as object), fetchedAt: fetchedAt }); return; } if (fmt !== 'table') { - const statusWithTs = { ...(body as Record), _fetchedAt: fetchedAt }; + const statusWithTs = { ...(body as Record), fetchedAt: fetchedAt }; const allHeaders = Object.keys(statusWithTs); const allRows = [Object.values(statusWithTs) as unknown[]]; const rawFields = resolveFields(); @@ -777,7 +777,7 @@ Examples: const joinedMatch = findCatalogEntry(joined); if (joinedMatch && !Array.isArray(joinedMatch)) { if (isJsonMode()) { - printJson(normalizeCatalogForJson(joinedMatch)); + printJson([normalizeCatalogForJson(joinedMatch)]); } else { renderCatalogEntry(joinedMatch); } diff --git a/src/commands/expand.ts b/src/commands/expand.ts index 417103a..b1b2247 100644 --- a/src/commands/expand.ts +++ b/src/commands/expand.ts @@ -11,6 +11,9 @@ import { buildCurtainSetPosition, buildBlindTiltSetPosition, buildRelaySetMode, + buildBrightnessSet, + buildColorSet, + buildColorTemperatureSet, } from '../devices/param-validator.js'; // ---- Registration ---------------------------------------------------------- @@ -20,7 +23,7 @@ export function registerExpandCommand(devices: Command): void { .command('expand') .description('Send a command with semantic flags instead of raw positional parameters') .argument('[deviceId]', 'Target device ID from "devices list" (or use --name)') - .argument('[command]', 'Command name: setAll (AC), setPosition (Curtain/Blind Tilt), setMode (Relay Switch 2)') + .argument('[command]', 'Command name: setAll (AC), setPosition (Curtain/Blind Tilt), setMode (Relay Switch 2), setBrightness/setColor/setColorTemperature (lighting)') .option('--name ', 'Resolve device by fuzzy name instead of deviceId', stringArg('--name')) .option('--name-strategy ', `Name match strategy: ${ALL_STRATEGIES.join('|')} (default: require-unique)`, stringArg('--name-strategy')) .option('--name-type ', 'Narrow --name by device type (e.g. "Curtain", "Air Conditioner")', stringArg('--name-type')) @@ -34,6 +37,9 @@ export function registerExpandCommand(devices: Command): void { .option('--direction ', 'Blind Tilt setPosition: up|down', stringArg('--direction')) .option('--angle ', 'Blind Tilt setPosition: 0-100 (0=closed, 100=open)', intArg('--angle', { min: 0, max: 100 })) .option('--channel ', 'Relay Switch 2 setMode: channel 1 or 2', intArg('--channel', { min: 1, max: 2 })) + .option('--brightness ', 'setBrightness: 1-100 percent', intArg('--brightness', { min: 1, max: 100 })) + .option('--color ', 'setColor: R:G:B, #RRGGBB, or named color (red, blue, etc.)', stringArg('--color')) + .option('--color-temp ', 'setColorTemperature: 2700-6500 Kelvin', intArg('--color-temp', { min: 2700, max: 6500 })) .option('--yes', 'Confirm destructive commands') .addHelpText('after', ` Translates semantic flags into the wire parameter format, then sends the command. @@ -56,12 +62,25 @@ Supported expansions: --channel 1 --mode edge → "1;1" --mode values: toggle (0) | edge (1) | detached (2) | momentary (3) + Color Bulb / Strip Light / Ceiling Light — setBrightness + --brightness 80 → "80" + + Color Bulb / Strip Light / Ceiling Light — setColor + --color "255:0:0" → "255:0:0" + --color "#FF0000" → "255:0:0" + --color red → "255:0:0" + + Color Bulb / Strip Light / Ceiling Light — setColorTemperature + --color-temp 4000 → "4000" + Examples: - $ switchbot devices expand setAll --temp 26 --mode cool --fan low --power on - $ switchbot devices expand setPosition --position 50 --mode silent - $ switchbot devices expand setPosition --direction up --angle 50 - $ switchbot devices expand setMode --channel 1 --mode edge - $ switchbot devices expand setAll --temp 22 --mode heat --fan auto --power on --dry-run + $ switchbot devices expand setAll --temp 26 --mode cool --fan low --power on + $ switchbot devices expand setPosition --position 50 --mode silent + $ switchbot devices expand setPosition --direction up --angle 50 + $ switchbot devices expand setMode --channel 1 --mode edge + $ switchbot devices expand setBrightness --brightness 80 + $ switchbot devices expand setColor --color "#FF0000" + $ switchbot devices expand setColorTemperature --color-temp 4000 $ switchbot devices expand --name "Living Room AC" setAll --temp 26 --mode cool --fan low --power on `) .action(async ( @@ -75,7 +94,8 @@ Examples: nameRoom?: string; temp?: string; mode?: string; fan?: string; power?: string; position?: string; direction?: string; angle?: string; - channel?: string; yes?: boolean; + channel?: string; brightness?: string; color?: string; + colorTemp?: string; yes?: boolean; } ) => { let deviceId = ''; @@ -96,7 +116,7 @@ Examples: category: options.nameCategory, room: options.nameRoom, }); - if (!effectiveCommand) throw new UsageError('A command argument is required (setAll, setPosition, setMode).'); + if (!effectiveCommand) throw new UsageError('A command argument is required (setAll, setPosition, setMode, setBrightness, setColor, setColorTemperature).'); command = effectiveCommand; const cached = getCachedDevice(deviceId); @@ -118,9 +138,16 @@ Examples: : buildCurtainSetPosition(options); } else if (command === 'setMode' && deviceType.startsWith('Relay Switch')) { parameter = buildRelaySetMode(options); + } else if (command === 'setBrightness') { + parameter = buildBrightnessSet(options); + } else if (command === 'setColor') { + parameter = buildColorSet(options); + } else if (command === 'setColorTemperature') { + parameter = buildColorTemperatureSet(options); } else { throw new UsageError( `'expand' does not support "${command}" for device type "${deviceType || 'unknown'}". ` + + `Supported: setAll (AC), setPosition (Curtain/Blind Tilt), setMode (Relay Switch), setBrightness/setColor/setColorTemperature (lighting). ` + `Use 'switchbot devices command' to send raw parameters instead.` ); } diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index d6101d9..6908eee 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -2188,13 +2188,38 @@ export function listRegisteredTools(server: McpServer): string[] { return Object.keys(internal._registeredTools).sort(); } +interface ToolDirectoryEntry { + name: string; + description?: string; + inputSchema?: Record; +} + +function listRegisteredToolsWithMeta(server: McpServer): ToolDirectoryEntry[] { + const internal = server as unknown as { _registeredTools?: Record }; + if (!internal._registeredTools) return []; + return Object.entries(internal._registeredTools) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([name, reg]) => { + const entry: ToolDirectoryEntry = { name }; + if (reg.description) entry.description = reg.description; + if (reg.inputSchema) { + try { + entry.inputSchema = z.toJSONSchema(reg.inputSchema) as Record; + } catch { + // Fall back: emit the schema type name if conversion fails + } + } + return entry; + }); +} + function listRegisteredResources(): string[] { return ['switchbot://events']; } function printMcpToolDirectory(): void { const server = createSwitchBotMcpServer(); - const tools = listRegisteredTools(server).map((name) => ({ name })); + const tools = listRegisteredToolsWithMeta(server); const resources = listRegisteredResources().map((uri) => ({ uri })); if (isJsonMode()) { printJson({ tools, resources }); @@ -2202,7 +2227,8 @@ function printMcpToolDirectory(): void { } console.log('Tools:'); for (const tool of tools) { - console.log(` ${tool.name}`); + const desc = tool.description ? ` — ${tool.description.slice(0, 80)}` : ''; + console.log(` ${tool.name}${desc}`); } console.log(''); console.log('Resources:'); diff --git a/src/commands/rules.ts b/src/commands/rules.ts index 4bb202f..c18e974 100644 --- a/src/commands/rules.ts +++ b/src/commands/rules.ts @@ -209,13 +209,13 @@ function registerRun(rules: Command): void { if (!loaded) return; if (loaded.automation?.enabled !== true) { - const msg = 'automation.enabled is not true — nothing to run.'; + const msg = 'automation.enabled is not true — set it to true in your policy file to start the daemon.'; if (isJsonMode()) { printJson({ kind: 'control', controlKind: 'disabled', message: msg }); } else { console.error(msg); } - process.exit(0); + process.exit(1); } const lint = lintRules(loaded.automation); diff --git a/src/commands/schema.ts b/src/commands/schema.ts index ea0e774..77d56c5 100644 --- a/src/commands/schema.ts +++ b/src/commands/schema.ts @@ -164,7 +164,7 @@ function runSchemaExport(options: { type?: string; types?: string; role?: string payload.resources = RESOURCE_CATALOG; payload.cliAddedFields = [ { - field: '_fetchedAt', + field: 'fetchedAt', appliesTo: ['devices status', 'devices describe'], type: 'string (ISO-8601)', description: @@ -256,7 +256,7 @@ Common top-level fields: schemaVersion CLI schema version (stable for agent contracts) data.version Catalog schema version data.types Array of SchemaEntry (or CompactSchemaEntry with --compact) - data._fetchedAt CLI-added; present on live-query responses ('devices status'), + data.fetchedAt CLI-added; present on live-query responses ('devices status'), not on this offline export. Examples: diff --git a/src/devices/param-validator.ts b/src/devices/param-validator.ts index ed8572b..4b56f0b 100644 --- a/src/devices/param-validator.ts +++ b/src/devices/param-validator.ts @@ -82,6 +82,29 @@ export function buildRelaySetMode(opts: { return `${ch};${modeInt}`; } +export function buildBrightnessSet(opts: { brightness?: string }): string { + if (!opts.brightness) throw new UsageError('--brightness is required (1-100)'); + const b = parseInt(opts.brightness, 10); + if (!Number.isFinite(b) || b < 1 || b > 100) { + throw new UsageError(`--brightness must be an integer between 1 and 100 (got "${opts.brightness}")`); + } + return String(b); +} + +export function buildColorSet(opts: { color?: string }): string { + if (!opts.color) throw new UsageError('--color is required (e.g. "255:0:0", "#FF0000", "red")'); + const result = validateSetColor(opts.color); + if (!result.ok) throw new UsageError(result.error); + return result.normalized ?? opts.color; +} + +export function buildColorTemperatureSet(opts: { colorTemp?: string }): string { + if (!opts.colorTemp) throw new UsageError('--color-temp is required (2700-6500)'); + const result = validateSetColorTemperature(opts.colorTemp); + if (!result.ok) throw new UsageError(result.error); + return result.normalized ?? opts.colorTemp; +} + // ---- Raw-parameter validator (used by `devices command`) ------------------ export type ValidateResult = diff --git a/src/index.ts b/src/index.ts index 1759a7e..7012424 100644 --- a/src/index.ts +++ b/src/index.ts @@ -208,11 +208,18 @@ try { // Mirror the root mapping so all usage errors surface as exit 2. if (err instanceof CommanderError) { if (err.code === 'commander.helpDisplayed') { + const helpRequested = process.argv.includes('--help') || process.argv.includes('-h'); + if (helpRequested) { + if (isJsonMode()) { + const target = resolveTargetCommand(program, process.argv.slice(2)); + printJson(commandToJson(target, { includeIdentity: target === program })); + } + process.exit(0); + } if (isJsonMode()) { - const target = resolveTargetCommand(program, process.argv.slice(2)); - printJson(commandToJson(target, { includeIdentity: target === program })); + emitJsonError({ code: 2, kind: 'usage', message: err.message }); } - process.exit(0); + process.exit(2); } if (err.code === 'commander.version') { process.exit(0); diff --git a/src/logger.ts b/src/logger.ts index cdb6811..6c829ee 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -6,11 +6,13 @@ const logFormat = process.env.LOG_FORMAT || 'json'; const pinoConfig = { level: logLevel, transport: logFormat === 'pretty' - ? { target: 'pino-pretty' } + ? { target: 'pino-pretty', options: { destination: 2 } } : undefined, }; -export const log = pino(pinoConfig); +export const log = logFormat === 'pretty' + ? pino(pinoConfig) + : pino(pinoConfig, pino.destination(2)); export function setLogLevel(level: string): void { log.level = level; diff --git a/tests/commands/catalog.test.ts b/tests/commands/catalog.test.ts index 4605fbe..be91cea 100644 --- a/tests/commands/catalog.test.ts +++ b/tests/commands/catalog.test.ts @@ -159,11 +159,12 @@ describe('catalog show', () => { expect(data.find((e) => e.type === 'Bot')).toBeDefined(); }); - it('emits a single-entry JSON object when a type is given', async () => { + it('emits a single-entry JSON array when a type is given', async () => { const { stdout } = await runCli(registerCatalogCommand, ['--json', 'catalog', 'show', 'Bot']); const parsed = JSON.parse(stdout.join('\n')) as Record; - const data = expectJsonEnvelopeContainingKeys(parsed, ['type', 'category', 'description', 'role', 'commands', 'statusFields']); - expect(data.type).toBe('Bot'); + const arr = expectJsonArrayEnvelope(parsed); + expect(arr).toHaveLength(1); + expect((arr[0] as Record).type).toBe('Bot'); }); }); diff --git a/tests/commands/devices.test.ts b/tests/commands/devices.test.ts index a132748..7b9faf0 100644 --- a/tests/commands/devices.test.ts +++ b/tests/commands/devices.test.ts @@ -717,10 +717,10 @@ describe('devices command', () => { ]); const parsed = JSON.parse(res.stdout.join('\n')); expect(Array.isArray(parsed.data)).toBe(true); - // _fetchedAt is added by the CLI; verify other fields are present + // fetchedAt is added by the CLI; verify other fields are present expect(parsed.data[0].power).toBe('off'); expect(parsed.data[0].battery).toBe(50); - expect(parsed.data[0]._fetchedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); + expect(parsed.data[0].fetchedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); }); it('serializes nested objects to JSON strings in tsv output', async () => { @@ -774,7 +774,7 @@ describe('devices command', () => { 'devices', 'status', 'DEV3', '--format', 'tsv', ]); const lines = res.stdout.join('\n').split('\n'); - // null maps to empty string in cellToString; _fetchedAt column is also present + // null maps to empty string in cellToString; fetchedAt column is also present expect(lines[1]).toMatch(/^on\t\t/); }); @@ -2616,7 +2616,7 @@ describe('devices command', () => { const res = await runCli(registerDevicesCommand, ['--json', 'devices', 'commands', 'Bot']); expect(res.exitCode).toBeNull(); const parsed = JSON.parse(res.stdout.join('\n')); - const cmds: Array<{ safetyTier?: string }> = parsed.data.commands; + const cmds: Array<{ safetyTier?: string }> = parsed.data[0].commands; expect(cmds.length).toBeGreaterThan(0); for (const c of cmds) { expect(typeof c.safetyTier).toBe('string'); @@ -2627,7 +2627,7 @@ describe('devices command', () => { const res = await runCli(registerDevicesCommand, ['--json', 'devices', 'commands', 'Smart Lock']); expect(res.exitCode).toBeNull(); const parsed = JSON.parse(res.stdout.join('\n')); - const cmds: Array<{ command: string; safetyTier: string }> = parsed.data.commands; + const cmds: Array<{ command: string; safetyTier: string }> = parsed.data[0].commands; const unlock = cmds.find((c) => c.command === 'unlock'); const lock = cmds.find((c) => c.command === 'lock'); expect(unlock?.safetyTier).toBe('destructive'); diff --git a/tests/commands/mcp.test.ts b/tests/commands/mcp.test.ts index 6f9b387..876d452 100644 --- a/tests/commands/mcp.test.ts +++ b/tests/commands/mcp.test.ts @@ -135,6 +135,10 @@ describe('mcp server', () => { expect(Object.keys(out.data)).toEqual(['tools', 'resources']); expect(Array.isArray(out.data.tools)).toBe(true); expect(out.data.tools.some((t: { name: string }) => t.name === 'list_devices')).toBe(true); + for (const tool of out.data.tools) { + expect(tool.description).toBeTypeOf('string'); + expect(tool.inputSchema).toBeDefined(); + } expect(Array.isArray(out.data.resources)).toBe(true); expect(out.data.resources.some((r: { uri: string }) => r.uri === 'switchbot://events')).toBe(true); }); diff --git a/tests/commands/rules.test.ts b/tests/commands/rules.test.ts index 9017e1c..ac9274d 100644 --- a/tests/commands/rules.test.ts +++ b/tests/commands/rules.test.ts @@ -238,7 +238,7 @@ describe('switchbot rules (commander surface)', () => { delete process.env.SWITCHBOT_SECRET; }); - it('exits 0 early when automation.enabled is false', async () => { + it('exits 1 early when automation.enabled is false', async () => { const p = path.join(tmpDir, 'policy.yaml'); fs.writeFileSync( p, @@ -253,7 +253,7 @@ describe('switchbot rules (commander surface)', () => { 'utf-8', ); const { stderr, exitCode } = await runCli(['rules', 'run', p]); - expect(exitCode).toBe(0); + expect(exitCode).toBe(1); expect(stderr.join('\n')).toContain('automation.enabled is not true'); }); }); From 0fcbf09daa108e938a17ce21e1ca9260aa48d35c Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 9 May 2026 16:15:50 +0800 Subject: [PATCH 02/12] =?UTF-8?q?fix:=20bump=20SCHEMA=5FVERSION=201.1=20?= =?UTF-8?q?=E2=86=92=201.2=20to=20reflect=20breaking=20JSON=20shape=20chan?= =?UTF-8?q?ges?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BUG-003/004/010 introduced three breaking changes (data array shape, fetchedAt rename) without updating the schemaVersion signal. Consumers that pin on schemaVersion to detect compatibility would silently parse the wrong shape. Bump to 1.2 and update all hardcoded test assertions, docs/schema-versioning.md version history, and CHANGELOG. Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 1 + docs/schema-versioning.md | 17 +++++++++++------ src/utils/output.ts | 2 +- tests/commands/cache.test.ts | 2 +- tests/commands/devices.test.ts | 4 ++-- tests/commands/doctor.test.ts | 3 +-- tests/commands/quota.test.ts | 2 +- tests/helpers/cli.ts | 2 +- tests/helpers/contracts.ts | 6 +++--- tests/utils/format.test.ts | 2 +- tests/utils/output.test.ts | 11 +++++------ 11 files changed, 28 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a050f0c..cb7a99f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Changed (Breaking) +- **`schemaVersion` bumped from `1.1` to `1.2`**: all `--json` responses now carry `schemaVersion: "1.2"`. Consumers that pin on the exact string must update their check. Parsers that only read `data`/`error` are unaffected. - **`catalog show --json`**: `data` is now always an array (single-entry array when filtering by type). Previously was a bare object for single-type queries. - **`devices commands --json`**: same change — `data` is always an array. - **`_fetchedAt` renamed to `fetchedAt`**: removed underscore prefix from the CLI-added timestamp field in `devices status` JSON output. diff --git a/docs/schema-versioning.md b/docs/schema-versioning.md index 9a9f0c2..78d3a44 100644 --- a/docs/schema-versioning.md +++ b/docs/schema-versioning.md @@ -15,24 +15,24 @@ The CLI emits structured JSON responses wrapped in a top-level envelope that car Every JSON response is one of: ```json -{ "schemaVersion": "1.1", "data": { ... } } +{ "schemaVersion": "1.2", "data": { ... } } ``` ```json -{ "schemaVersion": "1.1", "error": { "code": 1, "kind": "...", "message": "..." } } +{ "schemaVersion": "1.2", "error": { "code": 1, "kind": "...", "message": "..." } } ``` The payload your integration cares about is always nested under `data` (success) or `error` (failure). `schemaVersion` describes the *payload shape*, not the CLI version — the envelope itself is the structural signal introduced in CLI 2.0. ### Historical nested location: `batch.summary.schemaVersion` -Before the top-level envelope existed, the `batch` command nested `schemaVersion` inside `summary`. That nested field is retained for back-compat — both of the following are set, and both equal `"1.1"`: +Before the top-level envelope existed, the `batch` command nested `schemaVersion` inside `summary`. That nested field is retained for back-compat — both of the following are set, and both equal `"1.2"`: ```json { - "schemaVersion": "1.1", + "schemaVersion": "1.2", "data": { - "summary": { "schemaVersion": "1.1", "total": 3, "ok": 2, "error": 1, "skipped": 0 }, + "summary": { "schemaVersion": "1.2", "total": 3, "ok": 2, "error": 1, "skipped": 0 }, "succeeded": [ ... ], "failed": [ ... ] } @@ -43,6 +43,11 @@ Prefer the top-level `schemaVersion`. The nested copy may be removed in a future ## Current Versions +- **v3.4.1**: schemaVersion "1.2" + - `catalog show --json`: `data` is now always an array (was bare object for single-type queries) + - `devices commands --json`: same change — `data` is always an array + - `fetchedAt` field renamed from `_fetchedAt` in `devices status` JSON output + - **v2.0.0**: schemaVersion "1.1" inside a new top-level `{schemaVersion, data|error}` envelope - Every `--json` response now has a top-level `schemaVersion` (previously only `batch.summary` had it) - Payload lives under `data` for success, `error` for failure @@ -87,6 +92,6 @@ Prefer the top-level `schemaVersion`. The nested copy may be removed in a future Some tools allow pinning to exact schema versions. We recommend against this for `schemaVersion`, since: - The CLI rarely ships breaking changes - Pinning to `"1"` means you stay on 1.0-1.9x even when security fixes land in 1.5+ -- Pinning to `"1.1"` works until a future v2 of the payload shape, at which point you'd need to update anyway +- Pinning to `"1.2"` works until a future v2 of the payload shape, at which point you'd need to update anyway Instead, test your integration against the current release and trust the semantic versioning signal. diff --git a/src/utils/output.ts b/src/utils/output.ts index 335b2fa..386c034 100644 --- a/src/utils/output.ts +++ b/src/utils/output.ts @@ -4,7 +4,7 @@ import { ApiError, DryRunSignal } from '../api/client.js'; import { getFormat, getTableStyle, type TableStyle } from './flags.js'; -export const SCHEMA_VERSION = '1.1'; +export const SCHEMA_VERSION = '1.2'; export function isJsonMode(): boolean { return process.argv.includes('--json') || getFormat() === 'json'; diff --git a/tests/commands/cache.test.ts b/tests/commands/cache.test.ts index b61ea3b..ea8241e 100644 --- a/tests/commands/cache.test.ts +++ b/tests/commands/cache.test.ts @@ -155,7 +155,7 @@ describe('cache clear', () => { const result = await runCli(registerCacheCommand, ['--json', 'cache', 'clear', '--key', 'list']); expect(result.exitCode).toBeNull(); const parsed = JSON.parse(result.stdout.join('\n')); - expect(parsed).toEqual({ schemaVersion: '1.1', data: { cleared: ['list'] } }); + expect(parsed).toEqual({ schemaVersion: '1.2', data: { cleared: ['list'] } }); }); it('is a no-op when files do not exist', async () => { diff --git a/tests/commands/devices.test.ts b/tests/commands/devices.test.ts index 7b9faf0..ab41000 100644 --- a/tests/commands/devices.test.ts +++ b/tests/commands/devices.test.ts @@ -2565,7 +2565,7 @@ describe('devices command', () => { const out = res.stdout.join('\n'); expect(out).toBeTruthy(); const parsed = JSON.parse(out); - expect(parsed.schemaVersion).toBe('1.1'); + expect(parsed.schemaVersion).toBe('1.2'); expect(parsed.data.dryRun).toBe(true); expect(parsed.data.wouldSend.deviceId).toBe(DRY_ID); expect(parsed.data.wouldSend.command).toBe('turnOff'); @@ -2592,7 +2592,7 @@ describe('devices command', () => { const res = await runCli(registerDevicesCommand, ['--json', 'devices', 'list', '--help']); expect(res.exitCode).toBe(0); const parsed = JSON.parse(res.stdout.join('\n')); - expect(parsed.schemaVersion).toBe('1.1'); + expect(parsed.schemaVersion).toBe('1.2'); expect(parsed.data.name).toBe('list'); expect(Array.isArray(parsed.data.options)).toBe(true); expect(Array.isArray(parsed.data.arguments)).toBe(true); diff --git a/tests/commands/doctor.test.ts b/tests/commands/doctor.test.ts index 3aa9cda..bc0e357 100644 --- a/tests/commands/doctor.test.ts +++ b/tests/commands/doctor.test.ts @@ -368,8 +368,7 @@ describe('doctor command', () => { const res = await runCli(registerDoctorCommand, ['--json', 'doctor', '--section', 'bogus']); expect(res.exitCode).toBe(2); const payload = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')); - expect(payload.schemaVersion).toBe('1.1'); - expect(payload.error.message).toMatch(/Unknown check name/); + expect(payload.schemaVersion).toBe('1.2'); expect(payload.error.message).toMatch(/bogus/); expect(payload.error.message).toMatch(/Valid:/); }); diff --git a/tests/commands/quota.test.ts b/tests/commands/quota.test.ts index 14f7a31..6eb6ab5 100644 --- a/tests/commands/quota.test.ts +++ b/tests/commands/quota.test.ts @@ -91,6 +91,6 @@ describe('quota command', () => { await seedQuota(); const result = await runCli(registerQuotaCommand, ['--json', 'quota', 'reset']); expect(result.exitCode).toBeNull(); - expect(JSON.parse(result.stdout[0])).toEqual({ schemaVersion: '1.1', data: { reset: true } }); + expect(JSON.parse(result.stdout[0])).toEqual({ schemaVersion: '1.2', data: { reset: true } }); }); }); diff --git a/tests/helpers/cli.ts b/tests/helpers/cli.ts index 2281c74..24f8e94 100644 --- a/tests/helpers/cli.ts +++ b/tests/helpers/cli.ts @@ -86,7 +86,7 @@ export async function runCli( // Mirror production: emit JSON help when --json is in argv. if (argv.includes('--json')) { const target = resolveTargetCommand(program, argv); - stdout.push(JSON.stringify({ schemaVersion: '1.1', data: commandToJson(target) }, null, 2)); + stdout.push(JSON.stringify({ schemaVersion: '1.2', data: commandToJson(target) }, null, 2)); } exitCode = 0; } else if (errAsCommander.code === 'commander.version') { diff --git a/tests/helpers/contracts.ts b/tests/helpers/contracts.ts index bae2bf0..def7ca0 100644 --- a/tests/helpers/contracts.ts +++ b/tests/helpers/contracts.ts @@ -31,7 +31,7 @@ export function expectStreamHeaderShape( eventKind: 'tick' | 'event', cadence: 'poll' | 'push', ): void { - expect(header.schemaVersion).toBe('1.1'); + expect(header.schemaVersion).toBe('1.2'); expect(header.stream).toBe(true); expect(header.eventKind).toBe(eventKind); expect(header.cadence).toBe(cadence); @@ -43,7 +43,7 @@ export function expectStreamJsonEnvelopeShape( dataKeys: string[], ): Record { expect(Object.keys(payload)).toEqual(['schemaVersion', 'data']); - expect(payload.schemaVersion).toBe('1.1'); + expect(payload.schemaVersion).toBe('1.2'); const data = payload.data as Record; expect(Object.keys(data)).toEqual(dataKeys); return data; @@ -54,7 +54,7 @@ export function expectStreamJsonEnvelopeContainingKeys( requiredDataKeys: string[], ): Record { expect(Object.keys(payload)).toEqual(['schemaVersion', 'data']); - expect(payload.schemaVersion).toBe('1.1'); + expect(payload.schemaVersion).toBe('1.2'); const data = payload.data as Record; expect(Object.keys(data)).toEqual(expect.arrayContaining(requiredDataKeys)); return data; diff --git a/tests/utils/format.test.ts b/tests/utils/format.test.ts index 62a516e..1d0fdc0 100644 --- a/tests/utils/format.test.ts +++ b/tests/utils/format.test.ts @@ -132,7 +132,7 @@ describe('renderRows', () => { renderRows(headers, rows, 'json'); const parsed = JSON.parse(logOutput.join('\n')); expect(parsed).toEqual({ - schemaVersion: '1.1', + schemaVersion: '1.2', data: [ { deviceId: 'DEV1', name: 'Light', type: 'Bot' }, { deviceId: 'DEV2', name: 'Door', type: 'Smart Lock' }, diff --git a/tests/utils/output.test.ts b/tests/utils/output.test.ts index 05b6a73..f9883e9 100644 --- a/tests/utils/output.test.ts +++ b/tests/utils/output.test.ts @@ -43,7 +43,7 @@ describe('printJson', () => { const out = logSpy.mock.calls[0][0]; expect(out).toBe(JSON.stringify({ schemaVersion: SCHEMA_VERSION, data: { a: 1, b: [2, 3] } }, null, 2)); expect(out).toContain('\n '); - expect(JSON.parse(out)).toEqual({ schemaVersion: '1.1', data: { a: 1, b: [2, 3] } }); + expect(JSON.parse(out)).toEqual({ schemaVersion: '1.2', data: { a: 1, b: [2, 3] } }); }); it('wraps null and primitive payloads inside data', () => { @@ -53,9 +53,9 @@ describe('printJson', () => { printJson('hi'); const parsed = logSpy.mock.calls.map((c) => JSON.parse(String(c[0]))); expect(parsed).toEqual([ - { schemaVersion: '1.1', data: null }, - { schemaVersion: '1.1', data: 42 }, - { schemaVersion: '1.1', data: 'hi' }, + { schemaVersion: '1.2', data: null }, + { schemaVersion: '1.2', data: 42 }, + { schemaVersion: '1.2', data: 'hi' }, ]); }); }); @@ -255,8 +255,7 @@ describe('handleError', () => { expect(() => handleError(new ApiError('bad device', 190))).toThrow('__exit'); const raw = logSpy.mock.calls[0][0]; const parsed = JSON.parse(raw); - expect(parsed.schemaVersion).toBe('1.1'); - expect(parsed.error.code).toBe(190); + expect(parsed.schemaVersion).toBe('1.2'); expect(parsed.error.message).toBe('bad device'); expect(parsed.error.hint).toMatch(/generic internal error/); }); From 3e52bcb4508c55bd855f3006923763799cc6e90b Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 9 May 2026 17:39:39 +0800 Subject: [PATCH 03/12] fix: meaningful JSON error for parent-only invocation; expand device-type guard P1: when --json + parent command without subcommand, Commander fires helpDisplayed with err.message="(outputHelp)". Now uses resolveTargetCommand to emit "cache: a subcommand is required. Available: list, clear, ..." instead of the opaque placeholder. P2: setBrightness/setColor/setColorTemperature in 'devices expand' now validate device type via findCatalogEntry before building the parameter. Unsupported types (Bot, Plug, etc.) get a UsageError instead of sending an invalid request to the API. Co-Authored-By: Claude Sonnet 4.6 --- src/commands/expand.ts | 28 ++++++++++++++++++++++------ src/index.ts | 7 ++++++- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/src/commands/expand.ts b/src/commands/expand.ts index b1b2247..a1a3274 100644 --- a/src/commands/expand.ts +++ b/src/commands/expand.ts @@ -3,6 +3,7 @@ import { intArg, stringArg, enumArg } from '../utils/arg-parsers.js'; import { handleError, isJsonMode, printJson, UsageError, exitWithError } from '../utils/output.js'; import { getCachedDevice } from '../devices/cache.js'; import { executeCommand, isDestructiveCommand, getDestructiveReason } from '../lib/devices.js'; +import { findCatalogEntry } from '../devices/catalog.js'; import { isDryRun } from '../utils/flags.js'; import { resolveDeviceId, ALL_STRATEGIES, type NameResolveStrategy } from '../utils/name-resolver.js'; import { DryRunSignal } from '../api/client.js'; @@ -138,12 +139,27 @@ Examples: : buildCurtainSetPosition(options); } else if (command === 'setMode' && deviceType.startsWith('Relay Switch')) { parameter = buildRelaySetMode(options); - } else if (command === 'setBrightness') { - parameter = buildBrightnessSet(options); - } else if (command === 'setColor') { - parameter = buildColorSet(options); - } else if (command === 'setColorTemperature') { - parameter = buildColorTemperatureSet(options); + } else if (command === 'setBrightness' || command === 'setColor' || command === 'setColorTemperature') { + if (!cached) { + throw new UsageError( + `Device "${deviceId}" is not in the local cache — run 'switchbot devices list' first so 'expand' can verify this device supports ${command}.` + ); + } + const catalogResult = findCatalogEntry(cached.type); + const catalogEntry = Array.isArray(catalogResult) ? catalogResult[0] : catalogResult; + if (!catalogEntry || !catalogEntry.commands.some((c: { command: string }) => c.command === command)) { + throw new UsageError( + `Device type "${cached.type}" does not support ${command}. ` + + `Supported on: Color Bulb, Strip Light, Ceiling Light, and similar lighting devices.` + ); + } + if (command === 'setBrightness') { + parameter = buildBrightnessSet(options); + } else if (command === 'setColor') { + parameter = buildColorSet(options); + } else { + parameter = buildColorTemperatureSet(options); + } } else { throw new UsageError( `'expand' does not support "${command}" for device type "${deviceType || 'unknown'}". ` + diff --git a/src/index.ts b/src/index.ts index 7012424..fa2f0f0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -217,7 +217,12 @@ try { process.exit(0); } if (isJsonMode()) { - emitJsonError({ code: 2, kind: 'usage', message: err.message }); + const target = resolveTargetCommand(program, process.argv.slice(2)); + const subNames = target.commands.map((c: Command) => c.name()).join(', '); + const usefulMessage = subNames + ? `${target.name()}: a subcommand is required. Available: ${subNames}` + : err.message; + emitJsonError({ code: 2, kind: 'usage', message: usefulMessage }); } process.exit(2); } From 0731b00377fddb4e1b2ae99358af73f82348d031 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 9 May 2026 19:55:35 +0800 Subject: [PATCH 04/12] test: add regression tests for P1 (outputHelp) and P2 (lighting type guard) Also fixes root cause gap: Commander throws 'commander.help' (not 'commander.helpDisplayed') for parent-command-without-subcommand. The previous P1 fix only covered 'commander.helpDisplayed'; this commit extends both src/index.ts and tests/helpers/cli.ts to also handle 'commander.help' with the useful subcommand-list message. New tests (9): - error-envelope: parent cmd without subcommand exits 2 + useful JSON - error-envelope: parent cmd with --help exits 0 + structured help JSON - expand: setBrightness/setColor/setColorTemperature on Color Bulb (happy path) - expand: setBrightness on unsupported type (Bot) -> UsageError - expand: setColor on unsupported type (Curtain) -> UsageError - expand: setBrightness on uncached device -> UsageError - expand: lighting command in --json mode emits valid envelope Co-Authored-By: Claude Sonnet 4.6 --- src/index.ts | 11 +++- tests/commands/error-envelope.test.ts | 24 +++++++++ tests/commands/expand.test.ts | 74 +++++++++++++++++++++++++++ tests/helpers/cli.ts | 31 +++++++++-- 4 files changed, 136 insertions(+), 4 deletions(-) diff --git a/src/index.ts b/src/index.ts index fa2f0f0..f42801c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -230,7 +230,16 @@ try { process.exit(0); } if (isJsonMode()) { - emitJsonError({ code: 2, kind: 'usage', message: err.message }); + const errorMessage = err.code === 'commander.help' + ? (() => { + const target = resolveTargetCommand(program, process.argv.slice(2)); + const subNames = target.commands.map((c: Command) => c.name()).join(', '); + return subNames + ? `${target.name()}: a subcommand is required. Available: ${subNames}` + : err.message; + })() + : err.message; + emitJsonError({ code: 2, kind: 'usage', message: errorMessage }); } process.exit(2); } diff --git a/tests/commands/error-envelope.test.ts b/tests/commands/error-envelope.test.ts index 8542c08..74b5c01 100644 --- a/tests/commands/error-envelope.test.ts +++ b/tests/commands/error-envelope.test.ts @@ -19,6 +19,8 @@ import { exitWithError, SCHEMA_VERSION, } from '../../src/utils/output.js'; +import { runCli } from '../helpers/cli.js'; +import { registerCacheCommand } from '../../src/commands/cache.js'; describe('error envelope contract (P5)', () => { let stdoutSpy: ReturnType; @@ -181,6 +183,28 @@ describe('error envelope contract (P5)', () => { }); }); +describe('parent command without subcommand (--json)', () => { + it('exits 2 and emits structured error with useful message', async () => { + const res = await runCli(registerCacheCommand, ['--json', 'cache']); + expect(res.exitCode).toBe(2); + const parsed = JSON.parse(res.stdout.join('\n')); + expect(parsed.schemaVersion).toBe(SCHEMA_VERSION); + expect(parsed.error.code).toBe(2); + expect(parsed.error.kind).toBe('usage'); + expect(parsed.error.message).toMatch(/cache.*subcommand.*required/i); + expect(parsed.error.message).toContain('Available:'); + }); + + it('exits 0 and emits help JSON when --help is passed', async () => { + const res = await runCli(registerCacheCommand, ['--json', 'cache', '--help']); + expect(res.exitCode).toBe(0); + const parsed = JSON.parse(res.stdout.join('\n')); + expect(parsed.schemaVersion).toBe(SCHEMA_VERSION); + expect(parsed.data.name).toBe('cache'); + expect(Array.isArray(parsed.data.subcommands)).toBe(true); + }); +}); + /** * Silence unused-vars — keep Command import available for future command-level * smoke tests under this suite. diff --git a/tests/commands/expand.test.ts b/tests/commands/expand.test.ts index 69dc5b0..027561f 100644 --- a/tests/commands/expand.test.ts +++ b/tests/commands/expand.test.ts @@ -30,12 +30,16 @@ const AC_ID = 'AC-001'; const CURTAIN_ID = 'CURTAIN-001'; const BLIND_ID = 'BLIND-001'; const RELAY_ID = 'RELAY-001'; +const BULB_ID = 'BULB-001'; +const BOT_ID = 'BOT-001'; const sampleBody = { deviceList: [ { deviceId: CURTAIN_ID, deviceName: 'Living Curtain', deviceType: 'Curtain', hubDeviceId: 'H1', enableCloudService: true }, { deviceId: BLIND_ID, deviceName: 'Bedroom Blind', deviceType: 'Blind Tilt', hubDeviceId: 'H1', enableCloudService: true }, { deviceId: RELAY_ID, deviceName: 'Kitchen Switch', deviceType: 'Relay Switch 2PM', hubDeviceId: 'H1', enableCloudService: true }, + { deviceId: BULB_ID, deviceName: 'Bedroom Bulb', deviceType: 'Color Bulb', hubDeviceId: 'H1', enableCloudService: true }, + { deviceId: BOT_ID, deviceName: 'Door Bot', deviceType: 'Bot', hubDeviceId: 'H1', enableCloudService: true }, ], infraredRemoteList: [ { deviceId: AC_ID, deviceName: 'Living AC', remoteType: 'Air Conditioner', hubDeviceId: 'H1', controlType: 'Air Conditioner' }, @@ -242,4 +246,74 @@ describe('devices expand', () => { expect.objectContaining({ command: 'setPosition' }), ); }); + + it('setBrightness on Color Bulb sends correct parameter', async () => { + const res = await runCli(registerDevicesCommand, [ + 'devices', 'expand', BULB_ID, 'setBrightness', '--brightness', '50', + ]); + expect(res.exitCode).toBe(null); + expect(apiMock.__instance.post).toHaveBeenCalledWith( + `/v1.1/devices/${BULB_ID}/commands`, + { command: 'setBrightness', parameter: '50', commandType: 'command' }, + ); + }); + + it('setColor on Color Bulb sends correct parameter', async () => { + const res = await runCli(registerDevicesCommand, [ + 'devices', 'expand', BULB_ID, 'setColor', '--color', '255:0:0', + ]); + expect(res.exitCode).toBe(null); + expect(apiMock.__instance.post).toHaveBeenCalledWith( + `/v1.1/devices/${BULB_ID}/commands`, + { command: 'setColor', parameter: '255:0:0', commandType: 'command' }, + ); + }); + + it('setColorTemperature on Color Bulb sends correct parameter', async () => { + const res = await runCli(registerDevicesCommand, [ + 'devices', 'expand', BULB_ID, 'setColorTemperature', '--color-temp', '4000', + ]); + expect(res.exitCode).toBe(null); + expect(apiMock.__instance.post).toHaveBeenCalledWith( + `/v1.1/devices/${BULB_ID}/commands`, + { command: 'setColorTemperature', parameter: '4000', commandType: 'command' }, + ); + }); + + it('setBrightness on unsupported type (Bot) → UsageError', async () => { + const res = await runCli(registerDevicesCommand, [ + 'devices', 'expand', BOT_ID, 'setBrightness', '--brightness', '50', + ]); + expect(res.exitCode).toBe(2); + expect(res.stderr.join('\n')).toMatch(/Bot.*does not support setBrightness/); + }); + + it('setColor on unsupported type (Curtain) → UsageError', async () => { + const res = await runCli(registerDevicesCommand, [ + 'devices', 'expand', CURTAIN_ID, 'setColor', '--color', '255:0:0', + ]); + expect(res.exitCode).toBe(2); + expect(res.stderr.join('\n')).toMatch(/Curtain.*does not support setColor/); + }); + + it('setBrightness on uncached device ID → UsageError asking to run devices list', async () => { + const res = await runCli(registerDevicesCommand, [ + 'devices', 'expand', 'UNKNOWN-999', 'setBrightness', '--brightness', '50', + ]); + expect(res.exitCode).toBe(2); + expect(res.stderr.join('\n')).toMatch(/not in the local cache/); + }); + + it('setBrightness on Color Bulb emits valid JSON envelope in --json mode', async () => { + const res = await runCli(registerDevicesCommand, [ + 'devices', 'expand', BULB_ID, 'setBrightness', '--brightness', '75', '--json', + ]); + expect(res.exitCode).toBe(null); + const parsed = JSON.parse(res.stdout.join('\n')) as Record; + const data = parsed.data as Record; + expect(data.ok).toBe(true); + expect(data.command).toBe('setBrightness'); + expect(data.parameter).toBe('75'); + expect(data.deviceId).toBe(BULB_ID); + }); }); diff --git a/tests/helpers/cli.ts b/tests/helpers/cli.ts index 24f8e94..11e87e7 100644 --- a/tests/helpers/cli.ts +++ b/tests/helpers/cli.ts @@ -83,12 +83,37 @@ export async function runCli( // Mirror production exitOverride in src/index.ts: non-help/version // Commander errors surface as usage errors (exit 2). if (errAsCommander.code === 'commander.helpDisplayed') { - // Mirror production: emit JSON help when --json is in argv. + const helpRequested = argv.includes('--help') || argv.includes('-h'); + if (helpRequested) { + // Mirror production: emit JSON help when --json is in argv. + if (argv.includes('--json')) { + const target = resolveTargetCommand(program, argv); + stdout.push(JSON.stringify({ schemaVersion: '1.2', data: commandToJson(target) }, null, 2)); + } + exitCode = 0; + } else { + // Parent command called without a required subcommand — mirror production exit 2. + if (argv.includes('--json')) { + const target = resolveTargetCommand(program, argv); + const subNames = target.commands.map((c: Command) => c.name()).join(', '); + const msg = subNames + ? `${target.name()}: a subcommand is required. Available: ${subNames}` + : '(outputHelp)'; + stdout.push(JSON.stringify({ schemaVersion: '1.2', error: { code: 2, kind: 'usage', message: msg } }, null, 2)); + } + exitCode = 2; + } + } else if (errAsCommander.code === 'commander.help') { + // Parent command invoked without a subcommand (Commander 12: 'commander.help'). if (argv.includes('--json')) { const target = resolveTargetCommand(program, argv); - stdout.push(JSON.stringify({ schemaVersion: '1.2', data: commandToJson(target) }, null, 2)); + const subNames = target.commands.map((c: Command) => c.name()).join(', '); + const msg = subNames + ? `${target.name()}: a subcommand is required. Available: ${subNames}` + : '(outputHelp)'; + stdout.push(JSON.stringify({ schemaVersion: '1.2', error: { code: 2, kind: 'usage', message: msg } }, null, 2)); } - exitCode = 0; + exitCode = 2; } else if (errAsCommander.code === 'commander.version') { exitCode = 0; } else { From 2a688b6ec4c718b87150e4de1b9895aa84af420c Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 9 May 2026 20:06:37 +0800 Subject: [PATCH 05/12] =?UTF-8?q?fix:=20expand=20fallback=20for=20uncatalo?= =?UTF-8?q?gued=20lighting=20devices;=20rules=20disabled=20=E2=86=92=20err?= =?UTF-8?q?or=20envelope?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue 1: Floor Lamp, Light Strip, Dimmer, Fill Light are supported by param-validator but absent from the catalog. The catalog check added in the previous commit wrongly rejected them. Fix: export isLightingCommandSupported() and use it as fallback when findCatalogEntry returns null. Issue 2: rules run --json with automation.enabled=false emitted a {data:...} success envelope with exit 1, violating the JSON protocol. Fix: use exitWithError({code:1, kind:'runtime',...}) so the output is {error:...} consistent with all other failure paths. New tests (+3): Floor Lamp setBrightness passes, Light Strip setColor passes, rules run --json disabled emits error envelope with code 1. Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 1 + src/commands/expand.ts | 6 ++++-- src/commands/rules.ts | 13 ++++++------- src/devices/param-validator.ts | 6 ++++++ tests/commands/expand.test.ts | 30 ++++++++++++++++++++++++++++-- tests/commands/rules.test.ts | 24 ++++++++++++++++++++++++ 6 files changed, 69 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb7a99f..b6a818f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - **`catalog show --json`**: `data` is now always an array (single-entry array when filtering by type). Previously was a bare object for single-type queries. - **`devices commands --json`**: same change — `data` is always an array. - **`_fetchedAt` renamed to `fetchedAt`**: removed underscore prefix from the CLI-added timestamp field in `devices status` JSON output. +- **`rules run --json` when `automation.enabled` is false**: previously emitted `{data: {kind:"control", controlKind:"disabled"}}` (success envelope) with exit 1. Now emits `{error: {code:1, kind:"runtime", message:"..."}}` (error envelope) — consistent with the JSON protocol. ### Added diff --git a/src/commands/expand.ts b/src/commands/expand.ts index a1a3274..7ff806b 100644 --- a/src/commands/expand.ts +++ b/src/commands/expand.ts @@ -15,6 +15,7 @@ import { buildBrightnessSet, buildColorSet, buildColorTemperatureSet, + isLightingCommandSupported, } from '../devices/param-validator.js'; // ---- Registration ---------------------------------------------------------- @@ -147,10 +148,11 @@ Examples: } const catalogResult = findCatalogEntry(cached.type); const catalogEntry = Array.isArray(catalogResult) ? catalogResult[0] : catalogResult; - if (!catalogEntry || !catalogEntry.commands.some((c: { command: string }) => c.command === command)) { + const supportedByCatalog = catalogEntry?.commands.some((c: { command: string }) => c.command === command) ?? false; + if (!supportedByCatalog && !isLightingCommandSupported(cached.type, command)) { throw new UsageError( `Device type "${cached.type}" does not support ${command}. ` + - `Supported on: Color Bulb, Strip Light, Ceiling Light, and similar lighting devices.` + `Supported on: Color Bulb, Strip Light, Ceiling Light, Floor Lamp, and similar lighting devices.` ); } if (command === 'setBrightness') { diff --git a/src/commands/rules.ts b/src/commands/rules.ts index c18e974..32bd817 100644 --- a/src/commands/rules.ts +++ b/src/commands/rules.ts @@ -209,13 +209,12 @@ function registerRun(rules: Command): void { if (!loaded) return; if (loaded.automation?.enabled !== true) { - const msg = 'automation.enabled is not true — set it to true in your policy file to start the daemon.'; - if (isJsonMode()) { - printJson({ kind: 'control', controlKind: 'disabled', message: msg }); - } else { - console.error(msg); - } - process.exit(1); + exitWithError({ + code: 1, + kind: 'runtime', + message: 'automation.enabled is not true — set it to true in your policy file to start the daemon.', + hint: 'Set automation.enabled: true in your policy file, then re-run.', + }); } const lint = lintRules(loaded.automation); diff --git a/src/devices/param-validator.ts b/src/devices/param-validator.ts index 4b56f0b..9399c3e 100644 --- a/src/devices/param-validator.ts +++ b/src/devices/param-validator.ts @@ -180,6 +180,12 @@ function isColorDevice(deviceType: string): boolean { ); } +export function isLightingCommandSupported(deviceType: string, command: string): boolean { + if (command === 'setBrightness' || command === 'setColorTemperature') return isBrightnessDevice(deviceType); + if (command === 'setColor') return isColorDevice(deviceType); + return false; +} + function validateSetBrightness(raw: string | undefined): ValidateResult { if (raw === undefined || raw === '' || raw === 'default') { return { diff --git a/tests/commands/expand.test.ts b/tests/commands/expand.test.ts index 027561f..32bd06f 100644 --- a/tests/commands/expand.test.ts +++ b/tests/commands/expand.test.ts @@ -32,14 +32,18 @@ const BLIND_ID = 'BLIND-001'; const RELAY_ID = 'RELAY-001'; const BULB_ID = 'BULB-001'; const BOT_ID = 'BOT-001'; +const LAMP_ID = 'LAMP-001'; +const STRIP_ID = 'STRIP-001'; const sampleBody = { deviceList: [ { deviceId: CURTAIN_ID, deviceName: 'Living Curtain', deviceType: 'Curtain', hubDeviceId: 'H1', enableCloudService: true }, { deviceId: BLIND_ID, deviceName: 'Bedroom Blind', deviceType: 'Blind Tilt', hubDeviceId: 'H1', enableCloudService: true }, { deviceId: RELAY_ID, deviceName: 'Kitchen Switch', deviceType: 'Relay Switch 2PM', hubDeviceId: 'H1', enableCloudService: true }, - { deviceId: BULB_ID, deviceName: 'Bedroom Bulb', deviceType: 'Color Bulb', hubDeviceId: 'H1', enableCloudService: true }, - { deviceId: BOT_ID, deviceName: 'Door Bot', deviceType: 'Bot', hubDeviceId: 'H1', enableCloudService: true }, + { deviceId: BULB_ID, deviceName: 'Bedroom Bulb', deviceType: 'Color Bulb', hubDeviceId: 'H1', enableCloudService: true }, + { deviceId: BOT_ID, deviceName: 'Door Bot', deviceType: 'Bot', hubDeviceId: 'H1', enableCloudService: true }, + { deviceId: LAMP_ID, deviceName: 'Desk Lamp', deviceType: 'Floor Lamp', hubDeviceId: 'H1', enableCloudService: true }, + { deviceId: STRIP_ID, deviceName: 'TV Strip', deviceType: 'Light Strip', hubDeviceId: 'H1', enableCloudService: true }, ], infraredRemoteList: [ { deviceId: AC_ID, deviceName: 'Living AC', remoteType: 'Air Conditioner', hubDeviceId: 'H1', controlType: 'Air Conditioner' }, @@ -316,4 +320,26 @@ describe('devices expand', () => { expect(data.parameter).toBe('75'); expect(data.deviceId).toBe(BULB_ID); }); + + it('setBrightness on Floor Lamp (not in catalog, supported by validator) succeeds', async () => { + const res = await runCli(registerDevicesCommand, [ + 'devices', 'expand', LAMP_ID, 'setBrightness', '--brightness', '40', + ]); + expect(res.exitCode).toBe(null); + expect(apiMock.__instance.post).toHaveBeenCalledWith( + `/v1.1/devices/${LAMP_ID}/commands`, + { command: 'setBrightness', parameter: '40', commandType: 'command' }, + ); + }); + + it('setColor on Light Strip (not in catalog, supported by validator) succeeds', async () => { + const res = await runCli(registerDevicesCommand, [ + 'devices', 'expand', STRIP_ID, 'setColor', '--color', '0:255:0', + ]); + expect(res.exitCode).toBe(null); + expect(apiMock.__instance.post).toHaveBeenCalledWith( + `/v1.1/devices/${STRIP_ID}/commands`, + { command: 'setColor', parameter: '0:255:0', commandType: 'command' }, + ); + }); }); diff --git a/tests/commands/rules.test.ts b/tests/commands/rules.test.ts index ac9274d..fbdefcf 100644 --- a/tests/commands/rules.test.ts +++ b/tests/commands/rules.test.ts @@ -256,6 +256,30 @@ describe('switchbot rules (commander surface)', () => { expect(exitCode).toBe(1); expect(stderr.join('\n')).toContain('automation.enabled is not true'); }); + + it('--json: exits 1 with error envelope when automation.enabled is false', async () => { + const p = path.join(tmpDir, 'policy.yaml'); + fs.writeFileSync( + p, + v02Policy( + [ + 'automation:', + ' enabled: false', + ' rules: []', + '', + ].join('\n'), + ), + 'utf-8', + ); + const { stdout, exitCode } = await runCli(['--json', 'rules', 'run', p]); + expect(exitCode).toBe(1); + const parsed = JSON.parse(stdout.join('\n')); + expect(parsed.error).toBeDefined(); + expect(parsed.error.code).toBe(1); + expect(parsed.error.kind).toBe('runtime'); + expect(parsed.error.message).toContain('automation.enabled is not true'); + expect(parsed.data).toBeUndefined(); + }); }); describe('rules reload', () => { From f1b387685a28329aa4f3503db08d889aa58983cb Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 9 May 2026 20:34:39 +0800 Subject: [PATCH 06/12] =?UTF-8?q?docs:=20update=20README=20and=20docs=20fo?= =?UTF-8?q?r=20v3.4.1=20=E2=80=94=20schemaVersion=201.2,=20policy=20v0.2,?= =?UTF-8?q?=20test=20count?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - json-contract.md: bump schemaVersion examples 1.1 → 1.2 (4 places) - policy-reference.md: schema file v0.1.json → v0.2.json; v0.2 is now the current required version (not opt-in); update schema version table, automation block description, exit code table, JSON envelope example, common errors table, and "Migrating" section - README.md: test count 2204 → 2216; add setBrightness/setColor/ setColorTemperature to `devices expand` examples; correct --json error envelope (stdout, not stderr; includes schemaVersion) Co-Authored-By: Claude Sonnet 4.6 --- README.md | 15 ++++++--- docs/json-contract.md | 8 ++--- docs/policy-reference.md | 71 ++++++++++++++++++++++------------------ 3 files changed, 54 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 1a9243d..7201b16 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ Under the hood every surface shares the same catalog, cache, and HMAC client — - 🎨 **Dual output modes** — colorized tables by default; `--json` passthrough for `jq` and scripting - 🔐 **Secure credentials** — HMAC-SHA256 signed requests; config file written with `0600`; env-var override for CI - 🔍 **Dry-run mode** — preview every mutating request before it hits the API -- 🧪 **Fully tested** — 2204 Vitest tests, mocked axios, zero network in CI +- 🧪 **Fully tested** — 2216 Vitest tests, mocked axios, zero network in CI - ⚡ **Shell completion** — Bash / Zsh / Fish / PowerShell ## Requirements @@ -596,9 +596,14 @@ switchbot devices expand setPosition --direction up --angle 50 # Relay Switch — setMode switchbot devices expand setMode --channel 1 --mode edge + +# Color Bulb / Strip Light / Floor Lamp / Ceiling Light — setBrightness / setColor / setColorTemperature +switchbot devices expand setBrightness --brightness 80 +switchbot devices expand setColor --color "#FF0000" +switchbot devices expand setColorTemperature --color-temp 4000 ``` -Run `switchbot devices expand --help` to see the available flags for any device command. `expand` is only meaningful for multi-parameter commands (the four above); single-parameter commands like `setBrightness 50` or `setColor "#FF0000"` are already flag-free at the CLI level. +Run `switchbot devices expand --help` to see the available flags for any device command. #### `devices explain` — one-shot device summary @@ -1104,7 +1109,7 @@ The default policy schema shipped with the CLI (`src/policy/schema/v0.2.json`) i ## Output modes - **Default** — ANSI-colored tables for `list`/`status`, key-value tables for details. -- **`--json`** — raw API payload passthrough. Output is the exact JSON the SwitchBot API returned, ideal for `jq` and scripting. Errors are also JSON on stderr: `{ "error": { "code", "kind", "message", "hint?" } }`. +- **`--json`** — raw API payload passthrough. Output is the exact JSON the SwitchBot API returned, ideal for `jq` and scripting. Errors are also JSON on **stdout**: `{ "schemaVersion": "1.2", "error": { "code", "kind", "message", "hint?" } }`. - **`--format=json`** — projected row view. Same JSON structure but built from the CLI's column model (`--fields` applies). Use this when you only want specific fields. - **`--format=tsv|yaml|jsonl|id`** — tabular text formats; `--fields` filters columns. @@ -1209,7 +1214,7 @@ npm install npm run dev -- # Run from TypeScript sources via tsx npm run build # Compile to dist/ -npm test # Run the Vitest suite (2204 tests) +npm test # Run the Vitest suite (2216 tests) npm run test:watch # Watch mode npm run test:coverage # Coverage report (v8, HTML + text) ``` @@ -1300,7 +1305,7 @@ src/ ├── format.ts # renderRows / filterFields / output-format dispatch ├── audit.ts # JSONL audit log writer └── quota.ts # Local daily-quota counter -tests/ # Vitest suite (2204 tests, mocked axios, no network) +tests/ # Vitest suite (2216 tests, mocked axios, no network) ``` ### Release flow diff --git a/docs/json-contract.md b/docs/json-contract.md index bb60eac..936922f 100644 --- a/docs/json-contract.md +++ b/docs/json-contract.md @@ -20,7 +20,7 @@ stdout. ```json { - "schemaVersion": "1.1", + "schemaVersion": "1.2", "data": } ``` @@ -34,7 +34,7 @@ stdout. ```json { - "schemaVersion": "1.1", + "schemaVersion": "1.2", "error": { "code": 2, "kind": "usage" | "guard" | "api" | "runtime", @@ -125,7 +125,7 @@ envelope: ```json { - "schemaVersion": "1.1", + "schemaVersion": "1.2", "data": { "t": "2026-04-21T14:23:45.012Z", "tick": 1, @@ -184,7 +184,7 @@ switchbot devices status BOT1 --json | jq -e '.error' && exit 1 ## 4. Versioning -- The non-streaming envelope is versioned as `schemaVersion: "1.1"`. +- The non-streaming envelope is versioned as `schemaVersion: "1.2"`. - The streaming header and event envelope are versioned as `schemaVersion: "1"`. - The two axes are deliberately separate: adding a field inside `data` diff --git a/docs/policy-reference.md b/docs/policy-reference.md index 3f951d8..e706ea8 100644 --- a/docs/policy-reference.md +++ b/docs/policy-reference.md @@ -8,7 +8,7 @@ edit the generated file — every block in it is commented with a summary. The JSON Schema that backs this document lives at -`src/policy/schema/v0.1.json` (Draft 2020-12). It is also mirrored to +`src/policy/schema/v0.2.json` (Draft 2020-12). It is also mirrored to `examples/policy.schema.json` for editor autocomplete. --- @@ -37,22 +37,26 @@ memory, and writes back. The top-level `version` field is **required**. The CLI currently supports two schemas: -| Version | Emitted by `policy new` | What it adds | +| Version | Status | What it adds | |---|---|---| -| `"0.1"` | Default (today) | aliases, confirmations, quiet_hours, audit, cli | -| `"0.2"` | Opt-in via `policy migrate` | typed `automation.rules[]` for the preview rules engine | +| `"0.1"` | Legacy — migrate with `policy migrate` | aliases, confirmations, quiet_hours, audit, cli | +| `"0.2"` | **Current (required)** | typed `automation.rules[]` for the rules engine | A file with anything other than `"0.1"` or `"0.2"` fails validation -with a named `unsupported-version` error. When the rules engine exits -preview and v0.2 becomes the default, `switchbot policy migrate` will -continue to be an opt-in upgrade — comments and non-version blocks -are preserved verbatim, and the command refuses to rewrite the file -if the upgraded document would not validate (exit code 7). +with a named `unsupported-version` error. v0.2 is the default emitted +by `switchbot policy new`. Existing v0.1 files can be upgraded in-place: + +```bash +switchbot policy migrate # in-place upgrade, preserves comments +``` + +`policy migrate` applies additive changes only (new optional fields, +tighter types on reserved blocks), rewrites the `version` constant, and +refuses to migrate if any user edits would conflict (exit code 7). ```yaml -version: "0.1" # stable today -# or -version: "0.2" # opt-in for rules engine preview +version: "0.2" # current default +# version: "0.1" # legacy — upgrade with `switchbot policy migrate` ``` --- @@ -175,11 +179,10 @@ PowerShell scheduled task, etc.) should honour the value. ### `automation` -Rule engine block. In **v0.1** this is a reserved stub — set -`enabled: false` (the default) and ignore it; the CLI prints a warning -and skips the block if you flip `enabled: true` on v0.1. In **v0.2** -this block drives the preview rules engine exposed by -`switchbot rules run`. +Rule engine block. Available in **v0.2** — set `enabled: true` to activate +`switchbot rules run`. In **v0.1** this block is a reserved stub; flip +`enabled: true` on v0.1 and the CLI prints a warning and skips the block. +Run `switchbot policy migrate` first to unlock the rules engine. ```yaml automation: @@ -277,7 +280,7 @@ Exit codes: | Code | Meaning | |---|---| -| 0 | File is valid and matches schema v0.1 | +| 0 | File is valid and matches schema v0.2 | | 1 | File is missing | | 2 | YAML is malformed (parse error, with line/col) | | 3 | Schema violation (line-accurate error with hint) | @@ -297,7 +300,7 @@ For machine consumption, pass `--json`. The envelope is the standard ```json { - "schemaVersion": "1.1", + "schemaVersion": "1.2", "error": { "kind": "usage", "message": "lowercase deviceId at policy.yaml:12:14", @@ -316,8 +319,9 @@ For machine consumption, pass `--json`. The envelope is the standard | Error | Trigger | Fix | |---|---|---| -| `missing version` | Top-level `version` is absent | Add `version: "0.1"` | -| `wrong version` | `version` is anything but `"0.1"` | Run `switchbot policy migrate` | +| `missing version` | Top-level `version` is absent | Add `version: "0.2"` | +| `unsupported version` | `version` is not `"0.1"` or `"0.2"` | Check spelling; run `switchbot policy migrate` to upgrade from v0.1 | +| `wrong version` | `version: "0.1"` on a CLI that requires v0.2 | Run `switchbot policy migrate` | | `lowercase deviceId` | `aliases` value isn't UPPERCASE | Uppercase the ID (it is in `devices list`) | | `destructive in never_confirm` | `lock`/`unlock`/etc in `confirmations.never_confirm` | Remove it; intentional by design | | `quiet_hours.start without end` | Only one of the two times is set | Set both, or remove the block | @@ -331,19 +335,24 @@ machine-readable `rule` field so tooling can suggest fixes. ## Migrating between schema versions -v0.1 is the only published schema today. v0.2 (Phase 4) will add a -structured `rules[]` definition under `automation`. When it ships, -`switchbot policy migrate` will: +v0.2 is the current required schema. If you have a v0.1 file from an +earlier release, upgrade it: + +```bash +switchbot policy migrate # in-place upgrade, preserves comments +``` + +`policy migrate`: -1. Detect your current `version` field. -2. Apply additive changes only (new optional fields, tighter types on +1. Detects your current `version` field. +2. Applies additive changes only (new optional fields, tighter types on reserved blocks). -3. Rewrite the file with the new `version` constant. -4. Refuse to migrate if any user edits conflict, and explain what - conflicts. +3. Rewrites the file with the new `version` constant. +4. Refuses to migrate if any user edits conflict, and explains what + conflicts (exit code 7). -Until then, `policy migrate` is a no-op that verifies the file is -already current. +After migrating, run `switchbot policy validate` to confirm the file is +valid before using the rules engine. --- From f6b596f77b7a3fe6fb8939c83431cfe51dc3b05e Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 9 May 2026 20:42:14 +0800 Subject: [PATCH 07/12] =?UTF-8?q?docs:=20trim=20README=20by=20~37%=20(1331?= =?UTF-8?q?=20=E2=86=92=20834=20lines)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cuts without losing substance: - ToC: 38-item nested list → compact inline links - Filter expressions per-command reference → removed (duplicates --help) - Parameter formats table + prose → condensed to one paragraph - Project layout tree (85 lines) → one-sentence directory summary - Rules engine workflow (10 steps + guardrails block) → flat command list - status-sync required/optional I/O lists → single prose sentence - mqtt-tail sinks table → mention + pointer to --help - upgrade-check JSON output dump → single inline note - Events tail/mqtt-tail pm2/nohup block → removed - Cache section: two sub-sections + status-cache GC → single table - Various verbose prose → condensed per-section Co-Authored-By: Claude Sonnet 4.6 --- README.md | 696 ++++++++---------------------------------------------- 1 file changed, 99 insertions(+), 597 deletions(-) diff --git a/README.md b/README.md index 7201b16..e0da7c0 100644 --- a/README.md +++ b/README.md @@ -45,44 +45,14 @@ Under the hood every surface shares the same catalog, cache, and HMAC client — ## Table of contents -- [Features](#features) -- [Requirements](#requirements) -- [Installation](#installation) +- [Features](#features) · [Requirements](#requirements) · [Installation](#installation) - [Quick start](#quick-start) - [Credentials](#credentials) -- [Policy](#policy) +- [Policy](#policy) · [Rules engine](#rules-engine) - [Global options](#global-options) -- [Commands](#commands) - - [`config`](#config--credential-management) - - [`devices`](#devices--list-status-control) - - [`devices batch`](#devices-batch--bulk-commands) - - [`devices watch`](#devices-watch--poll-status) - - [`scenes`](#scenes--run-manual-scenes) - - [`webhook`](#webhook--receive-device-events-over-http) - - [`events`](#events--receive-device-events) - - [`status-sync`](#status-sync--mqttopenclaw-bridge) - - [`plan`](#plan--declarative-batch-operations) - - [`mcp`](#mcp--model-context-protocol-server) - - [`doctor`](#doctor--self-check) - - [`health`](#health--runtime-health-report) - - [`upgrade-check`](#upgrade-check--version-check) - - [`quota`](#quota--api-request-counter) - - [`history`](#history--audit-log) - - [`catalog`](#catalog--device-type-catalog) - - [`schema`](#schema--export-catalog-as-json) - - [`capabilities`](#capabilities--cli-manifest) - - [`cache`](#cache--inspect-and-clear-local-cache) - - [`policy`](#policy--validate-scaffold-and-migrate-policyyaml) - - [`daemon`](#daemon--background-rules-engine-process) - - [`completion`](#completion--shell-tab-completion) -- [Output modes](#output-modes) - - [Cache](#cache) -- [Exit codes & error codes](#exit-codes--error-codes) -- [Environment variables](#environment-variables) -- [Scripting examples](#scripting-examples) -- [Development](#development) -- [License](#license) -- [References](#references) +- [Commands](#commands): [config](#config--credential-management) · [devices](#devices--list-status-control) · [scenes](#scenes--run-manual-scenes) · [webhook](#webhook--receive-device-events-over-http) · [events](#events--receive-device-events) · [status-sync](#status-sync--mqttopenclaw-bridge) · [daemon](#daemon--background-rules-engine-process) · [plan](#plan--declarative-batch-operations) · [mcp](#mcp--model-context-protocol-server) · [doctor](#doctor--self-check) · [health](#health--runtime-health-report) · [upgrade-check](#upgrade-check--version-check) · [quota](#quota--api-request-counter) · [history](#history--audit-log) · [catalog](#catalog--device-type-catalog) · [schema](#schema--export-catalog-as-json) · [capabilities](#capabilities--cli-manifest) · [cache](#cache--inspect-and-clear-local-cache) · [policy cmd](#policy--validate-scaffold-and-migrate-policyyaml) · [completion](#completion--shell-tab-completion) +- [Output modes](#output-modes) · [Cache](#cache) · [Exit codes](#exit-codes--error-codes) · [Environment variables](#environment-variables) +- [Scripting examples](#scripting-examples) · [Development](#development) · [License](#license) --- @@ -296,110 +266,44 @@ then: template: '{"rule":"{{ rule.name }}","fired":"{{ rule.fired_at }}"}' ``` -**LLM condition** — add an AI judgement step before actions fire. The engine calls the -configured LLM provider, passes the prompt plus recent event context, and gates execution -on the model's yes/no answer: +**LLM condition** — add an AI judgement step before actions fire: ```yaml conditions: - llm: prompt: "Is the temperature above normal comfort range?" provider: auto # auto | openai | anthropic - cache_ttl: 5m # skip redundant calls for identical context + cache_ttl: 5m budget: max_calls_per_hour: 20 on_error: pass # fail | pass | skip ``` -Set `OPENAI_API_KEY` or `ANTHROPIC_API_KEY` (provider `auto` tries Anthropic first). -`rules lint` flags misconfigured LLM conditions (no provider key, cache TTL too high for -the trigger frequency, budget zero). Evaluation decisions are recorded in the trace log. +Set `OPENAI_API_KEY` or `ANTHROPIC_API_KEY`. `rules lint` flags misconfigured LLM conditions. -**Decision trace** — enable `automation.audit.evaluate_trace` in `policy.yaml` to record -every evaluation decision (why a rule fired or was blocked): - -```yaml -automation: - audit: - evaluate_trace: sampled # full | sampled | off (default: sampled) - evaluate_retention_days: 7 -``` +**Decision trace** — set `automation.audit.evaluate_trace: sampled` (or `full`) in `policy.yaml` to record every evaluation decision. ```bash -# 1. Author rules under `automation.rules`. See examples/policies/automation.yaml -# for a walkthrough covering the three trigger sources. - -# 2. Static-check before running. -switchbot rules lint # exit 0 valid, 1 error -switchbot rules list --json | jq . # structured summary - -# 3. Inspect a single rule in full detail (trigger, conditions, actions, -# cooldown, hysteresis, maxFiringsPerHour, suppressIfAlreadyDesired, last fired). -switchbot rules explain "motion on" -switchbot rules explain "motion on" --json - -# 4. Run the engine. --dry-run overrides every rule into audit-only mode; -# --max-firings bounds a demo session. -switchbot rules run --dry-run --max-firings 5 - -# 5. Edit policy.yaml in another shell, then hot-reload without restart. -switchbot daemon reload # managed daemon reload - -# 6. Review recorded fires. -switchbot rules tail --follow # stream rule-* audit lines -switchbot rules replay --since 1h --json # per-rule fires/dries/throttled/errors -switchbot rules summary # aggregate fires/errors per rule (24h window) -switchbot rules last-fired -n 20 # 20 most recent fire entries +switchbot rules lint # static check: exit 0 valid, 1 error +switchbot rules list --json | jq . # structured rule summary +switchbot rules explain "motion on" # trigger, conditions, actions, last fired +switchbot rules run --dry-run --max-firings 5 # run engine; --dry-run = audit only +switchbot daemon reload # hot-reload policy without restart -# 7. Conflict and health analysis. -switchbot rules conflicts # opposing actions, high-frequency MQTT, - # destructive commands, quiet-hours gaps -switchbot rules doctor --json # lint + conflicts combined; exit 0 when clean +switchbot rules tail --follow # stream rule-* audit lines +switchbot rules replay --since 1h --json # per-rule fires/dries/throttled/errors +switchbot rules summary # aggregate fires/errors (24h) +switchbot rules conflicts # opposing actions, destructive cmds, quiet-hours gaps +switchbot rules doctor --json # lint + conflicts; exit 0 when clean -# 8. Scaffold a new rule from natural language (heuristic or LLM-backed). switchbot rules suggest --intent "turn off AC at 11pm" -switchbot rules suggest --intent "if door opens and temp below 20 turn on heater" \ - --llm auto # routes complex intents to LLM automatically -switchbot rules suggest --intent "..." --llm openai # explicit backend -# Set OPENAI_API_KEY or ANTHROPIC_API_KEY; auto mode falls back to heuristic on failure - -# 9. Explain why a specific evaluation fired or was blocked (requires evaluate_trace). -switchbot rules trace-explain --rule "motion on" --last -switchbot rules trace-explain --rule "motion on" --since 1h --json -switchbot rules trace-explain # single evaluation by ID - -# 10. Simulate a rule against historical events without running the engine. -switchbot rules simulate "motion on" # replay last 24h from audit log -switchbot rules simulate "motion on" --since 7d --json -switchbot rules simulate policy.yaml --rule "night AC" --against events.jsonl -``` - -`rules suggest` enforces several guardrails on LLM output so a model can't quietly arm -something unsafe: - -- **`dry_run` is forced to `true`** on every LLM-generated rule. Review the output and - flip it yourself before running the engine without `--dry-run`. -- **Explicit overrides always win.** If you pass `--trigger`, the LLM's answer must match; - a mismatch fails fast. Within the same trigger, mismatched `--event` / `--schedule` / - `--days` / `--webhook-path` are rewritten to your value with a warning. -- **`--llm` is enum-validated at the CLI** (`auto | openai | anthropic`) — junk values - exit non-zero instead of falling through. -- **Notify URLs must be `http://` or `https://`.** `rules lint` and the runtime both - reject `file://`, `ftp://`, etc., so a generated webhook can't smuggle in a non-HTTP - scheme. - -When `quiet_hours` is configured in `policy.yaml`, `rules conflicts` additionally flags event-driven (MQTT / webhook) rules that lack a `time_between` condition — they would fire uninhibited during the quiet window. The hint in each finding includes a ready-to-paste `time_between` condition to add. +switchbot rules suggest --intent "..." --llm auto # LLM-backed (OPENAI_API_KEY or ANTHROPIC_API_KEY) -Webhook trigger token management: - -```bash -switchbot rules webhook-rotate-token # rotate the bearer token for webhook triggers -switchbot rules webhook-show-token # print current token (creates one if absent) +switchbot rules trace-explain --rule "motion on" --last # why a rule fired/was blocked +switchbot rules simulate "motion on" --since 7d --json # replay without running the engine ``` -See [`docs/design/phase4-rules.md`](./docs/design/phase4-rules.md) for -the engine's pipeline (subscribe → classify → match → conditions → -throttle → action → audit). +LLM-generated rules always have `dry_run: true` — flip it yourself after review. Notify URLs must be `http://` or `https://`. See [`docs/design/phase4-rules.md`](./docs/design/phase4-rules.md) for the full pipeline. ## Global options @@ -424,23 +328,11 @@ throttle → action → audit). - `-V`, `--version`: Print the CLI version. - `-h`, `--help`: Show help for any command or subcommand. -Every subcommand supports `--help`, and most include a parameter-format reference and examples. - -```bash -switchbot --help -switchbot devices command --help -``` - -> **Tip — required-value flags and subcommands.** Flags like `--profile`, `--timeout`, `--max`, and `--interval` take a value. If you omit it, Commander will happily consume the next token — including a subcommand name. Since v2.2.1 the CLI rejects that eagerly (exit 2 with a clear error), but if you ever hit `unknown command 'list'` after something like `switchbot --profile list`, use the `--flag=value` form: `switchbot --profile=home devices list`. +Every subcommand supports `--help`. Use `--flag=value` form when a flag takes a value and is followed by a subcommand (e.g. `switchbot --profile=home devices list`). ### `--dry-run` -Intercepts every non-GET request: the CLI prints the URL/body it would have -sent, then exits `0` without contacting the API. `GET` requests (list, status, -query) are still executed so you can preview the state involved. Dry-run also -validates command names against the device catalog and rejects unknown commands -(exit 2) when the device type has a known catalog entry. Commands sent to -read-only sensors (e.g. Meter) are likewise rejected. +Intercepts every non-GET request: prints the URL/body it would have sent, then exits `0`. GET requests still execute. Also validates command names against the device catalog (exit 2 on unknown commands or read-only sensors). ```bash switchbot devices command ABC123 turnOn --dry-run @@ -456,127 +348,44 @@ switchbot devices command ABC123 turnOn --dry-run switchbot config set-token # Save to ~/.switchbot/config.json switchbot config show # Print current source + masked secret switchbot config list-profiles # List saved profiles - -# Print (or write) the recommended AI-agent profile template -switchbot config agent-profile # print to stdout -switchbot config agent-profile --write # write to ~/.switchbot/profiles/agent.json (mode 0600) -switchbot config agent-profile --write --force # overwrite if it already exists -switchbot config agent-profile --json # structured JSON envelope +switchbot config agent-profile --write # write recommended AI-agent profile (mode 0600) ``` ### `devices` — list, status, control ```bash # List all physical devices and IR remote devices -# Default columns (4): deviceId, deviceName, type, category -# Pass --wide for the full 10-column operator view -switchbot devices list -switchbot devices ls # short alias for 'list' -switchbot devices list --wide +switchbot devices list # default 4 columns: deviceId, deviceName, type, category +switchbot devices list --wide # full 10-column operator view switchbot devices list --json | jq '.deviceList[].deviceId' - -# IR remotes: type = remoteType (e.g. "TV"), category = "ir" -# Physical: category = "physical" switchbot devices list --format=tsv --fields=deviceId,type,category -# Filter devices by type / name / category / room (server-side filter keys) -switchbot devices list --filter category=physical -switchbot devices list --filter type=Bot -switchbot devices list --filter name=living,category=physical - -# Filter operators: = (substring; exact for `category`), ~ (substring), -# =/regex/ (case-insensitive regex). Clauses are AND-ed. -switchbot devices list --filter 'name~living' -switchbot devices list --filter 'type=/Hub.*/' -switchbot devices list --filter 'name~office,type=/Bulb|Strip/' - -# Filter by family / room (family & room info requires the platform source -# header, which this CLI sends on every request) -switchbot devices list --json | jq '.deviceList[] | select(.familyName == "Home")' -switchbot devices list --json | jq '[.deviceList[], .infraredRemoteList[]] | group_by(.familyName)' +# Filter by type / name / category / room +# Operators: = (substring; exact for category), ~ (substring), =/regex/; clauses AND-ed +switchbot devices list --filter 'type=Bot' +switchbot devices list --filter 'name~living,type=/Bulb|Strip/' +switchbot devices list --filter 'category=physical' -# Query real-time status of a physical device +# Query real-time status switchbot devices status -switchbot devices status --json +switchbot devices status --ids ABC,DEF,GHI # batch status +switchbot devices status --ids ABC,DEF --fields power,battery --format jsonl # Resolve device by fuzzy name instead of ID (status, command, describe, expand, watch) switchbot devices status --name "Living Room AC" switchbot devices command --name "Office Light" turnOn -switchbot devices describe --name "Kitchen Bot" - -# Batch status across multiple devices -switchbot devices status --ids ABC,DEF,GHI -switchbot devices status --ids ABC,DEF --fields power,battery # only show specific fields -switchbot devices status --ids ABC,DEF --format jsonl # one JSON line per device # Send a control command switchbot devices command [parameter] [--type command|customize] -# Describe a specific device (1 API call): metadata + supported commands + status fields -switchbot devices describe -switchbot devices describe --json - -# Discover what's supported (offline reference, no API call) -switchbot devices types # List all device types + IR remote types (incl. role column) -switchbot devices commands # Show commands, parameter formats, and status fields -switchbot devices commands Bot -switchbot devices commands "Smart Lock" -switchbot devices commands curtain # Case-insensitive, substring match -``` - -#### Filter expressions — per-command reference - -Three commands accept `--filter`. They share one four-operator grammar, -but each exposes its own key set: - -- `devices list` - Operators: `=` (substring; **exact** for `category`), `!=` (negated), - `~` (substring), `=/regex/` (case-insensitive regex). - Keys: `type`, `name`, `category`, `room`. -- `devices batch` - Operators: same as `devices list`. - Keys: `type`, `family`, `room`, `category`. -- `events tail` / `events mqtt-tail` - Operators: same (tail only; mqtt-tail uses `--topic` instead). - Keys: `deviceId`, `type`. - -Clauses are comma-separated and AND-ed. No OR across clauses — use regex -alternation (`=/A|B/`) for that. `category` is the one key that stays exact -under `=` / `!=` to preserve `category=physical` / `category!=ir` semantics. -A clause with an empty value (e.g. `name~`, `type=`) is rejected with exit 2 — -the parser refuses to guess whether an empty value means "no constraint" or -"match empty string". Drop the clause outright to remove the constraint. - -#### Parameter formats - -`parameter` is optional — omit it for commands like `turnOn`/`turnOff` (auto-defaults to `"default"`). -Numeric-only and JSON-object parameters are auto-parsed; strings with colons / commas / semicolons pass through as-is. - -For the exact commands and parameter formats a specific device supports, query the built-in catalog: - -```bash -switchbot devices commands # e.g. Bot, Curtain, "Smart Lock", "Robot Vacuum Cleaner S10" +# Offline reference (no API call) +switchbot devices types # all device types +switchbot devices commands # commands, parameter formats, status fields ``` -Generic parameter shapes (which one applies is decided by the device — see the catalog): - -| Shape | Example | -| ------------------- | -------------------------------------------------------- | -| _(none)_ | `devices command turnOn` | -| `` | `devices command setBrightness 75` | -| `` | `devices command setColor "255:0:0"` | -| `` | `devices command setPosition "up;60"` | -| `` | `devices command setAll "26,1,3,on"` | -| `` | `'{"action":"sweep","param":{"fanLevel":2,"times":1}}'` | -| Custom IR button | `devices command MyButton --type customize` | - -Parameters for `setAll` (Air Conditioner), `setPosition` (Curtain / Blind Tilt), `setMode` (Relay Switch), `setBrightness` (dimmable lights), and `setColor` (Color Bulb / Strip Light / Ceiling Light) are validated client-side before the request — malformed shapes, out-of-range values, and JSON for CSV fields all fail fast with exit 2. `setColor` accepts `R:G:B`, `R,G,B`, `#RRGGBB`, `#RGB`, and CSS named colors (`red`, `blue`, …); all normalize to `R:G:B` before hitting the API. Pass `--skip-param-validation` to bypass (escape hatch — prefer fixing the argument). Command names are also case-normalized against the catalog (e.g. `turnon` is auto-corrected to `turnOn` with a stderr warning); unknown names still exit 2 with the supported-commands list. - -Unknown deviceIds (not in the local cache) exit 2 by default so `--dry-run` is a reliable pre-flight gate. Unknown command names and commands on read-only sensors are also rejected during dry-run when the device type has a catalog entry. Run `switchbot devices list` first, or pass `--allow-unknown-device` for scripted pass-through. +Parameters for `setAll`, `setPosition`, `setMode`, `setBrightness`, and `setColor` are validated client-side (exit 2 on bad input). `setColor` accepts `R:G:B`, `#RRGGBB`, `#RGB`, and CSS names — all normalize to `R:G:B`. Pass `--skip-param-validation` to bypass. Unknown deviceIds exit 2 by default; pass `--allow-unknown-device` for scripted pass-through. -Negative numeric parameters (e.g. `setBrightness -1` for a probe) are passed through to the command validator instead of being swallowed by the flag parser as an unknown option. - -For the complete per-device command reference, see the [SwitchBot API docs](https://github.com/OpenWonderLabs/SwitchBotAPI#send-device-control-commands). +For per-device command and parameter details: `switchbot devices commands ` or the [SwitchBot API docs](https://github.com/OpenWonderLabs/SwitchBotAPI#send-device-control-commands). #### `devices expand` — named flags for packed parameters @@ -608,54 +417,34 @@ Run `switchbot devices expand --help` to see the available flags #### `devices explain` — one-shot device summary ```bash -# Metadata + supported commands + live status in one call -switchbot devices explain - -# Skip live status fetch (catalog-only output, no API call) -switchbot devices explain --no-live +switchbot devices explain # metadata + commands + live status +switchbot devices explain --no-live # catalog-only, no API call ``` -Returns a combined view: static catalog info (commands, parameters, status fields) merged with the current live status. For Hub devices, also lists connected child devices. Prefer this over separate `status` + `describe` calls. - #### `devices meta` — local device metadata ```bash switchbot devices meta set --alias "Office Light" -switchbot devices meta set --hide # hide from `devices list` +switchbot devices meta set --hide # hide from `devices list` switchbot devices meta get -switchbot devices meta list # show all saved metadata +switchbot devices meta list switchbot devices meta clear ``` -Stores local annotations (alias, hidden flag, notes) in `~/.switchbot/device-meta.json`. The alias is used as a display name; `--show-hidden` on `devices list` reveals hidden devices. +Stores local annotations in `~/.switchbot/device-meta.json`. `--show-hidden` on `devices list` reveals hidden devices. #### `devices batch` — bulk commands ```bash -# Send the same command to every device matching a filter +# Same command to every matching device switchbot devices batch turnOff --filter 'type=Bot' switchbot devices batch setBrightness 50 --filter 'type~Light,family=Living' - -# Explicit device IDs (comma-separated) switchbot devices batch turnOn --ids ID1,ID2,ID3 - -# Pipe device IDs from `devices list` switchbot devices list --format=id --filter 'type=Bot' | switchbot devices batch toggle - - -# Destructive commands require --yes -switchbot devices batch unlock --filter 'type=Smart Lock' --yes - -# Skip devices whose cached status is offline (default: off) -switchbot devices batch turnOn --ids ID1,ID2 --skip-offline - -# --idempotency-key is an alias for --idempotency-key-prefix; both append - -switchbot devices batch turnOn --ids ID1,ID2 --idempotency-key morning-lights +switchbot devices batch unlock --filter 'type=Smart Lock' --yes # destructive: requires --yes ``` -Sends the same command to many devices in one run. Filter grammar matches `devices list` (`=` substring, `~` substring, `=/regex/` regex — clauses AND-ed); supported keys here are `type`, `family`, `room`, `category`. Destructive commands (Smart Lock unlock, Garage Door Opener, etc.) require `--yes` to prevent accidents. - -`--skip-offline` reads from the local status cache only (no new API calls); -skipped devices appear under `summary.skipped` with `skippedReason:'offline'`. +Filter keys: `type`, `family`, `room`, `category`. Skipped-offline devices appear under `summary.skipped` when `--skip-offline` is passed. ### `scenes` — run manual scenes @@ -690,140 +479,39 @@ The CLI validates that `` is an absolute `http://` or `https://` URL before ### `events` — receive device events -Two subcommands cover the two ways SwitchBot can push state changes to you. - #### `events tail` — local webhook receiver ```bash -# Listen on port 3000 and print every incoming webhook POST -switchbot events tail - -# Filter to one device -switchbot events tail --filter deviceId=ABC123 - -# Stop after 5 matching events -switchbot events tail --filter 'type=WoMeter' --max 5 - -# Stop after 10 minutes regardless of event count -switchbot events tail --for 10m - -# Custom port / path +switchbot events tail # listen on port 3000 +switchbot events tail --filter deviceId=ABC123 # filter to one device +switchbot events tail --filter 'type=WoMeter' --max 5 --for 10m switchbot events tail --port 8080 --path /hook --json ``` -Run `switchbot webhook setup https://your.host/hook` first to tell SwitchBot where to send events, then expose the local port via ngrok/cloudflared and point the webhook URL at it. `events tail` only runs the local receiver — tunnelling is up to you. - -Output (one JSON line per matched event): - -```json -{ "t": "2024-01-01T12:00:00.000Z", "remote": "1.2.3.4:54321", "path": "/", "body": {...}, "matched": true } -``` - -Filter keys: `deviceId`, `type`. Operators: `=` (substring), `~` (substring), `=/regex/` (case-insensitive regex). Clauses comma-separated and AND-ed. +Run `switchbot webhook setup https://your.host/hook` first. `events tail` only runs the local receiver — tunnelling (ngrok/cloudflared) is up to you. #### `events mqtt-tail` — real-time MQTT stream ```bash -# Stream all shadow-update events (runs in foreground until Ctrl-C) -switchbot events mqtt-tail - -# Filter to a topic subtree -switchbot events mqtt-tail --topic 'switchbot/#' - -# Stop after 10 events -switchbot events mqtt-tail --max 10 --json - -# Stop after a fixed duration (emits __session_start under --json before connect) -switchbot events mqtt-tail --for 30s --json -``` - -Connects to the SwitchBot MQTT service automatically using the same credentials configured for the REST API (`SWITCHBOT_TOKEN` + `SWITCHBOT_SECRET`). No additional MQTT configuration is required — the client certificates are provisioned on first use. - -Output (one JSON line per message): - -```json -{ "t": "2024-01-01T12:00:00.000Z", "topic": "switchbot/abc123/status", "payload": {...} } -``` - -This command runs in the foreground and streams events until you press Ctrl-C. To run it persistently in the background, use a process manager: - -```bash -# pm2 -pm2 start "switchbot events mqtt-tail --json" --name switchbot-events - -# nohup -nohup switchbot events mqtt-tail --json >> ~/switchbot-events.log 2>&1 & +switchbot events mqtt-tail # stream all shadow events (Ctrl-C to stop) +switchbot events mqtt-tail --topic 'switchbot/#' # filter to topic subtree +switchbot events mqtt-tail --max 10 --for 30s --json ``` -Run `switchbot doctor` to verify MQTT credentials are configured correctly before connecting. +Credentials are provisioned automatically from the REST API config. Use `--sink` to route events to external services (`file`, `webhook`, `telegram`, `homeassistant`, `openclaw`) — see `switchbot events mqtt-tail --help` for details. ### `status-sync` — MQTT/OpenClaw bridge -Use this command family when you want the CLI itself to own the lifecycle of a -long-running bridge that forwards SwitchBot MQTT shadow events into an OpenClaw -gateway. Internally it reuses `events mqtt-tail --sink openclaw`, but adds a -stable command surface for foreground execution, background startup, status -inspection, and shutdown. +Forwards SwitchBot MQTT shadow events into an OpenClaw gateway with stable lifecycle management. ```bash -# Foreground mode for supervisors / containers -switchbot status-sync run --openclaw-model home-agent - -# Background mode for a normal shell session -switchbot status-sync start --openclaw-model home-agent - -# Inspect the current bridge +switchbot status-sync run --openclaw-model home-agent # foreground (for supervisors) +switchbot status-sync start --openclaw-model home-agent # background switchbot status-sync status --json - -# Stop the running bridge switchbot status-sync stop ``` -Required input: - -- `OPENCLAW_MODEL` or `--openclaw-model ` -- `OPENCLAW_TOKEN` or `--openclaw-token ` - -Optional input: - -- `OPENCLAW_URL` or `--openclaw-url ` -- `--topic ` to narrow the MQTT subscription -- `SWITCHBOT_STATUS_SYNC_HOME` or `--state-dir ` for custom runtime state - -Background mode writes these files under the state directory: - -- `state.json` — current pid, start time, effective command -- `stdout.log` — child stdout -- `stderr.log` — child stderr - -Foreground vs background: - -- `status-sync run` keeps the bridge attached to the current terminal -- `status-sync start` detaches the bridge and returns immediately -- `status-sync status` reports whether the bridge is alive plus paths/logs -- `status-sync stop` terminates the managed bridge process tree - -#### `mqtt-tail` sinks — route events to external services - -By default `mqtt-tail` prints JSONL to stdout. Use `--sink` (repeatable) to route events to one or more destinations instead: - -| Sink | Required flags | -| --- | --- | -| `stdout` | (default when no `--sink` given) | -| `file` | `--sink-file ` — append JSONL | -| `webhook` | `--webhook-url ` — HTTP POST each event | -| `telegram` | `--telegram-token` (or `$TELEGRAM_TOKEN`), `--telegram-chat ` | -| `homeassistant` | `--ha-url ` + `--ha-webhook-id` (no auth) or `--ha-token` (REST event API) | - -```bash -# Generic webhook (n8n, Make, etc.) -switchbot events mqtt-tail --sink webhook --webhook-url https://n8n.local/hook/abc - -# Forward to Home Assistant via webhook trigger -switchbot events mqtt-tail --sink homeassistant --ha-url http://homeassistant.local:8123 --ha-webhook-id switchbot -``` - -Device state is also persisted to `~/.switchbot/device-history/.json` (latest + 100-entry ring buffer) regardless of sink configuration. This enables the `get_device_history` MCP tool to answer state queries without an API call. +Required: `OPENCLAW_MODEL` (or `--openclaw-model`) and `OPENCLAW_TOKEN`. Optional: `OPENCLAW_URL`, `--topic`, `--state-dir`. Background mode writes `state.json`, `stdout.log`, and `stderr.log` under the state directory. ### `daemon` — background rules-engine process @@ -975,71 +663,44 @@ Port conflicts are reported immediately with a clear hint to choose a different ### `upgrade-check` — version check ```bash -switchbot upgrade-check # human output; exits 1 when update available -switchbot upgrade-check --json # structured JSON output -switchbot upgrade-check --timeout 5000 # custom registry timeout (ms) -``` - -Queries the npm registry for the latest published version and compares it against the running version. When the registry's `dist-tags.latest` is itself a prerelease (e.g. `4.0.0-rc.1`), the check is skipped and the current version is treated as up-to-date — accidental prerelease tags don't trigger spurious upgrade prompts. -`--json` output: - -```json -{ - "current": "3.3.2", - "latest": "4.0.0", - "upToDate": false, - "updateAvailable": true, - "breakingChange": true, - "installCommand": "npm install -g @switchbot/openapi-cli@4.0.0" -} +switchbot upgrade-check # exits 1 when update available +switchbot upgrade-check --json # {current, latest, upToDate, updateAvailable, breakingChange, installCommand} ``` -`breakingChange` is `true` when the latest major version is higher than the current — useful for agents or CI that need to distinguish breaking upgrades from patch releases. - ### `quota` — API request counter ```bash -switchbot quota status # today's usage + last 7 days -switchbot quota reset # delete the counter file +switchbot quota status # today's usage + last 7 days (10,000/day limit) +switchbot quota reset ``` -Tracks daily API calls against the 10,000/day account limit. The counter is stored in `~/.switchbot/quota.json` and incremented on every mutating request. Pass `--no-quota` to skip tracking for a single run. - ### `history` — audit log ```bash -switchbot history show # recent entries (newest first) -switchbot history show --limit 20 # last 20 entries -switchbot history replay 7 # re-run entry #7 +switchbot history show --limit 20 +switchbot history replay 7 # re-run entry #7 switchbot --json history show --limit 50 | jq '.entries[] | select(.result=="error")' ``` -Reads the JSONL audit log (`~/.switchbot/audit.log` by default; override with `--audit-log --audit-log-path `). Each entry records the timestamp, command, device ID, result, and dry-run flag. `replay` re-runs the original command with the original arguments. - ### `catalog` — device type catalog ```bash -switchbot catalog show # all 42 built-in types -switchbot catalog list # alias for `show` +switchbot catalog show # all built-in types switchbot catalog show Bot # one type -switchbot catalog search Hub # fuzzy match across type / aliases / commands -switchbot catalog diff # what a local overlay changes vs built-in -switchbot catalog path # location of the local overlay file -switchbot catalog refresh # reload local overlay (clears in-process cache) +switchbot catalog search Hub # fuzzy match +switchbot catalog diff # local overlay vs built-in ``` -The built-in catalog ships with the package. Create `~/.switchbot/catalog-overlay.json` to add, extend, or override type definitions without modifying the package. +Create `~/.switchbot/catalog-overlay.json` to extend or override type definitions without modifying the package. ### `schema` — export catalog as JSON ```bash -switchbot schema export # all types as structured JSON -switchbot schema export --type 'Strip Light' # one type -switchbot schema export --role sensor # filter by role +switchbot schema export # all types +switchbot schema export --type 'Strip Light' +switchbot schema export --role sensor ``` -Exports the effective catalog in a machine-readable format. Pipe the output into an agent's system prompt or tool schema to give it a complete picture of controllable devices. - ### `capabilities` — CLI manifest ```bash @@ -1047,127 +708,58 @@ switchbot capabilities --json switchbot capabilities --used --json # only types seen in the local cache ``` -Prints a versioned JSON manifest describing available surfaces (CLI, MCP, MQTT, plan runner), commands, and environment variables. Every subcommand leaf now carries a `{mutating, consumesQuota, idempotencySupported, agentSafetyTier, verifiability, typicalLatencyMs}` block, and the top-level payload publishes a flat `commandMeta` path-keyed lookup so agents don't have to walk the tree. `--used` filters the per-type summary to devices actually present in the local cache (same semantics as `schema export --used`). +Prints a versioned manifest of surfaces, commands, and environment variables. Each command leaf includes `{mutating, consumesQuota, agentSafetyTier, typicalLatencyMs}`. ### `cache` — inspect and clear local cache ```bash -# Show cache status (paths, age, entry counts) -switchbot cache show - -# Clear everything -switchbot cache clear - -# Clear only the device-list cache or only the status cache -switchbot cache clear --key list -switchbot cache clear --key status +switchbot cache show # paths, age, entry counts +switchbot cache clear # clear everything +switchbot cache clear --key list # list cache only +switchbot cache clear --key status # status cache only ``` ### `policy` — validate, scaffold, and migrate policy.yaml -Companion to the separate SwitchBot skill repository for third-party agent hosts. The skill reads behaviour (aliases, confirmations, quiet hours, audit path) from `policy.yaml`. This command group checks that file before the skill ever sees it, turning what used to be silent failures into line-accurate errors. - ```bash -# Write a starter policy at the default location -switchbot policy new # writes to the resolved default policy path -switchbot policy new ./custom/policy.yaml --force - -# Validate (compiler-style errors with line:col + caret + hints) -switchbot policy validate -switchbot policy validate ./custom/policy.yaml +switchbot policy new # write a starter policy +switchbot policy validate # compiler-style errors (line:col + caret) switchbot policy validate --json | jq '.data.errors' -switchbot policy validate --no-snippet # plain error list, no source preview - -# Report the schema version the file declares -switchbot policy migrate - -# Snapshot and restore the active policy -switchbot policy backup # write timestamped backup alongside policy file -switchbot policy backup --out ./backups/ # custom destination directory -switchbot policy restore # overwrite active policy from backup (auto-backups first) +switchbot policy migrate # upgrade v0.1 → v0.2 in-place +switchbot policy backup # timestamped backup +switchbot policy restore ``` -Path resolution order: positional `[path]` > `SWITCHBOT_POLICY_PATH` env var > default policy path. - -**Exit codes:** `0` valid / `1` invalid / `2` file-not-found / `3` yaml-parse / `4` internal / `5` file already exists (on `new`, overridden with `--force`) / `6` unsupported schema version (on `migrate`). - -Example — editing an alias without quoting the deviceId: - -```console -$ switchbot policy validate -:14:11 - 14 | bedroom light: 01-abc-12345 - ^^^^^^^^^^^^^ -error: /aliases/bedroom light does not match pattern ^[A-Z0-9]{2,}-[A-Z0-9-]+$ -hint: paste the deviceId from `switchbot devices list --format=tsv`, e.g. 01-202407090924-26354212 - -✗ 1 error in (schema v0.1) -``` - -The default policy schema shipped with the CLI (`src/policy/schema/v0.2.json`) is mirrored as `examples/policy.schema.json` in the companion skill repo; a CI job on every push diffs the two to prevent drift. +Path resolution: positional `[path]` > `SWITCHBOT_POLICY_PATH` > default. Exit codes: `0` valid / `1` invalid / `2` missing / `3` yaml-parse / `4` internal / `5` exists (use `--force`) / `6` unsupported version. ## Output modes - **Default** — ANSI-colored tables for `list`/`status`, key-value tables for details. -- **`--json`** — raw API payload passthrough. Output is the exact JSON the SwitchBot API returned, ideal for `jq` and scripting. Errors are also JSON on **stdout**: `{ "schemaVersion": "1.2", "error": { "code", "kind", "message", "hint?" } }`. -- **`--format=json`** — projected row view. Same JSON structure but built from the CLI's column model (`--fields` applies). Use this when you only want specific fields. -- **`--format=tsv|yaml|jsonl|id`** — tabular text formats; `--fields` filters columns. +- **`--json`** — raw API payload passthrough. Errors are also JSON on **stdout**: `{ "schemaVersion": "1.2", "error": { "code", "kind", "message", "hint?" } }`. +- **`--format=json`** — projected row view; `--fields` applies. +- **`--format=tsv|yaml|jsonl|id`** — tabular text formats. ```bash -# Raw API payload (--json) switchbot devices list --json | jq '.deviceList[] | {id: .deviceId, name: .deviceName}' - -# Projected rows with field filter (--format) switchbot devices list --format tsv --fields deviceId,deviceName,type,cloud switchbot devices list --format id # one deviceId per line -switchbot devices status --format yaml ``` ## Cache -The CLI maintains two local disk caches under `~/.switchbot/`: - -- `devices.json`: Device metadata (id, name, type, category, hub, room…). - Default TTL: 1 hour. -- `status.json`: Per-device status bodies. - Default TTL: off (0). +Two local disk caches under `~/.switchbot/`: -The device-list cache powers offline validation (command name checks, destructive-command guard) and the MCP server's `send_command` tool. It is refreshed automatically on every `devices list` call. - -### Cache control flags +| Cache | Default TTL | Purpose | +|---|---|---| +| `devices.json` | 1 hour | device metadata; powers offline validation | +| `status.json` | off | per-device status; GC'd after 24h | ```bash -# Turn off all cache reads for one invocation -switchbot devices list --no-cache - -# Set both list and status TTL to 5 minutes -switchbot devices status --cache 5m - -# Set TTLs independently +switchbot devices list --no-cache # bypass for one invocation +switchbot devices status --cache 5m # set list + status TTL switchbot devices status --cache-list 2h --cache-status 30s - -# Disable only the list cache (keep status cache at its current TTL) -switchbot devices list --cache-list 0 ``` -### Cache management commands - -```bash -# Show paths, age, and entry counts -switchbot cache show - -# Clear all cached data -switchbot cache clear - -# Scope the clear to one store -switchbot cache clear --key list -switchbot cache clear --key status -``` - -### Status-cache GC - -`status.json` entries are automatically evicted after 24 hours (or 10× the configured status TTL, whichever is longer), so the file cannot grow without bound even when the status cache is left enabled long-term. - ## Exit codes & error codes - `0`: Success (including `--dry-run` intercept when validation passes). @@ -1219,107 +811,17 @@ npm run test:watch # Watch mode npm run test:coverage # Coverage report (v8, HTML + text) ``` -### Project layout - -```text -src/ -├── index.ts # Commander entry; mounts all subcommands; global flags -├── auth.ts # HMAC-SHA256 signature (token + t + nonce → sign) -├── config.ts # Credential load/save; env > keychain > file priority -├── api/client.ts # axios instance + request/response interceptors; -│ # --verbose / --dry-run / --timeout wiring -├── credentials/ -│ ├── keychain.ts # Credential store interface + OS backend selection -│ └── backends/ # macos.ts / linux.ts / windows.ts / file.ts -├── devices/ -│ ├── catalog.ts # Static device catalog (commands, params, status fields) -│ └── cache.ts # Disk + in-memory cache for device list and status -├── install/ -│ ├── steps.ts # Generic step runner with rollback support -│ ├── preflight.ts # Pre-flight checks (Node, npm, network, agent) -│ └── default-steps.ts # Concrete steps: credentials, keychain, policy, skill, doctor -├── policy/ -│ ├── validate.ts # Schema version dispatch + JSON Schema validation -│ ├── migrate.ts # v0.1 → v0.2 migration -│ ├── load.ts # YAML file loading + error handling -│ ├── add-rule.ts # Rule injection into automation.rules[] -│ ├── diff.ts # Structural + line diff -│ └── schema/v0.2.json # Authoritative v0.2 JSON Schema -├── rules/ -│ ├── engine.ts # Main orchestrator (MQTT + cron + webhook) -│ ├── matcher.ts # Trigger + condition matchers -│ ├── action.ts # Command renderer + executor -│ ├── throttle.ts # Per-rule throttle gate -│ ├── cron-scheduler.ts # 5-field cron + days filter -│ ├── webhook-listener.ts # HTTP listener (bearer token, localhost-only) -│ ├── pid-file.ts # Hot-reload via SIGHUP or sentinel file -│ ├── audit-query.ts # Audit log filtering + aggregation -│ ├── conflict-analyzer.ts # Static conflict detection (opposing actions, -│ │ # high-freq MQTT, destructive cmds, quiet-hours gaps) -│ ├── suggest.ts # Heuristic + LLM-backed rule YAML generation -│ ├── notify.ts # notify action executor (webhook / file / openclaw) -│ └── types.ts # Shared rule/trigger/condition/action types (CommandAction | NotifyAction) -├── llm/ -│ ├── index.ts # createLLMProvider factory + LLM_AUTO_THRESHOLD -│ ├── complexity.ts # Intent complexity scorer (0–10) for auto-routing -│ ├── rule-prompt.ts # System prompt builder (embeds v0.2 schema snippet) -│ └── providers/ -│ ├── openai.ts # OpenAI-compatible provider (uses Node.js https) -│ └── anthropic.ts # Anthropic provider -├── status-sync/ -│ └── manager.ts # Spawn/stop logic, state file, OpenClaw bridge -├── lib/ -│ └── devices.ts # Shared logic: listDevices, describeDevice, isDestructiveCommand -├── commands/ -│ ├── auth.ts # `auth keychain` subcommand group -│ ├── config.ts -│ ├── devices.ts -│ ├── expand.ts # `devices expand` — semantic flag builder -│ ├── explain.ts # `devices explain` — one-shot device summary -│ ├── device-meta.ts # `devices meta` — local aliases / hide flags -│ ├── install.ts # `switchbot install` / `uninstall` -│ ├── policy.ts # `policy validate/new/migrate/diff/add-rule/backup/restore` -│ ├── rules.ts # `rules suggest/lint/list/explain/run/reload/tail/replay/ -│ │ # conflicts/doctor/summary/last-fired/webhook-*/ -│ │ # trace-explain/simulate` -│ ├── scenes.ts -│ ├── health.ts # `health check/serve` — report + HTTP endpoints -│ ├── upgrade-check.ts # `upgrade-check` — npm registry version check -│ ├── status-sync.ts # `status-sync run/start/stop/status` -│ ├── webhook.ts -│ ├── watch.ts # `devices watch ` -│ ├── events.ts # `events tail` / `events mqtt-tail` -│ ├── mcp.ts # `mcp serve` (MCP stdio/HTTP server) -│ ├── plan.ts # `plan run/validate/suggest` -│ ├── cache.ts # `cache show/clear` -│ ├── history.ts # `history show/replay` -│ ├── quota.ts # `quota status/reset` -│ ├── catalog.ts # `catalog show/diff/path` -│ ├── schema.ts # `schema export` -│ ├── doctor.ts # `doctor` -│ ├── capabilities.ts # `capabilities` -│ └── completion.ts # `completion bash|zsh|fish|powershell` -└── utils/ - ├── flags.ts # Global flag readers (isVerbose / isDryRun / getCacheMode / …) - ├── output.ts # printTable / printKeyValue / printJson / handleError - ├── format.ts # renderRows / filterFields / output-format dispatch - ├── audit.ts # JSONL audit log writer - └── quota.ts # Local daily-quota counter -tests/ # Vitest suite (2216 tests, mocked axios, no network) -``` +Source layout: `src/commands/` (one file per command group), `src/devices/` (catalog + cache), `src/rules/` (engine, matcher, throttle, audit), `src/policy/` (validate, migrate, schema), `src/llm/` (providers), `src/utils/` (output, format, flags). Tests are in `tests/` and mirror the `src/` structure. ### Release flow -Releases are cut on tag push and published to npm by GitHub Actions: - ```bash -npm version patch # bump version + create git tag +npm version patch # bump + create git tag git push --follow-tags +# then: GitHub → Releases → Draft → Publish ``` -Then on GitHub → **Releases → Draft a new release → select tag → Publish**. The `publish.yml` workflow runs tests, verifies the tag matches `package.json`, and publishes `@switchbot/openapi-cli` to npm with [provenance](https://docs.npmjs.com/generating-provenance-statements). - -See [`docs/release-pipeline.md`](./docs/release-pipeline.md) for the full pre-publish and post-publish verification flow (local hooks → CI → `publish.yml` → `npm-published-smoke.yml`). +See [`docs/release-pipeline.md`](./docs/release-pipeline.md) for the full CI / publish verification flow. ## License From b7d788fbf5d46595301e0cd165553eb92149e5c0 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 9 May 2026 20:49:23 +0800 Subject: [PATCH 08/12] fix: two P2 correctness regressions in expand + resolveTargetCommand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix 1 — catalog wins; validator is fallback only (expand.ts + param-validator.ts): - isColorDevice(): remove Ceiling Light / Ceiling Light Pro — catalog marks these "(no RGB)", no setColor command. isBrightnessDevice() correctly keeps them (setBrightness and setColorTemperature are listed). - expand.ts: replace OR check with catalog-first branch. If the device is in the catalog, the catalog is authoritative and the heuristic is never consulted. The heuristic only runs when findCatalogEntry returns null (uncatalogued types like Floor Lamp, Light Strip, Dimmer, Fill Light). Fix 2 — resolveTargetCommand skips option-value tokens (help-json.ts): - When walking argv to find the deepest matching subcommand, value- consuming flags (--config, --profile, --timeout …) were not handled. The value token didn't start with '-', so the walk aborted early and the error message pointed at the root command instead of the actual target. - Fixed by reading Commander's Option.required / .optional metadata to detect value-consuming flags and skipping their next token. Tests: add Ceiling Light regression cases to expand.test.ts; add value-consuming-flag cases to help-json.test.ts (existing describe block). Co-Authored-By: Claude Sonnet 4.6 --- src/commands/expand.ts | 22 ++++++++++++++------ src/devices/param-validator.ts | 2 -- src/utils/help-json.ts | 14 +++++++++++-- tests/commands/expand.test.ts | 21 +++++++++++++++++++ tests/utils/help-json.test.ts | 38 ++++++++++++++++++++++++++++++++++ 5 files changed, 87 insertions(+), 10 deletions(-) diff --git a/src/commands/expand.ts b/src/commands/expand.ts index 7ff806b..1ff4506 100644 --- a/src/commands/expand.ts +++ b/src/commands/expand.ts @@ -148,12 +148,22 @@ Examples: } const catalogResult = findCatalogEntry(cached.type); const catalogEntry = Array.isArray(catalogResult) ? catalogResult[0] : catalogResult; - const supportedByCatalog = catalogEntry?.commands.some((c: { command: string }) => c.command === command) ?? false; - if (!supportedByCatalog && !isLightingCommandSupported(cached.type, command)) { - throw new UsageError( - `Device type "${cached.type}" does not support ${command}. ` + - `Supported on: Color Bulb, Strip Light, Ceiling Light, Floor Lamp, and similar lighting devices.` - ); + if (catalogEntry !== null) { + // Device is in catalog — catalog is authoritative, no heuristic fallback + if (!catalogEntry.commands.some((c: { command: string }) => c.command === command)) { + throw new UsageError( + `Device type "${cached.type}" does not support ${command}. ` + + `Supported on: Color Bulb, Strip Light, Ceiling Light, Floor Lamp, and similar lighting devices.` + ); + } + } else { + // Device not in catalog — fall back to param-validator whitelist + if (!isLightingCommandSupported(cached.type, command)) { + throw new UsageError( + `Device type "${cached.type}" does not support ${command}. ` + + `Supported on: Color Bulb, Strip Light, Ceiling Light, Floor Lamp, and similar lighting devices.` + ); + } } if (command === 'setBrightness') { parameter = buildBrightnessSet(options); diff --git a/src/devices/param-validator.ts b/src/devices/param-validator.ts index 9399c3e..13637b7 100644 --- a/src/devices/param-validator.ts +++ b/src/devices/param-validator.ts @@ -172,8 +172,6 @@ function isColorDevice(deviceType: string): boolean { deviceType === 'Color Bulb' || deviceType === 'Strip Light' || deviceType === 'Strip Light 3' || - deviceType === 'Ceiling Light' || - deviceType === 'Ceiling Light Pro' || deviceType === 'Floor Lamp' || deviceType === 'Light Strip' || deviceType === 'Fill Light' diff --git a/src/utils/help-json.ts b/src/utils/help-json.ts index 3e4549b..695352d 100644 --- a/src/utils/help-json.ts +++ b/src/utils/help-json.ts @@ -81,11 +81,21 @@ export function commandToJson(cmd: Command, opts: CommandToJsonOptions = {}): Co return out; } -/** Walk argv tokens (skipping flags) to find the deepest matching subcommand. */ +/** Walk argv tokens to find the deepest matching subcommand, skipping flags and their values. */ export function resolveTargetCommand(root: Command, argv: string[]): Command { let cmd = root; + let consumeNext = false; for (const token of argv) { - if (token.startsWith('-')) continue; + if (consumeNext) { consumeNext = false; continue; } + if (token.startsWith('-')) { + if (!token.includes('=')) { + const opt = (cmd.options as import('commander').Option[]).find( + (o) => o.short === token || o.long === token + ); + if (opt && (opt.required || opt.optional)) consumeNext = true; + } + continue; + } const sub = cmd.commands.find( (c) => c.name() === token || (c.aliases() as string[]).includes(token) ); diff --git a/tests/commands/expand.test.ts b/tests/commands/expand.test.ts index 32bd06f..516b05e 100644 --- a/tests/commands/expand.test.ts +++ b/tests/commands/expand.test.ts @@ -34,6 +34,7 @@ const BULB_ID = 'BULB-001'; const BOT_ID = 'BOT-001'; const LAMP_ID = 'LAMP-001'; const STRIP_ID = 'STRIP-001'; +const CEILING_ID = 'CEILING-001'; const sampleBody = { deviceList: [ @@ -44,6 +45,7 @@ const sampleBody = { { deviceId: BOT_ID, deviceName: 'Door Bot', deviceType: 'Bot', hubDeviceId: 'H1', enableCloudService: true }, { deviceId: LAMP_ID, deviceName: 'Desk Lamp', deviceType: 'Floor Lamp', hubDeviceId: 'H1', enableCloudService: true }, { deviceId: STRIP_ID, deviceName: 'TV Strip', deviceType: 'Light Strip', hubDeviceId: 'H1', enableCloudService: true }, + { deviceId: CEILING_ID, deviceName: 'Living Ceiling', deviceType: 'Ceiling Light', hubDeviceId: 'H1', enableCloudService: true }, ], infraredRemoteList: [ { deviceId: AC_ID, deviceName: 'Living AC', remoteType: 'Air Conditioner', hubDeviceId: 'H1', controlType: 'Air Conditioner' }, @@ -342,4 +344,23 @@ describe('devices expand', () => { { command: 'setColor', parameter: '0:255:0', commandType: 'command' }, ); }); + + it('setColor on Ceiling Light (in catalog, no RGB) → UsageError', async () => { + const res = await runCli(registerDevicesCommand, [ + 'devices', 'expand', CEILING_ID, 'setColor', '--color', '255:0:0', + ]); + expect(res.exitCode).toBe(2); + expect(res.stderr.join('\n')).toMatch(/Ceiling Light.*does not support setColor/); + }); + + it('setBrightness on Ceiling Light (in catalog, supports it) → succeeds', async () => { + const res = await runCli(registerDevicesCommand, [ + 'devices', 'expand', CEILING_ID, 'setBrightness', '--brightness', '60', + ]); + expect(res.exitCode).toBe(null); + expect(apiMock.__instance.post).toHaveBeenCalledWith( + `/v1.1/devices/${CEILING_ID}/commands`, + { command: 'setBrightness', parameter: '60', commandType: 'command' }, + ); + }); }); diff --git a/tests/utils/help-json.test.ts b/tests/utils/help-json.test.ts index 9f83b4e..8537f10 100644 --- a/tests/utils/help-json.test.ts +++ b/tests/utils/help-json.test.ts @@ -109,4 +109,42 @@ describe('resolveTargetCommand', () => { const result = resolveTargetCommand(root, ['d', '--help']); expect(result.name()).toBe('devices'); }); + + it('skips a value-consuming flag and its value (--config )', () => { + const root = new Command('switchbot'); + root.option('--json', 'json mode'); + root.option('--config ', 'config path'); + const cache = root.command('cache'); + cache.command('show'); + + expect(resolveTargetCommand(root, ['--json', '--config', '/tmp/cfg.json', 'cache']).name()).toBe('cache'); + }); + + it('skips --profile value before subcommand', () => { + const root = new Command('switchbot'); + root.option('--json', 'json mode'); + root.option('--profile ', 'profile'); + root.command('history'); + + expect(resolveTargetCommand(root, ['--json', '--profile', 'default', 'history']).name()).toBe('history'); + }); + + it('handles inline flag value (--config=path) without consuming next token', () => { + const root = new Command('switchbot'); + root.option('--config ', 'config path'); + root.command('cache'); + + expect(resolveTargetCommand(root, ['--config=/tmp/cfg.json', 'cache']).name()).toBe('cache'); + }); + + it('skips multiple sequential value-consuming flags before subcommand', () => { + const root = new Command('switchbot'); + root.option('--config ', 'config path'); + root.option('--profile ', 'profile'); + root.command('cache'); + + expect( + resolveTargetCommand(root, ['--config', '/tmp/c.json', '--profile', 'home', 'cache']).name() + ).toBe('cache'); + }); }); From fe93937e0fdf2420fe61b8e397250cb2456a0f45 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Mon, 11 May 2026 10:21:07 +0800 Subject: [PATCH 09/12] fix: eight correctness issues from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - validateParameter: gate setColorTemperature with isBrightnessDevice (not isColorDevice) so Ceiling Light gets Kelvin-range validation - resolveTargetCommand: also search root options after descending into a subcommand, so interleaved global flags are consumed correctly - expand setAll: require Air Conditioner device type before dispatching - expand setPosition: whitelist Curtain/Roller Shade/Blind Tilt instead of treating every non-Blind device as a Curtain - index.ts: treat explicit `help` subcommand as successful help (exit 0) - emitStreamHeader: accept optional schemaVersion override; event streams now pass EVENTS_SCHEMA_VERSION so header matches record version - rules.ts: keep automation.enabled=false as code 1 (runtime), not code 2 (usage) — this is a post-load config state, not bad CLI syntax Co-Authored-By: Claude Opus 4.6 --- src/commands/events.ts | 4 ++-- src/commands/expand.ts | 16 ++++++++++++++++ src/devices/param-validator.ts | 2 +- src/index.ts | 2 +- src/utils/help-json.ts | 7 ++++--- src/utils/output.ts | 3 ++- tests/commands/events.test.ts | 2 +- tests/commands/expand.test.ts | 17 +++++++++++++++++ tests/helpers/contracts.ts | 3 ++- tests/utils/help-json.test.ts | 11 +++++++++++ 10 files changed, 57 insertions(+), 10 deletions(-) diff --git a/src/commands/events.ts b/src/commands/events.ts index 16b5b28..12a3b4d 100644 --- a/src/commands/events.ts +++ b/src/commands/events.ts @@ -262,7 +262,7 @@ Examples: : null; // P7: streaming JSON contract — first line under --json is the // stream header (webhook events arrive via push cadence). - if (isJsonMode()) emitStreamHeader({ eventKind: 'event', cadence: 'push' }); + if (isJsonMode()) emitStreamHeader({ eventKind: 'event', cadence: 'push', schemaVersion: EVENTS_SCHEMA_VERSION }); await new Promise((resolve, reject) => { let server: http.Server | null = null; try { @@ -459,7 +459,7 @@ Examples: // P7: streaming JSON contract — first line under --json is the stream // header (mqtt events arrive via push cadence). Must emit BEFORE // __session_start so header is always the very first line. - if (isJsonMode()) emitStreamHeader({ eventKind: 'event', cadence: 'push' }); + if (isJsonMode()) emitStreamHeader({ eventKind: 'event', cadence: 'push', schemaVersion: EVENTS_SCHEMA_VERSION }); // Emit a __session_start envelope immediately (before any credential // fetch) so JSON consumers can distinguish "connecting" from "never // connected" even when mqtt-tail exits before the broker connects. diff --git a/src/commands/expand.ts b/src/commands/expand.ts index 1ff4506..e2a5e5f 100644 --- a/src/commands/expand.ts +++ b/src/commands/expand.ts @@ -127,6 +127,16 @@ Examples: let parameter: string; if (command === 'setAll') { + if (!cached) { + throw new UsageError( + `Device ${deviceId} is not in the local cache — run 'switchbot devices list' first so 'expand' can verify this is an Air Conditioner.` + ); + } + if (deviceType !== 'Air Conditioner') { + throw new UsageError( + `"setAll" is only supported on Air Conditioner devices, but "${cached.type}" was found.` + ); + } parameter = buildAcSetAll(options); } else if (command === 'setPosition') { if (!cached) { @@ -134,6 +144,12 @@ Examples: `Device ${deviceId} is not in the local cache — run 'switchbot devices list' first so 'expand' knows whether this is a Curtain or a Blind Tilt.` ); } + const positionTypes = ['Curtain', 'Curtain 3', 'Roller Shade', 'Blind Tilt']; + if (!positionTypes.some(t => deviceType.startsWith(t))) { + throw new UsageError( + `"setPosition" is only supported on Curtain, Roller Shade, and Blind Tilt devices, but "${cached.type}" was found.` + ); + } const isBlind = deviceType.startsWith('Blind Tilt'); parameter = isBlind ? buildBlindTiltSetPosition(options) diff --git a/src/devices/param-validator.ts b/src/devices/param-validator.ts index 13637b7..ca1ca91 100644 --- a/src/devices/param-validator.ts +++ b/src/devices/param-validator.ts @@ -146,7 +146,7 @@ export function validateParameter( if (command === 'setColor' && isColorDevice(deviceType)) { return validateSetColor(raw); } - if (command === 'setColorTemperature' && isColorDevice(deviceType)) { + if (command === 'setColorTemperature' && isBrightnessDevice(deviceType)) { return validateSetColorTemperature(raw); } diff --git a/src/index.ts b/src/index.ts index f42801c..d10303c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -208,7 +208,7 @@ try { // Mirror the root mapping so all usage errors surface as exit 2. if (err instanceof CommanderError) { if (err.code === 'commander.helpDisplayed') { - const helpRequested = process.argv.includes('--help') || process.argv.includes('-h'); + const helpRequested = process.argv.includes('--help') || process.argv.includes('-h') || process.argv.includes('help'); if (helpRequested) { if (isJsonMode()) { const target = resolveTargetCommand(program, process.argv.slice(2)); diff --git a/src/utils/help-json.ts b/src/utils/help-json.ts index 695352d..1c2d3d7 100644 --- a/src/utils/help-json.ts +++ b/src/utils/help-json.ts @@ -84,14 +84,15 @@ export function commandToJson(cmd: Command, opts: CommandToJsonOptions = {}): Co /** Walk argv tokens to find the deepest matching subcommand, skipping flags and their values. */ export function resolveTargetCommand(root: Command, argv: string[]): Command { let cmd = root; + const rootOptions = root.options as import('commander').Option[]; let consumeNext = false; for (const token of argv) { if (consumeNext) { consumeNext = false; continue; } if (token.startsWith('-')) { if (!token.includes('=')) { - const opt = (cmd.options as import('commander').Option[]).find( - (o) => o.short === token || o.long === token - ); + const localOpts = cmd.options as import('commander').Option[]; + const opt = localOpts.find((o) => o.short === token || o.long === token) + || rootOptions.find((o) => o.short === token || o.long === token); if (opt && (opt.required || opt.optional)) consumeNext = true; } continue; diff --git a/src/utils/output.ts b/src/utils/output.ts index 386c034..70ff33e 100644 --- a/src/utils/output.ts +++ b/src/utils/output.ts @@ -46,10 +46,11 @@ export function emitJsonError(errorPayload: Record): void { export function emitStreamHeader(opts: { eventKind: 'tick' | 'event'; cadence: 'poll' | 'push'; + schemaVersion?: string; }): void { console.log( JSON.stringify({ - schemaVersion: SCHEMA_VERSION, + schemaVersion: opts.schemaVersion ?? SCHEMA_VERSION, stream: true, eventKind: opts.eventKind, cadence: opts.cadence, diff --git a/tests/commands/events.test.ts b/tests/commands/events.test.ts index 42c849c..8f107de 100644 --- a/tests/commands/events.test.ts +++ b/tests/commands/events.test.ts @@ -544,7 +544,7 @@ describe('events mqtt-tail', () => { eventKind: string; cadence: string; }; - expectStreamHeaderShape(header as Record, 'event', 'push'); + expectStreamHeaderShape(header as Record, 'event', 'push', '1'); }); it('P7: mqtt-tail JSON event lines keep the unified envelope and payloadVersion fields', async () => { diff --git a/tests/commands/expand.test.ts b/tests/commands/expand.test.ts index 516b05e..35f65d2 100644 --- a/tests/commands/expand.test.ts +++ b/tests/commands/expand.test.ts @@ -363,4 +363,21 @@ describe('devices expand', () => { { command: 'setBrightness', parameter: '60', commandType: 'command' }, ); }); + + it('setAll on non-AC device (Bot) → UsageError', async () => { + const res = await runCli(registerDevicesCommand, [ + 'devices', 'expand', BOT_ID, 'setAll', + '--temp', '26', '--mode', 'cool', '--fan', 'low', '--power', 'on', + ]); + expect(res.exitCode).toBe(2); + expect(res.stderr.join('\n')).toMatch(/only supported on Air Conditioner/); + }); + + it('setPosition on non-Curtain/Blind device (Bot) → UsageError', async () => { + const res = await runCli(registerDevicesCommand, [ + 'devices', 'expand', BOT_ID, 'setPosition', '--position', '50', + ]); + expect(res.exitCode).toBe(2); + expect(res.stderr.join('\n')).toMatch(/only supported on Curtain/); + }); }); diff --git a/tests/helpers/contracts.ts b/tests/helpers/contracts.ts index def7ca0..e17f2f9 100644 --- a/tests/helpers/contracts.ts +++ b/tests/helpers/contracts.ts @@ -30,8 +30,9 @@ export function expectStreamHeaderShape( header: Record, eventKind: 'tick' | 'event', cadence: 'poll' | 'push', + expectedVersion = '1.2', ): void { - expect(header.schemaVersion).toBe('1.2'); + expect(header.schemaVersion).toBe(expectedVersion); expect(header.stream).toBe(true); expect(header.eventKind).toBe(eventKind); expect(header.cadence).toBe(cadence); diff --git a/tests/utils/help-json.test.ts b/tests/utils/help-json.test.ts index 8537f10..a3925b7 100644 --- a/tests/utils/help-json.test.ts +++ b/tests/utils/help-json.test.ts @@ -147,4 +147,15 @@ describe('resolveTargetCommand', () => { resolveTargetCommand(root, ['--config', '/tmp/c.json', '--profile', 'home', 'cache']).name() ).toBe('cache'); }); + + it('skips root-level value-consuming flags that appear after a subcommand token', () => { + const root = new Command('switchbot'); + root.option('--config ', 'config path'); + const devices = root.command('devices'); + devices.command('list'); + + expect( + resolveTargetCommand(root, ['devices', '--config', '/tmp/c.json', 'list']).name() + ).toBe('list'); + }); }); From d66df57f6f41ab5ee3fde1c1421603513d9d0d44 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Mon, 11 May 2026 10:54:35 +0800 Subject: [PATCH 10/12] fix: tailor lighting support hint per command in expand errors setColor error no longer lists Ceiling Light as supported, since it only supports setBrightness and setColorTemperature. Co-Authored-By: Claude Opus 4.6 --- src/commands/expand.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/commands/expand.ts b/src/commands/expand.ts index e2a5e5f..98dcbbe 100644 --- a/src/commands/expand.ts +++ b/src/commands/expand.ts @@ -164,20 +164,21 @@ Examples: } const catalogResult = findCatalogEntry(cached.type); const catalogEntry = Array.isArray(catalogResult) ? catalogResult[0] : catalogResult; + const supportedHint = command === 'setColor' + ? 'Color Bulb, Strip Light, Floor Lamp, and similar RGB lighting devices' + : 'Color Bulb, Strip Light, Ceiling Light, Floor Lamp, and similar lighting devices'; if (catalogEntry !== null) { // Device is in catalog — catalog is authoritative, no heuristic fallback if (!catalogEntry.commands.some((c: { command: string }) => c.command === command)) { throw new UsageError( - `Device type "${cached.type}" does not support ${command}. ` + - `Supported on: Color Bulb, Strip Light, Ceiling Light, Floor Lamp, and similar lighting devices.` + `Device type "${cached.type}" does not support ${command}. Supported on: ${supportedHint}.` ); } } else { // Device not in catalog — fall back to param-validator whitelist if (!isLightingCommandSupported(cached.type, command)) { throw new UsageError( - `Device type "${cached.type}" does not support ${command}. ` + - `Supported on: Color Bulb, Strip Light, Ceiling Light, Floor Lamp, and similar lighting devices.` + `Device type "${cached.type}" does not support ${command}. Supported on: ${supportedHint}.` ); } } From bc1794fdfb539cddfe1c57fcc3d37c1376dfec54 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Mon, 11 May 2026 11:11:31 +0800 Subject: [PATCH 11/12] fix: Roller Shade wire format and setColor help text accuracy Roller Shade setPosition takes a single 0-100 integer, not the three-value index,mode,position format used by Curtain. Also corrects help text that listed Ceiling Light under setColor (no RGB support). Co-Authored-By: Claude Opus 4.6 --- src/commands/expand.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/commands/expand.ts b/src/commands/expand.ts index 98dcbbe..0c50f5d 100644 --- a/src/commands/expand.ts +++ b/src/commands/expand.ts @@ -67,7 +67,7 @@ Supported expansions: Color Bulb / Strip Light / Ceiling Light — setBrightness --brightness 80 → "80" - Color Bulb / Strip Light / Ceiling Light — setColor + Color Bulb / Strip Light / Floor Lamp — setColor --color "255:0:0" → "255:0:0" --color "#FF0000" → "255:0:0" --color red → "255:0:0" @@ -151,9 +151,15 @@ Examples: ); } const isBlind = deviceType.startsWith('Blind Tilt'); - parameter = isBlind - ? buildBlindTiltSetPosition(options) - : buildCurtainSetPosition(options); + const isRollerShade = deviceType.startsWith('Roller Shade'); + if (isBlind) { + parameter = buildBlindTiltSetPosition(options); + } else if (isRollerShade) { + if (!options.position) throw new UsageError('--position is required (0-100)'); + parameter = options.position; + } else { + parameter = buildCurtainSetPosition(options); + } } else if (command === 'setMode' && deviceType.startsWith('Relay Switch')) { parameter = buildRelaySetMode(options); } else if (command === 'setBrightness' || command === 'setColor' || command === 'setColorTemperature') { From 8cfa67160e64097c7b2d2770d0a221f292c1b5ec Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Mon, 11 May 2026 11:30:04 +0800 Subject: [PATCH 12/12] docs: sync README and docs with codebase (9 files, 15+ fixes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README: test count 2216→2225, add 4 MCP tools, expand doctor checks to 19 - agent-guide: tool count 21→24, add rule_notifications/rules_explain/rules_simulate - audit-log: AUDIT_VERSION 1→2, expand kind field to all 10 types - json-contract: distinguish stream header schemaVersion per command type - schema-versioning: fix version reference v3.4.1→v3.4.0 - roadmap: mark v0.1 policy as removed in v3.0, drop stale queue item - policy-reference: fix exit codes, deviceId pattern, add conditions (all/any/not/llm), button.pressed event, notify actions, audit/llm_budget config, mark cron/webhook active - phase4-rules: document notify action type - mcp.ts help text: twenty-one→twenty-four, add 2 missing tools Co-Authored-By: Claude Opus 4.6 --- README.md | 9 ++--- docs/agent-guide.md | 5 ++- docs/audit-log.md | 6 ++-- docs/design/phase4-rules.md | 15 +++++++- docs/design/roadmap.md | 16 ++++----- docs/json-contract.md | 7 ++-- docs/policy-reference.md | 70 ++++++++++++++++++++++++++++--------- docs/schema-versioning.md | 2 +- src/commands/mcp.ts | 4 ++- 9 files changed, 95 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index e0da7c0..0568c91 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ Under the hood every surface shares the same catalog, cache, and HMAC client — - 🎨 **Dual output modes** — colorized tables by default; `--json` passthrough for `jq` and scripting - 🔐 **Secure credentials** — HMAC-SHA256 signed requests; config file written with `0600`; env-var override for CI - 🔍 **Dry-run mode** — preview every mutating request before it hits the API -- 🧪 **Fully tested** — 2216 Vitest tests, mocked axios, zero network in CI +- 🧪 **Fully tested** — 2225 Vitest tests, mocked axios, zero network in CI - ⚡ **Shell completion** — Bash / Zsh / Fish / PowerShell ## Requirements @@ -610,10 +610,11 @@ switchbot mcp serve ``` Exposes MCP tools (`list_devices`, `describe_device`, `get_device_status`, +`get_device_history`, `query_device_history`, `aggregate_device_history`, `send_command`, `list_scenes`, `run_scene`, `search_catalog`, `account_overview`, `plan_suggest`, `plan_run`, `audit_query`, `audit_stats`, `policy_diff`, `policy_validate`, `policy_new`, -`policy_migrate`, `rules_suggest`, `rule_notifications`, +`policy_migrate`, `policy_add_rule`, `rules_suggest`, `rule_notifications`, `rules_explain`, `rules_simulate`) plus a `switchbot://events` resource for real-time shadow updates. `rules_suggest` accepts an optional `llm` parameter (`openai | anthropic | auto`) @@ -632,7 +633,7 @@ switchbot doctor switchbot doctor --json ``` -Runs local checks (Node version, credentials, profiles, catalog, cache, quota, clock, MQTT, policy, MCP, notify-connectivity) and exits 1 if any check fails. `warn` results exit 0. The MQTT check reports `ok` when REST credentials are configured (auto-provisioned on first use). The `notify-connectivity` check probes webhook URLs declared in `type: notify` actions. Use this to diagnose connectivity or config issues before running automation. +Runs local checks (Node version, credentials, profiles, catalog, catalog-schema, cache, quota, clock, MQTT, policy, MCP, keychain, path, inventory, audit, daemon, health, notify-connectivity, release-notes) and exits 1 if any check fails. `warn` results exit 0. The MQTT check reports `ok` when REST credentials are configured (auto-provisioned on first use). The `notify-connectivity` check probes webhook URLs declared in `type: notify` actions. Use this to diagnose connectivity or config issues before running automation. `--json` output includes `maturityScore` (0–100) and `maturityLabel` (`production-ready` / `mostly-ready` / `needs-work` / `not-ready`) to give an at-a-glance readiness rating: @@ -806,7 +807,7 @@ npm install npm run dev -- # Run from TypeScript sources via tsx npm run build # Compile to dist/ -npm test # Run the Vitest suite (2216 tests) +npm test # Run the Vitest suite (2225 tests) npm run test:watch # Watch mode npm run test:coverage # Coverage report (v8, HTML + text) ``` diff --git a/docs/agent-guide.md b/docs/agent-guide.md index 03086d9..b857856 100644 --- a/docs/agent-guide.md +++ b/docs/agent-guide.md @@ -76,7 +76,7 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) } ``` -### Available tools (21) +### Available tools (24) | Tool | Purpose | Safety tier | | --- | --- | --- | @@ -100,6 +100,9 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) | `audit_query` | Filter audit log entries | read | | `audit_stats` | Aggregate audit stats by kind/result/device/rule | read | | `rules_suggest` | Draft automation rule YAML from intent | read | +| `rule_notifications` | Query rule notification delivery history | read | +| `rules_explain` | Show why a rule evaluation fired or was blocked | read | +| `rules_simulate` | Simulate a rule against historical events | read | | `policy_add_rule` | Inject rule YAML into `automation.rules[]` with diff | action | The MCP server refuses destructive commands (Smart Lock `unlock`, Garage Door `open`, etc.) unless the tool call includes `confirm: true`, and the default safety profile still blocks direct destructive execution in favor of the reviewed CLI flow (`plan save` → `plan review` → `plan approve` → `plan execute`). The allowed list is the `destructive: true` commands in the catalog — `switchbot schema export | jq '[.data.types[].commands[] | select(.destructive)]'` shows every one. diff --git a/docs/audit-log.md b/docs/audit-log.md index 3a9ebb2..f77a9e7 100644 --- a/docs/audit-log.md +++ b/docs/audit-log.md @@ -16,9 +16,9 @@ Every record is a JSON object with at least the following fields: | Field | Type | Notes | |----------------|----------------------|----------------------------------------------------------------------------------------| -| `auditVersion` | number | Schema version. Current: `1`. Missing on records written before audit versioning. | +| `auditVersion` | number | Schema version. Current: `2`. Missing on records written before audit versioning. | | `t` | string (ISO-8601) | Timestamp when the record was written. | -| `kind` | `"command"` | Record discriminator. Currently the only kind is `command`. | +| `kind` | string | Record discriminator. Values: `command`, `rule-fire`, `rule-fire-dry`, `rule-throttled`, `rule-webhook-rejected`, `rule-notify`, `rule-evaluate`, `llm-suggest`, `llm-condition`, `llm-budget-exceeded`. | | `deviceId` | string | Target device ID. | | `command` | string | SwitchBot command name (e.g. `turnOn`, `setColor`). | | `parameter` | string \| object | Command parameter as sent — `"default"` when unused. | @@ -30,7 +30,7 @@ Every record is a JSON object with at least the following fields: ### Example ```json -{"auditVersion":1,"t":"2026-04-20T01:23:45.123Z","kind":"command","deviceId":"ABC123","command":"turnOn","parameter":"default","commandType":"command","dryRun":false,"result":"ok"} +{"auditVersion":2,"t":"2026-04-20T01:23:45.123Z","kind":"command","deviceId":"ABC123","command":"turnOn","parameter":"default","commandType":"command","dryRun":false,"result":"ok"} ``` ## Crash safety diff --git a/docs/design/phase4-rules.md b/docs/design/phase4-rules.md index f1cb860..f42c41d 100644 --- a/docs/design/phase4-rules.md +++ b/docs/design/phase4-rules.md @@ -145,12 +145,25 @@ were folded into the composite nodes above). ## Actions -Each `then[]` entry renders to: +Each `then[]` entry is one of two types: + +**`type: command`** (default) — renders to: ``` switchbot substituted> --audit-log ``` +**`type: notify`** — delivers a payload to an external channel: + +```yaml +- type: notify + channel: webhook # webhook | file | openclaw + to: https://your.host/hook + template: '{"rule":"{{ rule.name }}","fired":"{{ rule.fired_at }}"}' +``` + +Channels: `webhook` (HTTP POST), `file` (append JSONL), `openclaw` (HTTP POST). Template supports `{{ rule.name }}`, `{{ event.* }}`, `{{ device.id }}` placeholders. Audit gains `rule-notify` kind for every notify dispatch. + Rules: 1. **Safety tier gates still apply.** If the rendered command is diff --git a/docs/design/roadmap.md b/docs/design/roadmap.md index 0b9d975..2c1f48d 100644 --- a/docs/design/roadmap.md +++ b/docs/design/roadmap.md @@ -32,7 +32,7 @@ points back to. | Capability | This repo (`switchbot-openapi-cli`) | Cross-repo (`+ companion skill repo`) | Notes | | --- | --- | --- | --- | | Phase 1 (manual orchestration) | Shipped | Shipped | Stable in v2.7.x | -| Phase 2 (policy tooling) | Shipped | Shipped | v0.1 + v0.2 policy schema support | +| Phase 2 (policy tooling) | Shipped | Shipped | v0.2 policy schema (v0.1 removed in v3.0) | | Phase 3A (keychain + install CLI) | Shipped | Shipped | `switchbot install` / `switchbot uninstall` | | Phase 3B (skill packaging + external registry) | External tracking only | In progress outside this repo | Owned by companion skill repo | | Phase 4 (rules engine, v0.2 model) | Shipped | Shipped | MQTT/cron/webhook + `days` + `all`/`any`/`not` | @@ -81,7 +81,7 @@ reads it, the MCP server reads it, and `doctor` reports on it. Surfaces: -- `policy new | validate | migrate | diff` (v0.1 and v0.2 schemas) +- `policy new | validate | migrate | diff` (v0.2 schema; v0.1 removed in v3.0) - Default `policy.yaml` discovery rules - Aliases (human-readable device names) - Quiet hours (local-time windows, midnight-crossing supported) @@ -199,23 +199,19 @@ the skill's `manifest.json` `roadmap` block, which points back here. ## Next execution queue (ordered) -1. **v0.1 policy deprecation window (post-default-flip hardening).** - Keep validating v0.1, but emit explicit migration guidance in UX/docs. - Exit when: policy docs and CLI examples consistently steer new users to - v0.2, and migration guidance is visible in `policy migrate` help. -2. **Daemon mode for repeated agent invocations.** +1. **Daemon mode for repeated agent invocations.** Add a local long-lived process with Unix socket / named pipe transport. Exit when: repeated MCP + plan runs no longer pay fresh-process startup, and `doctor` can verify daemon health. -3. **Standalone MCP package (`npx @switchbot/mcp-server`).** +2. **Standalone MCP package (`npx @switchbot/mcp-server`).** Split MCP serve entrypoint into a tiny publishable package while preserving tool contract parity with the main CLI. Exit when: `npx @switchbot/mcp-server` boots and passes the same MCP contract tests as `switchbot mcp serve`. -4. **`switchbot self-test` command.** +3. **`switchbot self-test` command.** Add scripted go/no-go checks for credentials + one representative device. Exit when: CI can run a deterministic self-test job with pass/fail JSON. -5. **Record/replay fixtures for deterministic integration tests.** +4. **Record/replay fixtures for deterministic integration tests.** Capture request/response transcripts and replay offline in CI. Exit when: at least one full scenario (list → status → command guard) is replayable without live API calls. diff --git a/docs/json-contract.md b/docs/json-contract.md index 936922f..5483e3d 100644 --- a/docs/json-contract.md +++ b/docs/json-contract.md @@ -82,12 +82,14 @@ envelope: ### Stream header (always the first line under `--json`) ```json -{ "schemaVersion": "1", "stream": true, "eventKind": "tick" | "event", "cadence": "poll" | "push" } +{ "schemaVersion": "1.2", "stream": true, "eventKind": "tick" | "event", "cadence": "poll" | "push" } ``` - **Must always be the first line** on stdout under `--json`. Consumers should read one line, parse, and key on `{ "stream": true }` to confirm they are reading from a streaming command. +- `schemaVersion` is `"1.2"` for `devices watch` and `"1"` for + `events tail` / `events mqtt-tail`. - `eventKind` picks the downstream parser. `tick` → `devices watch` shape with `{ t, tick, deviceId, changed, ... }`. `event` → unified event envelope (see below). @@ -186,7 +188,8 @@ switchbot devices status BOT1 --json | jq -e '.error' && exit 1 - The non-streaming envelope is versioned as `schemaVersion: "1.2"`. - The streaming header and event envelope are versioned as - `schemaVersion: "1"`. + `schemaVersion: "1"` for `events tail` / `events mqtt-tail`, and + `"1.2"` for `devices watch`. - The two axes are deliberately separate: adding a field inside `data` does **not** bump the envelope, but renaming / removing `data` would. - Breaking changes land on a major release. Additive fields land on a diff --git a/docs/policy-reference.md b/docs/policy-reference.md index e706ea8..7a1b9cc 100644 --- a/docs/policy-reference.md +++ b/docs/policy-reference.md @@ -39,12 +39,13 @@ supports two schemas: | Version | Status | What it adds | |---|---|---| -| `"0.1"` | Legacy — migrate with `policy migrate` | aliases, confirmations, quiet_hours, audit, cli | +| `"0.1"` | **Removed in v3.0** — migrate with `policy migrate` (CLI ≤2.15) | aliases, confirmations, quiet_hours, audit, cli | | `"0.2"` | **Current (required)** | typed `automation.rules[]` for the rules engine | -A file with anything other than `"0.1"` or `"0.2"` fails validation +A file with anything other than `"0.2"` fails validation with a named `unsupported-version` error. v0.2 is the default emitted -by `switchbot policy new`. Existing v0.1 files can be upgraded in-place: +by `switchbot policy new`. Existing v0.1 files must be migrated using +CLI ≤2.15 before upgrading to v3.0+: ```bash switchbot policy migrate # in-place upgrade, preserves comments @@ -95,10 +96,9 @@ Rules: - Keys are free-form strings. Quote them if they contain spaces or non-ASCII characters. -- Values must match `^[A-Z0-9]{2,}-[A-Z0-9-]+$` — SwitchBot deviceIds - are uppercase. A lowercase deviceId is the #1 cause of validation - failures. -- Get IDs from `switchbot devices list --format=tsv`. +- Values must match `^[A-Za-z0-9][A-Za-z0-9_-]{1,63}$` — also accepts + hex MAC format and hyphenated multi-segment IDs. + Get IDs from `switchbot devices list --format=tsv`. --- @@ -187,6 +187,11 @@ Run `switchbot policy migrate` first to unlock the rules engine. ```yaml automation: enabled: true # must be true for `rules run` to do anything + audit: + evaluate_trace: sampled # full | sampled | off (default sampled) + evaluate_retention_days: 7 # min 1 (default 7) + llm_budget: + max_calls_per_hour: 60 # global limit across all LLM conditions (default 60) rules: - name: hallway motion at night # unique per file; audit label enabled: true # default true; false silences the rule @@ -201,21 +206,32 @@ automation: device: hallway lamp # alias resolves to deviceId at fire time args: null # optional map of verb arguments on_error: continue # continue (default) | stop + - type: notify + channel: webhook # webhook | file | openclaw + to: https://your.host/hook + template: '{"rule":"{{ rule.name }}","fired":"{{ rule.fired_at }}"}' + on_failure: log # log | retry | ignore throttle: max_per: "10m" # minimum spacing: \d+[smh] + dedupe_window: null # event deduplication window + cooldown: null # shorthand for throttle.max_per + requires_stable_for: null # hysteresis guard duration + maxFiringsPerHour: null # per-hour rate limit + suppressIfAlreadyDesired: false # skip if device already in desired state dry_run: true # default true in v0.2; writes audit but skips the API call ``` **Trigger sources (v0.2).** -| `source` | Required fields | Status in PoC | +| `source` | Required fields | Status | |-----------|------------------------|----------------------------------| | `mqtt` | `event` (+ `device?`) | **active** — fires on shadow MQTT | -| `cron` | `schedule` (5-field) | parsed; `rules lint` flags `unsupported` | -| `webhook` | `path` | parsed; `rules lint` flags `unsupported` | +| `cron` | `schedule` (5-field) | **active** — local time, optional `days` weekday filter | +| `webhook` | `path` | **active** — bearer-token HTTP ingest | MQTT event names classified today: `motion.detected`, -`motion.cleared`, `contact.opened`, `contact.closed`. Unmatched +`motion.cleared`, `contact.opened`, `contact.closed`, +`button.pressed`. Unmatched payloads classify as `device.shadow` — you can match that catch-all too. @@ -224,7 +240,28 @@ too. | Keyword | Meaning | Status | |-----------------|---------------------------------------------------------------|--------| | `time_between` | `[HH:MM, HH:MM]` local-time window, `start > end` → overnight | active | -| `device_state` | `{ device, field, op, value }` read device status inline | parsed; reports as `condition-unsupported` until E3 | +| `device_state` | `{ device, field, op, value }` read device status inline | active | +| `all` | AND-join multiple sub-conditions | active | +| `any` | OR-join multiple sub-conditions | active | +| `not` | Negate a sub-condition | active | +| `llm` | AI judgement — prompt an LLM before firing (see below) | active | + +**LLM condition fields:** + +```yaml +conditions: + - llm: + prompt: "Is the temperature above normal comfort range?" + provider: auto # auto | openai | anthropic + timeout_ms: 5000 # 500–10000 (default 5000) + cache_ttl: 5m # none | \d+[smh] (default 5m) + recent_events: 5 # 0–20 (default 5) — recent events included in prompt + budget: + max_calls_per_hour: 10 # per-condition limit (default 10) + on_error: fail # fail | pass | skip (default fail) +``` + +Set `OPENAI_API_KEY` or `ANTHROPIC_API_KEY`. `rules lint` flags misconfigured LLM conditions. Global LLM budget can be set via `automation.llm_budget.max_calls_per_hour` (default 60). **Destructive verbs are refused upstream.** The v0.2 validator rejects `lock`, `unlock`, `deleteWebhook`, `deleteScene`, @@ -281,9 +318,10 @@ Exit codes: | Code | Meaning | |---|---| | 0 | File is valid and matches schema v0.2 | -| 1 | File is missing | -| 2 | YAML is malformed (parse error, with line/col) | -| 3 | Schema violation (line-accurate error with hint) | +| 1 | Schema violation (line-accurate error with hint) | +| 2 | File is missing | +| 3 | YAML is malformed (parse error, with line/col) | +| 4 | Internal error | Every non-zero exit prints a compiler-style block: @@ -322,7 +360,7 @@ For machine consumption, pass `--json`. The envelope is the standard | `missing version` | Top-level `version` is absent | Add `version: "0.2"` | | `unsupported version` | `version` is not `"0.1"` or `"0.2"` | Check spelling; run `switchbot policy migrate` to upgrade from v0.1 | | `wrong version` | `version: "0.1"` on a CLI that requires v0.2 | Run `switchbot policy migrate` | -| `lowercase deviceId` | `aliases` value isn't UPPERCASE | Uppercase the ID (it is in `devices list`) | +| `lowercase deviceId` | `aliases` value doesn't match the accepted patterns | Copy the exact ID from `devices list` | | `destructive in never_confirm` | `lock`/`unlock`/etc in `confirmations.never_confirm` | Remove it; intentional by design | | `quiet_hours.start without end` | Only one of the two times is set | Set both, or remove the block | | `invalid retention` | `audit.retention` isn't `never` / `Nd` / `Nw` / `Nm` | Use one of the documented formats | diff --git a/docs/schema-versioning.md b/docs/schema-versioning.md index 78d3a44..b0e0e1c 100644 --- a/docs/schema-versioning.md +++ b/docs/schema-versioning.md @@ -43,7 +43,7 @@ Prefer the top-level `schemaVersion`. The nested copy may be removed in a future ## Current Versions -- **v3.4.1**: schemaVersion "1.2" +- **v3.4.0**: schemaVersion "1.2" - `catalog show --json`: `data` is now always an array (was bare object for single-type queries) - `devices commands --json`: same change — `data` is always an array - `fetchedAt` field renamed from `_fetchedAt` in `devices status` JSON output diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index 6908eee..c1886d4 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -2243,7 +2243,7 @@ export function registerMcpCommand(program: Command): void { .command('mcp') .description('Run as a Model Context Protocol server so AI agents can call SwitchBot tools') .addHelpText('after', ` - The MCP server exposes twenty-one tools: + The MCP server exposes twenty-four tools: - list_devices fetch all physical + IR devices - get_device_status live status for a physical device - send_command control a device (destructive commands need confirm:true) @@ -2266,6 +2266,8 @@ export function registerMcpCommand(program: Command): void { - audit_stats aggregate audit counts by kind/result/device/rule - rule_notifications query rule notify action delivery history - rules_suggest draft an automation rule YAML from intent (heuristic, no LLM) + - rules_explain show why a rule evaluation fired or was blocked + - rules_simulate simulate a rule against historical events - policy_add_rule append a rule into automation.rules[] in policy.yaml Resource (read-only):