From 6e6c0153cc2f88e7c3b1a9edb3f8264d80c68a96 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sun, 26 Apr 2026 22:31:03 +0800 Subject: [PATCH] Fix capabilities coverage for mcp tools / mcp list-tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add missing COMMAND_META entries for `mcp tools` and `mcp list-tools` that were added to mcp.ts in 3.3.0 but never registered in the metadata map. 3.3.1 published with the gap, so invoking `switchbot capabilities` threw `capabilities metadata coverage error: missing:mcp list-tools, missing:mcp tools` on installed tarballs. Add `capabilities-program-coverage.test.ts` — registers every register*Command from src/index.ts and parses `capabilities --compact` end-to-end so validateCommandMetaCoverage walks the full tree. The existing capabilities-meta unit test only registered capabilities itself and missed the gap. --- CHANGELOG.md | 24 +++++ README.md | 2 +- package-lock.json | 4 +- package.json | 2 +- src/commands/capabilities.ts | 2 + tests/commands/capabilities-meta.test.ts | 2 +- .../capabilities-program-coverage.test.ts | 102 ++++++++++++++++++ 7 files changed, 133 insertions(+), 5 deletions(-) create mode 100644 tests/commands/capabilities-program-coverage.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 48bb8f8..5fc6064 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,30 @@ All notable changes to `@switchbot/openapi-cli` are documented in this file. The format is loosely based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.3.2] - 2026-04-26 + +### Fixed + +- **`switchbot capabilities` crashed on 3.3.1 with `capabilities metadata + coverage error: missing:mcp list-tools, missing:mcp tools`.** 3.3.0 added + the `mcp tools` and `mcp list-tools` subcommands to `mcp.ts` but did not + register them in `COMMAND_META`. The runtime coverage check inside + `capabilities` fires against the fully-registered program, but the + existing `capabilities-meta` unit test only registered the `capabilities` + command in isolation — so the gap never surfaced in CI and only showed + up in the post-publish tarball smoke. Entries are now present and the + command prints its manifest again. + +### Added + +- **Regression guard test `capabilities-program-coverage.test.ts`.** + Registers every `register*Command` from `src/index.ts` (mirrored order) + against a fresh `Command`, then actually invokes + `capabilities --compact` so `validateCommandMetaCoverage` walks the + complete command tree. Any future leaf command added without a + `COMMAND_META` entry now fails this test before publish instead of on + users' machines. + ## [3.3.1] - 2026-04-26 ### Fixed diff --git a/README.md b/README.md index 6c14949..a84c3e6 100644 --- a/README.md +++ b/README.md @@ -894,7 +894,7 @@ Queries the npm registry for the latest published version and compares it agains ```json { - "current": "3.3.1", + "current": "3.3.2", "latest": "4.0.0", "upToDate": false, "updateAvailable": true, diff --git a/package-lock.json b/package-lock.json index 285b185..ccf751f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@switchbot/openapi-cli", - "version": "3.3.1", + "version": "3.3.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@switchbot/openapi-cli", - "version": "3.3.1", + "version": "3.3.2", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", diff --git a/package.json b/package.json index 98bb4c0..4c1ee06 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@switchbot/openapi-cli", - "version": "3.3.1", + "version": "3.3.2", "description": "SwitchBot smart home CLI — control devices, run scenes, stream real-time events, and integrate AI agents via MCP. Full API v1.1 coverage.", "keywords": [ "switchbot", diff --git a/src/commands/capabilities.ts b/src/commands/capabilities.ts index 3c37125..fc1971b 100644 --- a/src/commands/capabilities.ts +++ b/src/commands/capabilities.ts @@ -166,6 +166,8 @@ export const COMMAND_META: Record = { 'history aggregate': READ_LOCAL, 'install': ACTION_LOCAL, 'mcp serve': READ_LOCAL, + 'mcp tools': READ_LOCAL, + 'mcp list-tools': READ_LOCAL, 'plan schema': READ_LOCAL, 'plan validate': READ_LOCAL, 'plan suggest': READ_LOCAL, diff --git a/tests/commands/capabilities-meta.test.ts b/tests/commands/capabilities-meta.test.ts index b428ffe..0014c7b 100644 --- a/tests/commands/capabilities-meta.test.ts +++ b/tests/commands/capabilities-meta.test.ts @@ -39,7 +39,7 @@ const ALL_EXPECTED_LEAF_COMMANDS = [ 'history show', 'history replay', 'history range', 'history stats', 'history verify', 'history aggregate', 'install', - 'mcp serve', + 'mcp serve', 'mcp tools', 'mcp list-tools', 'plan schema', 'plan validate', 'plan suggest', 'plan run', 'plan save', 'plan list', 'plan review', 'plan approve', 'plan execute', 'policy validate', 'policy new', 'policy migrate', 'policy diff', diff --git a/tests/commands/capabilities-program-coverage.test.ts b/tests/commands/capabilities-program-coverage.test.ts new file mode 100644 index 0000000..a1bdd4b --- /dev/null +++ b/tests/commands/capabilities-program-coverage.test.ts @@ -0,0 +1,102 @@ +import { Command } from 'commander'; +import { describe, expect, it, vi } from 'vitest'; + +// Catalog and cache are imported transitively by many register*Command calls; +// mock them so the registration does not try to load real files. +const catalogMock = vi.hoisted(() => ({ + getEffectiveCatalog: vi.fn(() => []), + deriveSafetyTier: vi.fn(() => 'action' as const), + deriveStatusQueries: vi.fn(() => []), + findCatalogEntry: vi.fn(() => null), +})); +const cacheMock = vi.hoisted(() => ({ + loadCache: vi.fn(() => ({ list: [], status: {} })), +})); +vi.mock('../../src/devices/catalog.js', async (importActual) => { + const actual = await importActual(); + return { ...actual, ...catalogMock }; +}); +vi.mock('../../src/devices/cache.js', async (importActual) => { + const actual = await importActual(); + return { ...actual, ...cacheMock }; +}); + +// This test is the regression guard for v3.3.1 where `mcp tools` and +// `mcp list-tools` were registered in mcp.ts but not added to COMMAND_META. +// The coverage check inside `capabilities` only fires when the FULL program +// (every register*Command) has been wired up — the existing unit tests only +// registered `capabilities` itself, so the missing entries slipped past CI. +// This test registers every top-level command the real CLI registers, then +// invokes `capabilities` so `validateCommandMetaCoverage` sees the whole tree. + +// Imports are awaited sequentially rather than in Promise.all — on Windows +// under vitest, a 25-way Promise.all of dynamic imports of ESM modules (each +// with its own transitive chain) starves the import queue and wedges for >30s. +// Serial imports finish in ~1.5s. +async function buildFullProgram(): Promise { + const program = new Command(); + // Mirror src/index.ts registration order. + const modules: Array<[string, string]> = [ + ['../../src/commands/config.js', 'registerConfigCommand'], + ['../../src/commands/devices.js', 'registerDevicesCommand'], + ['../../src/commands/scenes.js', 'registerScenesCommand'], + ['../../src/commands/webhook.js', 'registerWebhookCommand'], + ['../../src/commands/completion.js', 'registerCompletionCommand'], + ['../../src/commands/mcp.js', 'registerMcpCommand'], + ['../../src/commands/quota.js', 'registerQuotaCommand'], + ['../../src/commands/catalog.js', 'registerCatalogCommand'], + ['../../src/commands/cache.js', 'registerCacheCommand'], + ['../../src/commands/events.js', 'registerEventsCommand'], + ['../../src/commands/doctor.js', 'registerDoctorCommand'], + ['../../src/commands/schema.js', 'registerSchemaCommand'], + ['../../src/commands/history.js', 'registerHistoryCommand'], + ['../../src/commands/plan.js', 'registerPlanCommand'], + ['../../src/commands/capabilities.js', 'registerCapabilitiesCommand'], + ['../../src/commands/agent-bootstrap.js', 'registerAgentBootstrapCommand'], + ['../../src/commands/policy.js', 'registerPolicyCommand'], + ['../../src/commands/rules.js', 'registerRulesCommand'], + ['../../src/commands/auth.js', 'registerAuthCommand'], + ['../../src/commands/install.js', 'registerInstallCommand'], + ['../../src/commands/uninstall.js', 'registerUninstallCommand'], + ['../../src/commands/status-sync.js', 'registerStatusSyncCommand'], + ['../../src/commands/health.js', 'registerHealthCommand'], + ['../../src/commands/upgrade-check.js', 'registerUpgradeCheckCommand'], + ['../../src/commands/daemon.js', 'registerDaemonCommand'], + ]; + for (const [modPath, fnName] of modules) { + const mod = (await import(modPath)) as Record void>; + mod[fnName](program); + } + program.exitOverride(); + return program; +} + +describe('capabilities coverage against the fully-registered CLI', () => { + it( + 'validateCommandMetaCoverage passes for every leaf command in the real program', + { timeout: 30_000 }, + async () => { + const program = await buildFullProgram(); + const stdout: string[] = []; + const logSpy = vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + stdout.push(args.map(String).join(' ')); + }); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`__exit__:${code}`); + }) as never); + try { + await program.parseAsync(['node', 'switchbot', 'capabilities', '--compact']); + } finally { + logSpy.mockRestore(); + exitSpy.mockRestore(); + } + const out = stdout.join(''); + expect(out.length).toBeGreaterThan(50); + const parsed = JSON.parse(out) as { data?: { commands?: Array<{ name: string }> } }; + expect(parsed.data).toBeDefined(); + expect(parsed.data!.commands).toBeDefined(); + // Sanity: the coverage check would have thrown before printJson ran, + // so reaching this line already proves every leaf has COMMAND_META. + }, + ); +});