From b5d64ab8d0d5fa719a5eddd7ac763d14699d3d37 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 13 Jun 2026 15:53:08 +0800 Subject: [PATCH 01/57] docs: add AI MindClip design spec and TDD implementation plan fix(hooks): set COMSPEC in pre-commit hook for Windows Git Bash compatibility --- .githooks/pre-commit | 7 + .../plans/2026-06-13-ai-mindclip.md | 1245 +++++++++++++++++ .../specs/2026-06-13-ai-mindclip-design.md | 165 +++ 3 files changed, 1417 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-13-ai-mindclip.md create mode 100644 docs/superpowers/specs/2026-06-13-ai-mindclip-design.md diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 9565ea36..fee616ef 100644 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -4,5 +4,12 @@ set -eu REPO_ROOT="$(git rev-parse --show-toplevel)" cd "$REPO_ROOT" +# Windows: Git Bash clears COMSPEC, which causes npm's child_process.spawn to +# receive an undefined shell path and fail with ERR_INVALID_ARG_TYPE. +if [ -z "${COMSPEC:-}" ] && [ -f "/c/Windows/System32/cmd.exe" ]; then + COMSPEC="C:\\Windows\\System32\\cmd.exe" + export COMSPEC +fi + echo "[pre-commit] packaging sanity checks" npm run verify:pre-commit diff --git a/docs/superpowers/plans/2026-06-13-ai-mindclip.md b/docs/superpowers/plans/2026-06-13-ai-mindclip.md new file mode 100644 index 00000000..22a98329 --- /dev/null +++ b/docs/superpowers/plans/2026-06-13-ai-mindclip.md @@ -0,0 +1,1245 @@ +# AI MindClip + Account Cache Fix Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add AI MindClip device support (catalog entry + 7 CLI subcommands) and fix the three in-memory caches that persist stale data when switching accounts. + +**Architecture:** Three independent parts — (A) catalog entry, (B) new `mindclip` command group with lib + commands files, (C) minimal cache-clear exports added to the credential priming module and wired into both credential-save paths. Part C is done first because it's the smallest and its tests establish the `clearPrimedCredentials` export that later tasks depend on. + +**Tech Stack:** TypeScript, Commander.js, Vitest, Axios (via `createClient()`), Node.js 20+ + +--- + +## File Structure + +| File | Status | Responsibility | +|---|---|---| +| `src/credentials/prime.ts` | MODIFY | Export `clearPrimedCredentials()` for production cache reset | +| `src/commands/auth.ts` | MODIFY | Call `clearPrimedCredentials()` + `idempotencyCache.clear()` after login | +| `src/commands/config.ts` | MODIFY | Call all 4 cache-clear functions after `set-token` | +| `src/utils/arg-parsers.ts` | MODIFY | Add `dateArg()` and `weekArg()` validators | +| `src/devices/catalog.ts` | MODIFY | Add AI MindClip entry (read-only, 5 status fields) | +| `src/lib/mindclip.ts` | CREATE | 7 async API helper functions for MindClip endpoints | +| `src/commands/mindclip.ts` | CREATE | 7 CLI subcommands with validation and help text | +| `src/program-builder.ts` | MODIFY | Import + register mindclip; add to `TOP_LEVEL_COMMANDS` | +| `tests/credentials/prime.test.ts` | MODIFY | Add test for `clearPrimedCredentials()` | +| `tests/utils/arg-parsers.test.ts` | MODIFY | Add `dateArg` and `weekArg` describe blocks | +| `tests/devices/mindclip-catalog.test.ts` | CREATE | Verify AI MindClip catalog entry fields | +| `tests/lib/mindclip.test.ts` | CREATE | Unit tests for 7 API helpers (mocked HTTP) | +| `tests/commands/mindclip.test.ts` | CREATE | Validation + action smoke tests | + +--- + +## Task 1: Export clearPrimedCredentials + add test + +**Files:** +- Modify: `src/credentials/prime.ts` (add one export after line 72) +- Modify: `tests/credentials/prime.test.ts` (add one `it` block + import) + +- [ ] **Step 1: Add the failing test** + +Open `tests/credentials/prime.test.ts`. Add `clearPrimedCredentials` to the import at line 6, then add this test inside the existing `describe('primeCredentials', ...)` block: + +```typescript +// Change line 6 from: +import { + primeCredentials, + getPrimedCredentials, + __resetPrimedCredentials, +} from '../../src/credentials/prime.js'; + +// To: +import { + primeCredentials, + getPrimedCredentials, + clearPrimedCredentials, + __resetPrimedCredentials, +} from '../../src/credentials/prime.js'; +``` + +Add this test inside the `describe` block (after line 93): + +```typescript + it('clearPrimedCredentials() clears the in-memory cache immediately', async () => { + const get = vi.fn().mockResolvedValue({ token: 'T', secret: 'S' }); + selectMock.mockResolvedValue({ name: 'keychain', get } as any); + + await primeCredentials('default'); + expect(getPrimedCredentials('default')).not.toBeNull(); + + clearPrimedCredentials(); + expect(getPrimedCredentials('default')).toBeNull(); + }); +``` + +- [ ] **Step 2: Run the failing test** + +``` +npx vitest run tests/credentials/prime.test.ts +``` + +Expected: FAIL — `clearPrimedCredentials is not exported` + +- [ ] **Step 3: Add the export to prime.ts** + +Open `src/credentials/prime.ts`. After the `__resetPrimedCredentials` function (line 70), add: + +```typescript +/** + * Production helper — called by auth and config commands after saving new + * credentials to ensure the 5-second priming cache does not serve stale + * token/secret from the previous account. + */ +export function clearPrimedCredentials(): void { + cache = null; +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +``` +npx vitest run tests/credentials/prime.test.ts +``` + +Expected: PASS (7 tests) + +- [ ] **Step 5: Commit** + +```bash +git add src/credentials/prime.ts tests/credentials/prime.test.ts +git commit -m "feat: export clearPrimedCredentials for cache reset on account switch" +``` + +--- + +## Task 2: Fix auth.ts + config.ts cache leaks + +**Files:** +- Modify: `src/commands/auth.ts` (around line 455–456) +- Modify: `src/commands/config.ts` (around line 257) + +- [ ] **Step 1: Update auth.ts** + +`src/commands/auth.ts` already imports `clearCache, clearStatusCache` at line 28. Add two more imports: + +```typescript +// After line 33 (import { verifyCredentials } from '../auth/verify.js'): +import { clearPrimedCredentials } from '../credentials/prime.js'; +import { idempotencyCache } from '../lib/idempotency.js'; +``` + +Find the block around line 452–456 that reads: + +```typescript + clearCache(); + clearStatusCache(); +``` + +Replace with: + +```typescript + clearCache(); + clearStatusCache(); + clearPrimedCredentials(); + idempotencyCache.clear(); +``` + +- [ ] **Step 2: Update config.ts** + +`src/commands/config.ts` currently has no cache-clear imports. Add four imports after the existing imports at the top of the file (after line 10): + +```typescript +import { clearCache, clearStatusCache } from '../devices/cache.js'; +import { clearPrimedCredentials } from '../credentials/prime.js'; +import { idempotencyCache } from '../lib/idempotency.js'; +``` + +Find the `saveConfig(...)` call around line 257. After that call, add: + +```typescript + saveConfig(token, secret, { + label: options.label, + description: options.description, + limits: options.dailyCap ? { dailyCap: Number.parseInt(options.dailyCap, 10) } : undefined, + defaults: options.defaultFlags + ? { + flags: options.defaultFlags + .split(',') + .map((s) => s.trim()) + .filter(Boolean), + } + : undefined, + }); + clearCache(); + clearStatusCache(); + clearPrimedCredentials(); + idempotencyCache.clear(); +``` + +- [ ] **Step 3: Run the full test suite to confirm no regressions** + +``` +npx vitest run tests/credentials/ tests/lib/idempotency.test.ts +``` + +Expected: all pass + +- [ ] **Step 4: Commit** + +```bash +git add src/commands/auth.ts src/commands/config.ts +git commit -m "fix: clear priming and idempotency caches on account credential change" +``` + +--- + +## Task 3: Add dateArg + weekArg validators + +**Files:** +- Modify: `src/utils/arg-parsers.ts` (add two exports at the end) +- Modify: `tests/utils/arg-parsers.test.ts` (add two describe blocks) + +- [ ] **Step 1: Write the failing tests** + +Add to the **end** of `tests/utils/arg-parsers.test.ts`: + +```typescript +describe('dateArg', () => { + const parse = dateArg('--date'); + + it('accepts valid YYYY-MM-DD dates', () => { + expect(parse('2026-06-13')).toBe('2026-06-13'); + expect(parse('2026-01-01')).toBe('2026-01-01'); + expect(parse('2026-12-31')).toBe('2026-12-31'); + }); + + it('rejects dates with wrong separator', () => { + expect(() => parse('2026/06/13')).toThrow(InvalidArgumentError); + expect(() => parse('2026/06/13')).toThrow(/YYYY-MM-DD/); + }); + + it('rejects American date format', () => { + expect(() => parse('06-13-2026')).toThrow(/YYYY-MM-DD/); + }); + + it('rejects impossible calendar dates', () => { + expect(() => parse('2026-02-30')).toThrow(/YYYY-MM-DD/); + expect(() => parse('2026-13-01')).toThrow(/YYYY-MM-DD/); + }); + + it('rejects flag-like tokens', () => { + expect(() => parse('--help')).toThrow(/YYYY-MM-DD/); + }); +}); + +describe('weekArg', () => { + const parse = weekArg('--week'); + + it('accepts valid ISO week strings W01-W53', () => { + expect(parse('2026-W23')).toBe('2026-W23'); + expect(parse('2026-W01')).toBe('2026-W01'); + expect(parse('2026-W53')).toBe('2026-W53'); + expect(parse('2026-W09')).toBe('2026-W09'); + }); + + it('rejects W00 (week 0 does not exist)', () => { + expect(() => parse('2026-W00')).toThrow(InvalidArgumentError); + expect(() => parse('2026-W00')).toThrow(/YYYY-Www/); + }); + + it('rejects W54 and above', () => { + expect(() => parse('2026-W54')).toThrow(/YYYY-Www/); + expect(() => parse('2026-W99')).toThrow(/YYYY-Www/); + }); + + it('rejects missing dash between year and W', () => { + expect(() => parse('2026W23')).toThrow(/YYYY-Www/); + }); + + it('rejects 2-digit years', () => { + expect(() => parse('26-W23')).toThrow(/YYYY-Www/); + }); + + it('rejects single-digit week', () => { + expect(() => parse('2026-W5')).toThrow(/YYYY-Www/); + }); +}); +``` + +Update the import at the top of `tests/utils/arg-parsers.test.ts` to include the two new functions: + +```typescript +import { intArg, durationArg, stringArg, enumArg, dateArg, weekArg } from '../../src/utils/arg-parsers.js'; +``` + +- [ ] **Step 2: Run the failing tests** + +``` +npx vitest run tests/utils/arg-parsers.test.ts +``` + +Expected: FAIL — `dateArg is not exported`, `weekArg is not exported` + +- [ ] **Step 3: Implement dateArg and weekArg in arg-parsers.ts** + +Add to the **end** of `src/utils/arg-parsers.ts`: + +```typescript +export function dateArg(flagName: string): (value: string) => string { + return (value: string) => { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value) || isNaN(Date.parse(value))) { + throw new InvalidArgumentError( + `${flagName} must be in YYYY-MM-DD format (got "${value}")`, + ); + } + return value; + }; +} + +export function weekArg(flagName: string): (value: string) => string { + return (value: string) => { + if (!/^\d{4}-W(0[1-9]|[1-4]\d|5[0-3])$/.test(value)) { + throw new InvalidArgumentError( + `${flagName} must be in YYYY-Www format, weeks 01–53 (e.g. 2026-W23 — got "${value}")`, + ); + } + return value; + }; +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +``` +npx vitest run tests/utils/arg-parsers.test.ts +``` + +Expected: PASS (all describe blocks) + +- [ ] **Step 5: Commit** + +```bash +git add src/utils/arg-parsers.ts tests/utils/arg-parsers.test.ts +git commit -m "feat: add dateArg and weekArg validators for YYYY-MM-DD and YYYY-Www formats" +``` + +--- + +## Task 4: Add AI MindClip to device catalog + +**Files:** +- Create: `tests/devices/mindclip-catalog.test.ts` +- Modify: `src/devices/catalog.ts` (add one entry to the `DEVICE_CATALOG` array) + +- [ ] **Step 1: Write the failing test** + +Create `tests/devices/mindclip-catalog.test.ts`: + +```typescript +import { describe, it, expect } from 'vitest'; +import { DEVICE_CATALOG } from '../../src/devices/catalog.js'; + +describe('AI MindClip catalog entry', () => { + const entry = DEVICE_CATALOG.find((e) => e.type === 'AI MindClip'); + + it('has a catalog entry', () => { + expect(entry).toBeDefined(); + }); + + it('is read-only (no control commands)', () => { + expect(entry?.readOnly).toBe(true); + expect(entry?.commands).toEqual([]); + }); + + it('has the correct category and role', () => { + expect(entry?.category).toBe('physical'); + expect(entry?.role).toBe('other'); + }); + + it('has all 5 status fields in the correct order', () => { + expect(entry?.statusFields).toEqual([ + 'battery', + 'chargingStatus', + 'recordingStatus', + 'uploadStatus', + 'hasUntransferredFiles', + ]); + }); +}); +``` + +- [ ] **Step 2: Run the failing test** + +``` +npx vitest run tests/devices/mindclip-catalog.test.ts +``` + +Expected: FAIL — `entry` is `undefined` + +- [ ] **Step 3: Add AI MindClip to catalog.ts** + +Open `src/devices/catalog.ts` and find the `DEVICE_CATALOG` array. Locate the entry for `'AI Hub'` or similar read-only device (for reference). Add the following entry in alphabetical order (near the top of the array or with other `A` entries): + +```typescript + { + type: 'AI MindClip', + category: 'physical', + description: 'AI-powered voice recorder with transcription and meeting summaries.', + role: 'other', + readOnly: true, + commands: [], + statusFields: ['battery', 'chargingStatus', 'recordingStatus', 'uploadStatus', 'hasUntransferredFiles'], + }, +``` + +- [ ] **Step 4: Run the test to verify it passes** + +``` +npx vitest run tests/devices/mindclip-catalog.test.ts +``` + +Expected: PASS (4 tests) + +- [ ] **Step 5: Run existing catalog tests to confirm no regressions** + +``` +npx vitest run tests/devices/ +``` + +Expected: all pass + +- [ ] **Step 6: Commit** + +```bash +git add src/devices/catalog.ts tests/devices/mindclip-catalog.test.ts +git commit -m "feat: add AI MindClip read-only device to catalog with 5 status fields" +``` + +--- + +## Task 5: Create src/lib/mindclip.ts + +**Files:** +- Create: `src/lib/mindclip.ts` +- Create: `tests/lib/mindclip.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/lib/mindclip.test.ts`: + +```typescript +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + listRecordings, + getRecording, + getSummary, + listTodos, + getDailyRecall, + getWeeklySummary, + getUrgentTodos, +} from '../../src/lib/mindclip.js'; + +const apiMock = vi.hoisted(() => { + const instance = { get: vi.fn() }; + return { createClient: vi.fn(() => instance), __instance: instance }; +}); + +vi.mock('../../src/api/client.js', () => ({ + createClient: apiMock.createClient, +})); + +beforeEach(() => { + apiMock.__instance.get.mockReset(); +}); + +// --------------------------------------------------------------------------- +// listRecordings +// --------------------------------------------------------------------------- +describe('listRecordings', () => { + it('calls GET /v1.1/mindclip/recordings and returns body', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ data: { body: { list: [] } } }); + const result = await listRecordings({}); + expect(apiMock.__instance.get).toHaveBeenCalledWith('/v1.1/mindclip/recordings', { params: {} }); + expect(result).toEqual({ list: [] }); + }); + + it('passes deviceID, page, and size params', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ data: { body: {} } }); + await listRecordings({ deviceID: 'DEV1', pageNum: 2, pageSize: 10 }); + expect(apiMock.__instance.get).toHaveBeenCalledWith('/v1.1/mindclip/recordings', { + params: { deviceID: 'DEV1', pageNum: 2, pageSize: 10 }, + }); + }); + + it('passes startTime, endTime, and folderID params', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ data: { body: {} } }); + await listRecordings({ startTime: 1000, endTime: 2000, folderID: 3 }); + expect(apiMock.__instance.get).toHaveBeenCalledWith('/v1.1/mindclip/recordings', { + params: { startTime: 1000, endTime: 2000, folderID: 3 }, + }); + }); + + it('omits undefined params from the request', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ data: { body: {} } }); + await listRecordings({ pageNum: 1 }); + const params = apiMock.__instance.get.mock.calls[0][1].params; + expect(params).not.toHaveProperty('deviceID'); + expect(params).not.toHaveProperty('startTime'); + expect(params).not.toHaveProperty('folderID'); + }); +}); + +// --------------------------------------------------------------------------- +// getRecording +// --------------------------------------------------------------------------- +describe('getRecording', () => { + it('calls GET /v1.1/mindclip/recordings/{id}', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ data: { body: { id: 'r1' } } }); + const result = await getRecording('r1'); + expect(apiMock.__instance.get).toHaveBeenCalledWith('/v1.1/mindclip/recordings/r1', { params: {} }); + expect(result).toEqual({ id: 'r1' }); + }); + + it('includes language param when provided', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ data: { body: {} } }); + await getRecording('r1', 'zh'); + expect(apiMock.__instance.get).toHaveBeenCalledWith('/v1.1/mindclip/recordings/r1', { + params: { language: 'zh' }, + }); + }); + + it('omits language param when undefined', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ data: { body: {} } }); + await getRecording('r1'); + const params = apiMock.__instance.get.mock.calls[0][1].params; + expect(params).not.toHaveProperty('language'); + }); +}); + +// --------------------------------------------------------------------------- +// getSummary +// --------------------------------------------------------------------------- +describe('getSummary', () => { + it('calls GET /v1.1/mindclip/summaries/{id}', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ data: { body: { summary: 'ok' } } }); + const result = await getSummary('s1'); + expect(apiMock.__instance.get).toHaveBeenCalledWith('/v1.1/mindclip/summaries/s1', { params: {} }); + expect(result).toEqual({ summary: 'ok' }); + }); +}); + +// --------------------------------------------------------------------------- +// listTodos +// --------------------------------------------------------------------------- +describe('listTodos', () => { + it('calls GET /v1.1/mindclip/todos and returns body', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ data: { body: { items: [] } } }); + const result = await listTodos({}); + expect(apiMock.__instance.get).toHaveBeenCalledWith('/v1.1/mindclip/todos', { params: {} }); + expect(result).toEqual({ items: [] }); + }); + + it('passes completedNum and category filters', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ data: { body: {} } }); + await listTodos({ completedNum: 1, category: 2, pageNum: 1, pageSize: 20 }); + expect(apiMock.__instance.get).toHaveBeenCalledWith('/v1.1/mindclip/todos', { + params: { completedNum: 1, category: 2, pageNum: 1, pageSize: 20 }, + }); + }); + + it('passes device and file filters', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ data: { body: {} } }); + await listTodos({ deviceID: 'D1', fileID: 'F1', startTime: 100, endTime: 200 }); + expect(apiMock.__instance.get).toHaveBeenCalledWith('/v1.1/mindclip/todos', { + params: { deviceID: 'D1', fileID: 'F1', startTime: 100, endTime: 200 }, + }); + }); + + it('omits undefined params', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ data: { body: {} } }); + await listTodos({ completedNum: 0 }); + const params = apiMock.__instance.get.mock.calls[0][1].params; + expect(params).not.toHaveProperty('deviceID'); + expect(params).not.toHaveProperty('fileID'); + expect(params).not.toHaveProperty('startTime'); + }); +}); + +// --------------------------------------------------------------------------- +// getDailyRecall +// --------------------------------------------------------------------------- +describe('getDailyRecall', () => { + it('calls GET /v1.1/mindclip/assistant/daily with date param', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ data: { body: {} } }); + await getDailyRecall('2026-06-13'); + expect(apiMock.__instance.get).toHaveBeenCalledWith('/v1.1/mindclip/assistant/daily', { + params: { date: '2026-06-13' }, + }); + }); + + it('omits date param when undefined (server uses its own default)', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ data: { body: {} } }); + await getDailyRecall(); + const params = apiMock.__instance.get.mock.calls[0][1].params; + expect(params).not.toHaveProperty('date'); + }); +}); + +// --------------------------------------------------------------------------- +// getWeeklySummary +// --------------------------------------------------------------------------- +describe('getWeeklySummary', () => { + it('calls GET /v1.1/mindclip/assistant/weekly with week param', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ data: { body: {} } }); + await getWeeklySummary('2026-W23'); + expect(apiMock.__instance.get).toHaveBeenCalledWith('/v1.1/mindclip/assistant/weekly', { + params: { week: '2026-W23' }, + }); + }); + + it('omits week param when undefined', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ data: { body: {} } }); + await getWeeklySummary(); + const params = apiMock.__instance.get.mock.calls[0][1].params; + expect(params).not.toHaveProperty('week'); + }); +}); + +// --------------------------------------------------------------------------- +// getUrgentTodos +// --------------------------------------------------------------------------- +describe('getUrgentTodos', () => { + it('calls GET /v1.1/mindclip/assistant/urgent-todos with date param', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ data: { body: {} } }); + await getUrgentTodos('2026-06-12'); + expect(apiMock.__instance.get).toHaveBeenCalledWith('/v1.1/mindclip/assistant/urgent-todos', { + params: { date: '2026-06-12' }, + }); + }); + + it('omits date param when undefined (server defaults to yesterday)', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ data: { body: {} } }); + await getUrgentTodos(); + const params = apiMock.__instance.get.mock.calls[0][1].params; + expect(params).not.toHaveProperty('date'); + }); +}); +``` + +- [ ] **Step 2: Run the failing tests** + +``` +npx vitest run tests/lib/mindclip.test.ts +``` + +Expected: FAIL — `Cannot find module '../../src/lib/mindclip.js'` + +- [ ] **Step 3: Implement src/lib/mindclip.ts** + +Create `src/lib/mindclip.ts`: + +```typescript +import { createClient } from '../api/client.js'; + +export interface ListRecordingsParams { + deviceID?: string; + pageNum?: number; + pageSize?: number; + startTime?: number; + endTime?: number; + folderID?: number; +} + +export interface ListTodosParams { + completedNum?: number; + pageNum?: number; + pageSize?: number; + deviceID?: string; + fileID?: string; + startTime?: number; + endTime?: number; + category?: number; +} + +function compact(obj: Record): Record { + return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined)); +} + +export async function listRecordings(params: ListRecordingsParams): Promise { + const c = createClient(); + const res = await c.get<{ body: unknown }>('/v1.1/mindclip/recordings', { + params: compact(params as Record), + }); + return res.data.body; +} + +export async function getRecording(id: string, language?: string): Promise { + const c = createClient(); + const res = await c.get<{ body: unknown }>(`/v1.1/mindclip/recordings/${id}`, { + params: compact({ language }), + }); + return res.data.body; +} + +export async function getSummary(id: string): Promise { + const c = createClient(); + const res = await c.get<{ body: unknown }>(`/v1.1/mindclip/summaries/${id}`, { params: {} }); + return res.data.body; +} + +export async function listTodos(params: ListTodosParams): Promise { + const c = createClient(); + const res = await c.get<{ body: unknown }>('/v1.1/mindclip/todos', { + params: compact(params as Record), + }); + return res.data.body; +} + +export async function getDailyRecall(date?: string): Promise { + const c = createClient(); + const res = await c.get<{ body: unknown }>('/v1.1/mindclip/assistant/daily', { + params: compact({ date }), + }); + return res.data.body; +} + +export async function getWeeklySummary(week?: string): Promise { + const c = createClient(); + const res = await c.get<{ body: unknown }>('/v1.1/mindclip/assistant/weekly', { + params: compact({ week }), + }); + return res.data.body; +} + +export async function getUrgentTodos(date?: string): Promise { + const c = createClient(); + const res = await c.get<{ body: unknown }>('/v1.1/mindclip/assistant/urgent-todos', { + params: compact({ date }), + }); + return res.data.body; +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +``` +npx vitest run tests/lib/mindclip.test.ts +``` + +Expected: PASS (all describe blocks, ~16 tests) + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/mindclip.ts tests/lib/mindclip.test.ts +git commit -m "feat: add MindClip API helper functions for 7 custom endpoints" +``` + +--- + +## Task 6: Create src/commands/mindclip.ts + +**Files:** +- Create: `src/commands/mindclip.ts` +- Create: `tests/commands/mindclip.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/commands/mindclip.test.ts`: + +```typescript +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { Command } from 'commander'; +import { registerMindclipCommand } from '../../src/commands/mindclip.js'; + +// Mock the mindclip lib so action handlers don't make real HTTP calls. +const mindclipMock = vi.hoisted(() => ({ + listRecordings: vi.fn().mockResolvedValue({}), + getRecording: vi.fn().mockResolvedValue({}), + getSummary: vi.fn().mockResolvedValue({}), + listTodos: vi.fn().mockResolvedValue({}), + getDailyRecall: vi.fn().mockResolvedValue({}), + getWeeklySummary: vi.fn().mockResolvedValue({}), + getUrgentTodos: vi.fn().mockResolvedValue({}), +})); + +vi.mock('../../src/lib/mindclip.js', () => mindclipMock); +vi.mock('../../src/utils/output.js', () => ({ + printJson: vi.fn(), + isJsonMode: vi.fn(() => false), + exitWithError: vi.fn((opts) => { throw new Error(typeof opts === 'string' ? opts : opts.message); }), +})); + +function buildProgram(): Command { + const program = new Command().exitOverride(); + registerMindclipCommand(program); + return program; +} + +beforeEach(() => { + Object.values(mindclipMock).forEach((fn) => fn.mockClear()); +}); + +// --------------------------------------------------------------------------- +// recordings validation +// --------------------------------------------------------------------------- +describe('mindclip recordings validation', () => { + it('rejects --page 0 (must be >= 1)', () => { + expect(() => + buildProgram().parse(['node', 'sw', 'mindclip', 'recordings', '--page', '0']), + ).toThrow(); + }); + + it('rejects --size 0', () => { + expect(() => + buildProgram().parse(['node', 'sw', 'mindclip', 'recordings', '--size', '0']), + ).toThrow(); + }); + + it('rejects --size 101', () => { + expect(() => + buildProgram().parse(['node', 'sw', 'mindclip', 'recordings', '--size', '101']), + ).toThrow(); + }); + + it('rejects --start with negative value', () => { + expect(() => + buildProgram().parse(['node', 'sw', 'mindclip', 'recordings', '--start', '-1']), + ).toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// todos validation +// --------------------------------------------------------------------------- +describe('mindclip todos validation', () => { + it('rejects --completed 3 (only 0, 1, 2 allowed)', () => { + expect(() => + buildProgram().parse(['node', 'sw', 'mindclip', 'todos', '--completed', '3']), + ).toThrow(); + }); + + it('rejects --category 6 (max is 5)', () => { + expect(() => + buildProgram().parse(['node', 'sw', 'mindclip', 'todos', '--category', '6']), + ).toThrow(); + }); + + it('rejects --category negative', () => { + expect(() => + buildProgram().parse(['node', 'sw', 'mindclip', 'todos', '--category', '-1']), + ).toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// daily / weekly / urgent-todos validation +// --------------------------------------------------------------------------- +describe('mindclip date validation', () => { + it('rejects --date in MM-DD-YYYY format', () => { + expect(() => + buildProgram().parse(['node', 'sw', 'mindclip', 'daily', '--date', '06-13-2026']), + ).toThrow(); + }); + + it('rejects --date with slashes', () => { + expect(() => + buildProgram().parse(['node', 'sw', 'mindclip', 'urgent-todos', '--date', '2026/06/13']), + ).toThrow(); + }); + + it('rejects --week without dash (2026W23)', () => { + expect(() => + buildProgram().parse(['node', 'sw', 'mindclip', 'weekly', '--week', '2026W23']), + ).toThrow(); + }); + + it('rejects --week W00', () => { + expect(() => + buildProgram().parse(['node', 'sw', 'mindclip', 'weekly', '--week', '2026-W00']), + ).toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// action handler smoke tests (valid args call the right lib function) +// --------------------------------------------------------------------------- +describe('mindclip action handlers', () => { + it('recordings with no options calls listRecordings with empty params', async () => { + await buildProgram().parseAsync(['node', 'sw', 'mindclip', 'recordings']); + expect(mindclipMock.listRecordings).toHaveBeenCalledOnce(); + const params = mindclipMock.listRecordings.mock.calls[0][0]; + expect(Object.keys(params).length).toBe(0); + }); + + it('recording calls getRecording with id', async () => { + await buildProgram().parseAsync(['node', 'sw', 'mindclip', 'recording', 'abc123']); + expect(mindclipMock.getRecording).toHaveBeenCalledWith('abc123', undefined); + }); + + it('recording --language en calls getRecording with language', async () => { + await buildProgram().parseAsync(['node', 'sw', 'mindclip', 'recording', 'abc123', '--language', 'en']); + expect(mindclipMock.getRecording).toHaveBeenCalledWith('abc123', 'en'); + }); + + it('summary calls getSummary', async () => { + await buildProgram().parseAsync(['node', 'sw', 'mindclip', 'summary', 's1']); + expect(mindclipMock.getSummary).toHaveBeenCalledWith('s1'); + }); + + it('todos --completed 1 calls listTodos with completedNum 1', async () => { + await buildProgram().parseAsync(['node', 'sw', 'mindclip', 'todos', '--completed', '1']); + const params = mindclipMock.listTodos.mock.calls[0][0]; + expect(params.completedNum).toBe(1); + }); + + it('daily --date 2026-06-10 calls getDailyRecall with that date', async () => { + await buildProgram().parseAsync(['node', 'sw', 'mindclip', 'daily', '--date', '2026-06-10']); + expect(mindclipMock.getDailyRecall).toHaveBeenCalledWith('2026-06-10'); + }); + + it('daily with no date calls getDailyRecall with undefined', async () => { + await buildProgram().parseAsync(['node', 'sw', 'mindclip', 'daily']); + expect(mindclipMock.getDailyRecall).toHaveBeenCalledWith(undefined); + }); + + it('weekly --week 2026-W23 calls getWeeklySummary', async () => { + await buildProgram().parseAsync(['node', 'sw', 'mindclip', 'weekly', '--week', '2026-W23']); + expect(mindclipMock.getWeeklySummary).toHaveBeenCalledWith('2026-W23'); + }); + + it('urgent-todos with no date calls getUrgentTodos with undefined (server defaults to yesterday)', async () => { + await buildProgram().parseAsync(['node', 'sw', 'mindclip', 'urgent-todos']); + expect(mindclipMock.getUrgentTodos).toHaveBeenCalledWith(undefined); + }); +}); +``` + +- [ ] **Step 2: Run the failing tests** + +``` +npx vitest run tests/commands/mindclip.test.ts +``` + +Expected: FAIL — `Cannot find module '../../src/commands/mindclip.js'` + +- [ ] **Step 3: Implement src/commands/mindclip.ts** + +Create `src/commands/mindclip.ts`: + +```typescript +import { Command } from 'commander'; +import { intArg, enumArg, stringArg, dateArg, weekArg } from '../utils/arg-parsers.js'; +import { isJsonMode, printJson } from '../utils/output.js'; +import { + listRecordings, + getRecording, + getSummary, + listTodos, + getDailyRecall, + getWeeklySummary, + getUrgentTodos, +} from '../lib/mindclip.js'; + +export function registerMindclipCommand(program: Command): void { + const mindclip = program + .command('mindclip') + .description('Access AI MindClip recordings, summaries, and to-dos') + .addHelpText( + 'after', + ` +Subcommands: + recordings List recordings across all AI MindClip devices + recording Get a single recording's metadata and transcript + summary Get AI summary for a recording + todos List AI-extracted to-do items + daily Get daily recall summary + weekly Get weekly summary + urgent-todos Get urgent to-dos for a date + +Examples: + switchbot mindclip recordings --device AABBCCDDEEFF --size 10 + switchbot mindclip todos --completed 1 + switchbot mindclip daily --date 2026-06-10 + switchbot mindclip weekly`, + ); + + // recordings + mindclip + .command('recordings') + .description('List recordings for AI MindClip devices') + .option('--device ', 'Filter by device ID', stringArg('--device')) + .option('--page ', 'Page number (>= 1)', intArg('--page', { min: 1 })) + .option('--size ', 'Results per page (1-100)', intArg('--size', { min: 1, max: 100 })) + .option('--start ', 'Start timestamp in milliseconds', intArg('--start', { min: 0 })) + .option('--end ', 'End timestamp in milliseconds', intArg('--end', { min: 0 })) + .option('--folder ', 'Folder ID', intArg('--folder', { min: 0 })) + .addHelpText( + 'after', + ` +Examples: + switchbot mindclip recordings + switchbot mindclip recordings --device AABBCCDDEEFF --page 2 --size 10`, + ) + .action(async (options) => { + const params = Object.fromEntries( + Object.entries({ + deviceID: options.device, + pageNum: options.page !== undefined ? Number(options.page) : undefined, + pageSize: options.size !== undefined ? Number(options.size) : undefined, + startTime: options.start !== undefined ? Number(options.start) : undefined, + endTime: options.end !== undefined ? Number(options.end) : undefined, + folderID: options.folder !== undefined ? Number(options.folder) : undefined, + }).filter(([, v]) => v !== undefined), + ); + const data = await listRecordings(params); + printJson(data); + }); + + // recording + mindclip + .command('recording ') + .description('Get details of a single recording') + .option('--language ', 'Language code for response (e.g. en, zh)', stringArg('--language')) + .addHelpText( + 'after', + ` +Examples: + switchbot mindclip recording 5f3a1c2e9b7d + switchbot mindclip recording 5f3a1c2e9b7d --language en`, + ) + .action(async (id: string, options) => { + const data = await getRecording(id, options.language); + printJson(data); + }); + + // summary + mindclip + .command('summary ') + .description('Get AI summary and transcription for a recording') + .addHelpText( + 'after', + ` +Examples: + switchbot mindclip summary 5f3a1c2e9b7d`, + ) + .action(async (id: string) => { + const data = await getSummary(id); + printJson(data); + }); + + // todos + mindclip + .command('todos') + .description('List AI-extracted to-do items') + .option( + '--completed ', + 'Filter: 0=all, 1=incomplete, 2=completed [default: 0]', + enumArg('--completed', ['0', '1', '2']), + ) + .option('--page ', 'Page number (>= 1)', intArg('--page', { min: 1 })) + .option('--size ', 'Results per page (1-100)', intArg('--size', { min: 1, max: 100 })) + .option('--device ', 'Filter by device ID', stringArg('--device')) + .option('--file ', 'Filter by recording file ID', intArg('--file', { min: 0 })) + .option('--start ', 'Start timestamp in milliseconds', intArg('--start', { min: 0 })) + .option('--end ', 'End timestamp in milliseconds', intArg('--end', { min: 0 })) + .option( + '--category ', + 'Category: 0=any, 1=work, 2=life, 3=hobby, 4=holiday, 5=other', + intArg('--category', { min: 0, max: 5 }), + ) + .addHelpText( + 'after', + ` +Examples: + switchbot mindclip todos + switchbot mindclip todos --completed 1 --size 5 + switchbot mindclip todos --category 1`, + ) + .action(async (options) => { + const params = Object.fromEntries( + Object.entries({ + completedNum: options.completed !== undefined ? Number(options.completed) : undefined, + pageNum: options.page !== undefined ? Number(options.page) : undefined, + pageSize: options.size !== undefined ? Number(options.size) : undefined, + deviceID: options.device, + fileID: options.file !== undefined ? String(options.file) : undefined, + startTime: options.start !== undefined ? Number(options.start) : undefined, + endTime: options.end !== undefined ? Number(options.end) : undefined, + category: options.category !== undefined ? Number(options.category) : undefined, + }).filter(([, v]) => v !== undefined), + ); + const data = await listTodos(params); + printJson(data); + }); + + // daily + mindclip + .command('daily') + .description('Get daily recall summary (omit --date to get the most recent)') + .option('--date ', 'Date [default: most recent record on server]', dateArg('--date')) + .addHelpText( + 'after', + ` +Examples: + switchbot mindclip daily + switchbot mindclip daily --date 2026-06-10`, + ) + .action(async (options) => { + const data = await getDailyRecall(options.date); + printJson(data); + }); + + // weekly + mindclip + .command('weekly') + .description('Get weekly summary (omit --week to get the most recent)') + .option('--week ', 'ISO week [default: most recent record on server]', weekArg('--week')) + .addHelpText( + 'after', + ` +Examples: + switchbot mindclip weekly + switchbot mindclip weekly --week 2026-W23`, + ) + .action(async (options) => { + const data = await getWeeklySummary(options.week); + printJson(data); + }); + + // urgent-todos + mindclip + .command('urgent-todos') + .description("Get urgent to-dos for a date (omit --date to use yesterday's)") + .option('--date ', 'Date [default: yesterday on server]', dateArg('--date')) + .addHelpText( + 'after', + ` +Examples: + switchbot mindclip urgent-todos + switchbot mindclip urgent-todos --date 2026-06-10`, + ) + .action(async (options) => { + const data = await getUrgentTodos(options.date); + printJson(data); + }); +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +``` +npx vitest run tests/commands/mindclip.test.ts +``` + +Expected: PASS (all describe blocks, ~22 tests) + +- [ ] **Step 5: Commit** + +```bash +git add src/commands/mindclip.ts tests/commands/mindclip.test.ts +git commit -m "feat: add mindclip command group with 7 subcommands and option validation" +``` + +--- + +## Task 7: Register mindclip command in program-builder.ts + +**Files:** +- Modify: `src/program-builder.ts` (add import + registration + constant entry) + +- [ ] **Step 1: Update program-builder.ts** + +Add the import after the `registerCodexCommand` import (line 33): + +```typescript +import { registerMindclipCommand } from './commands/mindclip.js'; +``` + +Add `'mindclip'` to the `TOP_LEVEL_COMMANDS` tuple (line 39–44): + +```typescript +export const TOP_LEVEL_COMMANDS = [ + 'config', 'devices', 'scenes', 'webhook', 'completion', 'mcp', + 'quota', 'catalog', 'cache', 'events', 'doctor', 'schema', + 'history', 'plan', 'capabilities', 'agent-bootstrap', 'install', 'uninstall', 'status-sync', + 'health', 'upgrade-check', 'daemon', 'reset', 'codex', 'claude-code', 'gemini', 'mindclip', +] as const; +``` + +Find the `buildProgram` function and add the registration call alongside the others (alphabetically by command name or at the end of the register block): + +```typescript +registerMindclipCommand(program); +``` + +- [ ] **Step 2: Verify help output** + +``` +npx ts-node --esm src/main.ts mindclip --help +``` + +Expected output contains: + +``` +Usage: switchbot mindclip [options] [command] + +Access AI MindClip recordings, summaries, and to-dos + +Commands: + recordings List recordings for AI MindClip devices + recording Get details of a single recording + summary Get AI summary and transcription for a recording + todos List AI-extracted to-do items + daily Get daily recall summary (omit --date to get the most recent) + weekly Get weekly summary (omit --week to get the most recent) + urgent-todos Get urgent to-dos for a date (omit --date to use yesterday's) +``` + +- [ ] **Step 3: Run the full test suite** + +``` +npx vitest run +``` + +Expected: all tests pass, no regressions + +- [ ] **Step 4: Commit** + +```bash +git add src/program-builder.ts +git commit -m "feat: register mindclip command group in program builder" +``` + +--- + +## Self-Review + +### Spec coverage check + +| Spec requirement | Covered by task | +|---|---| +| AI MindClip in device catalog (read-only, 5 status fields) | Task 4 | +| `listRecordings` with optional deviceID, pagination, time range, folder | Task 5 | +| `getRecording` with optional language | Task 5 | +| `getSummary` | Task 5 | +| `listTodos` with completedNum, pagination, device, file, time, category | Task 5 | +| `getDailyRecall` — no client-side default, server decides | Task 5 | +| `getWeeklySummary` — no client-side default | Task 5 | +| `getUrgentTodos` — no client-side default | Task 5 | +| CLI: 7 subcommands with correct signatures | Task 6 | +| `--completed` accepts 0/1/2 only | Task 6 | +| `--category` accepts 0–5 only | Task 6 | +| `--date` validates YYYY-MM-DD | Tasks 3 + 6 | +| `--week` validates YYYY-Www W01–W53 | Tasks 3 + 6 | +| Help text with examples on every subcommand | Task 6 | +| `clearPrimedCredentials()` exported from prime.ts | Task 1 | +| `auth login` clears priming + idempotency cache | Task 2 | +| `config set-token` clears all 4 caches | Task 2 | +| `mindclip` registered in program-builder + TOP_LEVEL_COMMANDS | Task 7 | + +### Type consistency + +- `listTodos` receives `fileID` as `string | undefined` (the API field is a string ID). In the command handler, `options.file` is an integer string validated by `intArg('--file', {min:0})`, then converted to a string via `String(options.file)` before passing to `listTodos`. This matches `ListTodosParams.fileID?: string`. +- All numeric options use `Number(options.x)` conversion in action handlers since Commander's `argParser` returns a `string`. +- `compact` in `lib/mindclip.ts` correctly removes `undefined` keys before the request is built. diff --git a/docs/superpowers/specs/2026-06-13-ai-mindclip-design.md b/docs/superpowers/specs/2026-06-13-ai-mindclip-design.md new file mode 100644 index 00000000..bf9c43f1 --- /dev/null +++ b/docs/superpowers/specs/2026-06-13-ai-mindclip-design.md @@ -0,0 +1,165 @@ +# Design: AI MindClip Device Support + Account Switching Cache Fix + +**Date:** 2026-06-13 +**Status:** Approved +**Scope:** switchbot-cli + +--- + +## Problem + +1. **AI MindClip missing**: The `AI MindClip` voice recorder device (W1145000/AIPinNote) exists in SwitchBot's OpenAPI v1.1 but the CLI has no catalog entry and no support for its 7 custom `/v1.1/mindclip/*` endpoints (recordings, summaries, to-dos, daily/weekly recall, urgent to-dos). + +2. **Account switching data leak**: Switching accounts via `auth login` or `config set-token` leaves three in-memory caches populated with the previous account's data: (a) the 5-second credential priming cache (`prime.ts`), (b) the idempotency replay cache (`lib/idempotency.ts`), and (c) the device/status cache (only cleared by `auth login`, not by `config set-token`). + +--- + +## Approach + +Three independent, self-contained parts: + +**Part A — Device catalog entry**: Add `AI MindClip` to `src/devices/catalog.ts` as a read-only entry with 5 status fields. No commands — the device doesn't accept any. + +**Part B — MindClip command group**: Add `src/lib/mindclip.ts` (7 HTTP helper functions) and `src/commands/mindclip.ts` (7 CLI subcommands). Register in `src/program-builder.ts`. Option validation uses Commander's `InvalidArgumentError` pattern matching the existing `arg-parsers.ts` style. New `dateArg()`/`weekArg()` validators are added to `arg-parsers.ts`. + +**Part C — Cache leak fix**: Export `clearPrimedCredentials()` from `prime.ts` for production use (existing `__resetPrimedCredentials()` stays as test-only). Call it plus `idempotencyCache.clear()` in `auth.ts` (post-login) and `config.ts` (post-set-token). `config.ts` also gains the two missing `clearCache()`/`clearStatusCache()` calls. + +--- + +## File Layout + +``` +src/ + devices/catalog.ts MODIFY: add AI MindClip entry + utils/arg-parsers.ts MODIFY: add dateArg(), weekArg() + lib/mindclip.ts NEW: 7 API helper functions + commands/mindclip.ts NEW: 7 CLI subcommands + help text + program-builder.ts MODIFY: import + register mindclip; add to TOP_LEVEL_COMMANDS + credentials/prime.ts MODIFY: export clearPrimedCredentials() + commands/auth.ts MODIFY: call clearPrimedCredentials() + idempotencyCache.clear() + commands/config.ts MODIFY: call all 4 cache-clear functions after set-token + +tests/ + credentials/prime.test.ts MODIFY: add test for clearPrimedCredentials() + utils/arg-parsers.test.ts MODIFY: add describe blocks for dateArg(), weekArg() + devices/mindclip-catalog.test.ts NEW: catalog entry correctness + lib/mindclip.test.ts NEW: 7 API function unit tests (mocked HTTP client) + commands/mindclip.test.ts NEW: command validation + action smoke tests +``` + +--- + +## API Endpoints + +All 7 endpoints live under `/v1.1/mindclip/` and require the standard HMAC-SHA256 auth headers. + +| CLI Subcommand | Method | Endpoint | +|---|---|---| +| `recordings` | GET | `/v1.1/mindclip/recordings` | +| `recording ` | GET | `/v1.1/mindclip/recordings/{id}` | +| `summary ` | GET | `/v1.1/mindclip/summaries/{id}` | +| `todos` | GET | `/v1.1/mindclip/todos` | +| `daily` | GET | `/v1.1/mindclip/assistant/daily` | +| `weekly` | GET | `/v1.1/mindclip/assistant/weekly` | +| `urgent-todos` | GET | `/v1.1/mindclip/assistant/urgent-todos` | + +--- + +## API Function Signatures + +```typescript +// src/lib/mindclip.ts + +interface ListRecordingsParams { + deviceID?: string; + pageNum?: number; + pageSize?: number; + startTime?: number; + endTime?: number; + folderID?: number; +} + +interface ListTodosParams { + completedNum?: number; + pageNum?: number; + pageSize?: number; + deviceID?: string; + fileID?: string; + startTime?: number; + endTime?: number; + category?: number; +} + +export async function listRecordings(params: ListRecordingsParams): Promise +export async function getRecording(id: string, language?: string): Promise +export async function getSummary(id: string): Promise +export async function listTodos(params: ListTodosParams): Promise +export async function getDailyRecall(date?: string): Promise +export async function getWeeklySummary(week?: string): Promise +export async function getUrgentTodos(date?: string): Promise +``` + +--- + +## CLI Subcommand Signatures + +``` +switchbot mindclip recordings [--device ] [--page ] [--size ] [--start ] [--end ] [--folder ] +switchbot mindclip recording [--language ] +switchbot mindclip summary +switchbot mindclip todos [--completed ] [--page ] [--size ] [--device ] [--file ] [--category ] +switchbot mindclip daily [--date ] +switchbot mindclip weekly [--week ] +switchbot mindclip urgent-todos [--date ] +``` + +--- + +## Validation Rules + +| Option | Validator | Valid values | +|---|---|---| +| `--page` | `intArg('--page', {min:1})` | integer ≥ 1 | +| `--size` | `intArg('--size', {min:1, max:100})` | integer 1–100 | +| `--start`, `--end` | `intArg('--start', {min:0})` | integer ≥ 0 (ms timestamp) | +| `--folder` | `intArg('--folder', {min:0})` | integer ≥ 0 | +| `--file` | `intArg('--file', {min:0})` | integer ≥ 0 | +| `--completed` | `enumArg('--completed', ['0','1','2'])` | "0"=all / "1"=incomplete / "2"=completed | +| `--category` | `intArg('--category', {min:0, max:5})` | 0=any, 1=work, 2=life, 3=hobby, 4=holiday, 5=other | +| `--date` | `dateArg('--date')` | `YYYY-MM-DD` format, real date | +| `--week` | `weekArg('--week')` | `YYYY-Www` format, W01–W53 | +| `--language` | `stringArg('--language')` | any string (e.g. "en", "zh") | + +--- + +## Device Status Fields + +| Field | Type | Notes | +|---|---|---| +| `battery` | number | 0–100 | +| `chargingStatus` | number | 0=not charging, 1=charging | +| `recordingStatus` | number | 0=idle, 1=recording | +| `uploadStatus` | number | 0=not uploading, 1=uploading | +| `hasUntransferredFiles` | boolean | — | + +--- + +## Cache Leak Details + +| Cache | Module | Cleared by `auth login`? | Cleared by `config set-token`? | Fix | +|---|---|---|---|---| +| Device list/status | `devices/cache.ts` | ✅ yes | ❌ missing | Add `clearCache()` + `clearStatusCache()` to config | +| Credential priming | `credentials/prime.ts` | ❌ missing | ❌ missing | Export `clearPrimedCredentials()`, call in both | +| Idempotency replay | `lib/idempotency.ts` | ❌ missing | ❌ missing | Call `idempotencyCache.clear()` in both | + +--- + +## Default Value Handling + +When `--date` or `--week` flags are omitted, the query param is simply **not sent** — the server applies its own default: + +- `daily`: most recent record on the server +- `weekly`: most recent record on the server +- `urgent-todos`: yesterday's date on the server + +Client-side default computation is intentionally **not** implemented. From bde518b2e7f897a11798df2dfedf7ff0fb2962f5 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 13 Jun 2026 15:57:12 +0800 Subject: [PATCH 02/57] feat: export clearPrimedCredentials for cache reset on account switch --- src/credentials/prime.ts | 9 +++++++++ tests/credentials/prime.test.ts | 12 ++++++++++++ 2 files changed, 21 insertions(+) diff --git a/src/credentials/prime.ts b/src/credentials/prime.ts index 22bed2b0..b678871d 100644 --- a/src/credentials/prime.ts +++ b/src/credentials/prime.ts @@ -70,3 +70,12 @@ export function getPrimedCredentials(profile: string): CredentialBundle | null { export function __resetPrimedCredentials(): void { cache = null; } + +/** + * Production helper — called by auth and config commands after saving new + * credentials to ensure the 5-second priming cache does not serve stale + * token/secret from the previous account. + */ +export function clearPrimedCredentials(): void { + cache = null; +} diff --git a/tests/credentials/prime.test.ts b/tests/credentials/prime.test.ts index e37dacff..f6d5ccb8 100644 --- a/tests/credentials/prime.test.ts +++ b/tests/credentials/prime.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { primeCredentials, getPrimedCredentials, + clearPrimedCredentials, __resetPrimedCredentials, } from '../../src/credentials/prime.js'; @@ -91,4 +92,15 @@ describe('primeCredentials', () => { await expect(primeCredentials('default')).resolves.toBeUndefined(); expect(getPrimedCredentials('default')).toBeNull(); }); + + it('clearPrimedCredentials() clears the in-memory cache immediately', async () => { + const get = vi.fn().mockResolvedValue({ token: 'T', secret: 'S' }); + selectMock.mockResolvedValue({ name: 'keychain', get } as any); + + await primeCredentials('default'); + expect(getPrimedCredentials('default')).not.toBeNull(); + + clearPrimedCredentials(); + expect(getPrimedCredentials('default')).toBeNull(); + }); }); From 828984c404176a4da71f557efaf17729b1c8e996 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 13 Jun 2026 15:58:48 +0800 Subject: [PATCH 03/57] fix: clear priming and idempotency caches on account credential change --- src/commands/auth.ts | 4 ++++ src/commands/config.ts | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/src/commands/auth.ts b/src/commands/auth.ts index bd0766c2..5cd728b3 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -33,6 +33,8 @@ import { import { browserLogin } from '../auth/browser-login.js'; import type { ExchangeResult } from '../auth/token-exchange.js'; import { verifyCredentials } from '../auth/verify.js'; +import { clearPrimedCredentials } from '../credentials/prime.js'; +import { idempotencyCache } from '../lib/idempotency.js'; function activeProfile(): string { return getActiveProfile() ?? 'default'; @@ -454,6 +456,8 @@ export function registerAuthCommand(program: Command): void { // results from the previous account's cache. clearCache(); clearStatusCache(); + clearPrimedCredentials(); + idempotencyCache.clear(); if (isJsonMode()) { printJson({ diff --git a/src/commands/config.ts b/src/commands/config.ts index 08d28840..2794c823 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -8,6 +8,9 @@ import { stringArg } from '../utils/arg-parsers.js'; import { intArg } from '../utils/arg-parsers.js'; import { saveConfig, showConfig, getConfigSummary, listProfiles, readProfileMeta } from '../config.js'; import { isJsonMode, printJson, exitWithError } from '../utils/output.js'; +import { clearCache, clearStatusCache } from '../devices/cache.js'; +import { clearPrimedCredentials } from '../credentials/prime.js'; +import { idempotencyCache } from '../lib/idempotency.js'; import chalk from 'chalk'; function parseEnvFile(file: string): { token?: string; secret?: string } { @@ -267,6 +270,10 @@ Files are written with mode 0600. Profiles live under ~/.switchbot/profiles/ Date: Sat, 13 Jun 2026 16:01:26 +0800 Subject: [PATCH 04/57] feat: add dateArg and weekArg validators for YYYY-MM-DD and YYYY-Www formats --- src/utils/arg-parsers.ts | 28 +++++++++++++++ tests/utils/arg-parsers.test.ts | 63 ++++++++++++++++++++++++++++++++- 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/src/utils/arg-parsers.ts b/src/utils/arg-parsers.ts index 13284891..5fc5bd00 100644 --- a/src/utils/arg-parsers.ts +++ b/src/utils/arg-parsers.ts @@ -90,3 +90,31 @@ export function enumArg( return value; }; } + +export function dateArg(flagName: string): (value: string) => string { + return (value: string) => { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) { + throw new InvalidArgumentError( + `${flagName} must be in YYYY-MM-DD format (got "${value}")`, + ); + } + const d = new Date(value); + if (isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== value) { + throw new InvalidArgumentError( + `${flagName} must be in YYYY-MM-DD format (got "${value}")`, + ); + } + return value; + }; +} + +export function weekArg(flagName: string): (value: string) => string { + return (value: string) => { + if (!/^\d{4}-W(0[1-9]|[1-4]\d|5[0-3])$/.test(value)) { + throw new InvalidArgumentError( + `${flagName} must be in YYYY-Www format, weeks 01–53 (e.g. 2026-W23 — got "${value}")`, + ); + } + return value; + }; +} diff --git a/tests/utils/arg-parsers.test.ts b/tests/utils/arg-parsers.test.ts index 713bc5bb..f6d8857f 100644 --- a/tests/utils/arg-parsers.test.ts +++ b/tests/utils/arg-parsers.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { InvalidArgumentError } from 'commander'; -import { intArg, durationArg, stringArg, enumArg } from '../../src/utils/arg-parsers.js'; +import { intArg, durationArg, stringArg, enumArg, dateArg, weekArg } from '../../src/utils/arg-parsers.js'; describe('intArg', () => { const parse = intArg('--max'); @@ -117,3 +117,64 @@ describe('enumArg', () => { expect(() => parse('--help')).toThrow(/must be one of/); }); }); + +describe('dateArg', () => { + const parse = dateArg('--date'); + + it('accepts valid YYYY-MM-DD dates', () => { + expect(parse('2026-06-13')).toBe('2026-06-13'); + expect(parse('2026-01-01')).toBe('2026-01-01'); + expect(parse('2026-12-31')).toBe('2026-12-31'); + }); + + it('rejects dates with wrong separator', () => { + expect(() => parse('2026/06/13')).toThrow(InvalidArgumentError); + expect(() => parse('2026/06/13')).toThrow(/YYYY-MM-DD/); + }); + + it('rejects American date format', () => { + expect(() => parse('06-13-2026')).toThrow(/YYYY-MM-DD/); + }); + + it('rejects impossible calendar dates', () => { + expect(() => parse('2026-02-30')).toThrow(/YYYY-MM-DD/); + expect(() => parse('2026-13-01')).toThrow(/YYYY-MM-DD/); + }); + + it('rejects flag-like tokens', () => { + expect(() => parse('--help')).toThrow(/YYYY-MM-DD/); + }); +}); + +describe('weekArg', () => { + const parse = weekArg('--week'); + + it('accepts valid ISO week strings W01-W53', () => { + expect(parse('2026-W23')).toBe('2026-W23'); + expect(parse('2026-W01')).toBe('2026-W01'); + expect(parse('2026-W53')).toBe('2026-W53'); + expect(parse('2026-W09')).toBe('2026-W09'); + }); + + it('rejects W00 (week 0 does not exist)', () => { + expect(() => parse('2026-W00')).toThrow(InvalidArgumentError); + expect(() => parse('2026-W00')).toThrow(/YYYY-Www/); + }); + + it('rejects W54 and above', () => { + expect(() => parse('2026-W54')).toThrow(/YYYY-Www/); + expect(() => parse('2026-W99')).toThrow(/YYYY-Www/); + }); + + it('rejects missing dash between year and W', () => { + expect(() => parse('2026W23')).toThrow(/YYYY-Www/); + }); + + it('rejects 2-digit years', () => { + expect(() => parse('26-W23')).toThrow(/YYYY-Www/); + }); + + it('rejects single-digit week', () => { + expect(() => parse('2026-W5')).toThrow(/YYYY-Www/); + }); +}); From 79fc444e74c882e8bf62013bf9e975958b13be6f Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 13 Jun 2026 16:02:58 +0800 Subject: [PATCH 05/57] feat: add AI MindClip read-only device to catalog with 5 status fields --- src/devices/catalog.ts | 9 ++++++++ tests/devices/mindclip-catalog.test.ts | 30 ++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 tests/devices/mindclip-catalog.test.ts diff --git a/src/devices/catalog.ts b/src/devices/catalog.ts index 84450725..e4c40eb3 100644 --- a/src/devices/catalog.ts +++ b/src/devices/catalog.ts @@ -212,6 +212,15 @@ const rgbOnlyLightControls0To100: CommandSpec[] = [ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ // ---------- Physical devices ---------- + { + type: 'AI MindClip', + category: 'physical', + description: 'AI-powered voice recorder with transcription and meeting summaries.', + role: 'other', + readOnly: true, + commands: [], + statusFields: ['battery', 'chargingStatus', 'recordingStatus', 'uploadStatus', 'hasUntransferredFiles'], + }, { type: 'Bot', category: 'physical', diff --git a/tests/devices/mindclip-catalog.test.ts b/tests/devices/mindclip-catalog.test.ts new file mode 100644 index 00000000..c15f3ccd --- /dev/null +++ b/tests/devices/mindclip-catalog.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest'; +import { DEVICE_CATALOG } from '../../src/devices/catalog.js'; + +describe('AI MindClip catalog entry', () => { + const entry = DEVICE_CATALOG.find((e) => e.type === 'AI MindClip'); + + it('has a catalog entry', () => { + expect(entry).toBeDefined(); + }); + + it('is read-only (no control commands)', () => { + expect(entry?.readOnly).toBe(true); + expect(entry?.commands).toEqual([]); + }); + + it('has the correct category and role', () => { + expect(entry?.category).toBe('physical'); + expect(entry?.role).toBe('other'); + }); + + it('has all 5 status fields in the correct order', () => { + expect(entry?.statusFields).toEqual([ + 'battery', + 'chargingStatus', + 'recordingStatus', + 'uploadStatus', + 'hasUntransferredFiles', + ]); + }); +}); From 42c231f86a05798e90baf48e05988e99f09aef74 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 13 Jun 2026 16:07:25 +0800 Subject: [PATCH 06/57] feat: add mindclip API helper functions for 7 custom endpoints --- src/lib/mindclip.ts | 81 +++++++++++++++ tests/lib/mindclip.test.ts | 196 +++++++++++++++++++++++++++++++++++++ 2 files changed, 277 insertions(+) create mode 100644 src/lib/mindclip.ts create mode 100644 tests/lib/mindclip.test.ts diff --git a/src/lib/mindclip.ts b/src/lib/mindclip.ts new file mode 100644 index 00000000..b52691db --- /dev/null +++ b/src/lib/mindclip.ts @@ -0,0 +1,81 @@ +import { createClient } from '../api/client.js'; + +export interface ListRecordingsParams { + deviceID?: string; + pageNum?: number; + pageSize?: number; + startTime?: number; + endTime?: number; + folderID?: number; +} + +export interface ListTodosParams { + completedNum?: number; + pageNum?: number; + pageSize?: number; + deviceID?: string; + fileID?: string; + startTime?: number; + endTime?: number; + category?: number; +} + +function compact(obj: Record): Record { + return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined)); +} + +export async function listRecordings(params: ListRecordingsParams): Promise { + const c = createClient(); + const res = await c.get<{ body: unknown }>('/v1.1/mindclip/recordings', { + params: compact(params as Record), + }); + return res.data.body; +} + +export async function getRecording(id: string, language?: string): Promise { + const c = createClient(); + const res = await c.get<{ body: unknown }>(`/v1.1/mindclip/recordings/${id}`, { + params: compact({ language }), + }); + return res.data.body; +} + +export async function getSummary(id: string): Promise { + const c = createClient(); + const res = await c.get<{ body: unknown }>(`/v1.1/mindclip/summaries/${id}`, { + params: {}, + }); + return res.data.body; +} + +export async function listTodos(params: ListTodosParams): Promise { + const c = createClient(); + const res = await c.get<{ body: unknown }>('/v1.1/mindclip/todos', { + params: compact(params as Record), + }); + return res.data.body; +} + +export async function getDailyRecall(date?: string): Promise { + const c = createClient(); + const res = await c.get<{ body: unknown }>('/v1.1/mindclip/assistant/daily', { + params: compact({ date }), + }); + return res.data.body; +} + +export async function getWeeklySummary(week?: string): Promise { + const c = createClient(); + const res = await c.get<{ body: unknown }>('/v1.1/mindclip/assistant/weekly', { + params: compact({ week }), + }); + return res.data.body; +} + +export async function getUrgentTodos(date?: string): Promise { + const c = createClient(); + const res = await c.get<{ body: unknown }>('/v1.1/mindclip/assistant/urgent-todos', { + params: compact({ date }), + }); + return res.data.body; +} diff --git a/tests/lib/mindclip.test.ts b/tests/lib/mindclip.test.ts new file mode 100644 index 00000000..82af671c --- /dev/null +++ b/tests/lib/mindclip.test.ts @@ -0,0 +1,196 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + listRecordings, + getRecording, + getSummary, + listTodos, + getDailyRecall, + getWeeklySummary, + getUrgentTodos, +} from '../../src/lib/mindclip.js'; + +const apiMock = vi.hoisted(() => { + const instance = { get: vi.fn() }; + return { createClient: vi.fn(() => instance), __instance: instance }; +}); + +vi.mock('../../src/api/client.js', () => ({ + createClient: apiMock.createClient, +})); + +beforeEach(() => { + apiMock.__instance.get.mockReset(); +}); + +// --------------------------------------------------------------------------- +// listRecordings +// --------------------------------------------------------------------------- +describe('listRecordings', () => { + it('calls GET /v1.1/mindclip/recordings and returns body', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ data: { body: { list: [] } } }); + const result = await listRecordings({}); + expect(apiMock.__instance.get).toHaveBeenCalledWith('/v1.1/mindclip/recordings', { params: {} }); + expect(result).toEqual({ list: [] }); + }); + + it('passes deviceID, page, and size params', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ data: { body: {} } }); + await listRecordings({ deviceID: 'DEV1', pageNum: 2, pageSize: 10 }); + expect(apiMock.__instance.get).toHaveBeenCalledWith('/v1.1/mindclip/recordings', { + params: { deviceID: 'DEV1', pageNum: 2, pageSize: 10 }, + }); + }); + + it('passes startTime, endTime, and folderID params', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ data: { body: {} } }); + await listRecordings({ startTime: 1000, endTime: 2000, folderID: 3 }); + expect(apiMock.__instance.get).toHaveBeenCalledWith('/v1.1/mindclip/recordings', { + params: { startTime: 1000, endTime: 2000, folderID: 3 }, + }); + }); + + it('omits undefined params from the request', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ data: { body: {} } }); + await listRecordings({ pageNum: 1 }); + const params = apiMock.__instance.get.mock.calls[0][1].params; + expect(params).not.toHaveProperty('deviceID'); + expect(params).not.toHaveProperty('startTime'); + expect(params).not.toHaveProperty('folderID'); + }); +}); + +// --------------------------------------------------------------------------- +// getRecording +// --------------------------------------------------------------------------- +describe('getRecording', () => { + it('calls GET /v1.1/mindclip/recordings/{id}', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ data: { body: { id: 'r1' } } }); + const result = await getRecording('r1'); + expect(apiMock.__instance.get).toHaveBeenCalledWith('/v1.1/mindclip/recordings/r1', { params: {} }); + expect(result).toEqual({ id: 'r1' }); + }); + + it('includes language param when provided', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ data: { body: {} } }); + await getRecording('r1', 'zh'); + expect(apiMock.__instance.get).toHaveBeenCalledWith('/v1.1/mindclip/recordings/r1', { + params: { language: 'zh' }, + }); + }); + + it('omits language param when undefined', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ data: { body: {} } }); + await getRecording('r1'); + const params = apiMock.__instance.get.mock.calls[0][1].params; + expect(params).not.toHaveProperty('language'); + }); +}); + +// --------------------------------------------------------------------------- +// getSummary +// --------------------------------------------------------------------------- +describe('getSummary', () => { + it('calls GET /v1.1/mindclip/summaries/{id}', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ data: { body: { summary: 'ok' } } }); + const result = await getSummary('s1'); + expect(apiMock.__instance.get).toHaveBeenCalledWith('/v1.1/mindclip/summaries/s1', { params: {} }); + expect(result).toEqual({ summary: 'ok' }); + }); +}); + +// --------------------------------------------------------------------------- +// listTodos +// --------------------------------------------------------------------------- +describe('listTodos', () => { + it('calls GET /v1.1/mindclip/todos and returns body', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ data: { body: { items: [] } } }); + const result = await listTodos({}); + expect(apiMock.__instance.get).toHaveBeenCalledWith('/v1.1/mindclip/todos', { params: {} }); + expect(result).toEqual({ items: [] }); + }); + + it('passes completedNum and category filters', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ data: { body: {} } }); + await listTodos({ completedNum: 1, category: 2, pageNum: 1, pageSize: 20 }); + expect(apiMock.__instance.get).toHaveBeenCalledWith('/v1.1/mindclip/todos', { + params: { completedNum: 1, category: 2, pageNum: 1, pageSize: 20 }, + }); + }); + + it('passes device and file filters', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ data: { body: {} } }); + await listTodos({ deviceID: 'D1', fileID: 'F1', startTime: 100, endTime: 200 }); + expect(apiMock.__instance.get).toHaveBeenCalledWith('/v1.1/mindclip/todos', { + params: { deviceID: 'D1', fileID: 'F1', startTime: 100, endTime: 200 }, + }); + }); + + it('omits undefined params', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ data: { body: {} } }); + await listTodos({ completedNum: 0 }); + const params = apiMock.__instance.get.mock.calls[0][1].params; + expect(params).not.toHaveProperty('deviceID'); + expect(params).not.toHaveProperty('fileID'); + expect(params).not.toHaveProperty('startTime'); + }); +}); + +// --------------------------------------------------------------------------- +// getDailyRecall +// --------------------------------------------------------------------------- +describe('getDailyRecall', () => { + it('calls GET /v1.1/mindclip/assistant/daily with date param', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ data: { body: {} } }); + await getDailyRecall('2026-06-13'); + expect(apiMock.__instance.get).toHaveBeenCalledWith('/v1.1/mindclip/assistant/daily', { + params: { date: '2026-06-13' }, + }); + }); + + it('omits date param when undefined (server uses its own default)', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ data: { body: {} } }); + await getDailyRecall(); + const params = apiMock.__instance.get.mock.calls[0][1].params; + expect(params).not.toHaveProperty('date'); + }); +}); + +// --------------------------------------------------------------------------- +// getWeeklySummary +// --------------------------------------------------------------------------- +describe('getWeeklySummary', () => { + it('calls GET /v1.1/mindclip/assistant/weekly with week param', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ data: { body: {} } }); + await getWeeklySummary('2026-W23'); + expect(apiMock.__instance.get).toHaveBeenCalledWith('/v1.1/mindclip/assistant/weekly', { + params: { week: '2026-W23' }, + }); + }); + + it('omits week param when undefined', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ data: { body: {} } }); + await getWeeklySummary(); + const params = apiMock.__instance.get.mock.calls[0][1].params; + expect(params).not.toHaveProperty('week'); + }); +}); + +// --------------------------------------------------------------------------- +// getUrgentTodos +// --------------------------------------------------------------------------- +describe('getUrgentTodos', () => { + it('calls GET /v1.1/mindclip/assistant/urgent-todos with date param', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ data: { body: {} } }); + await getUrgentTodos('2026-06-12'); + expect(apiMock.__instance.get).toHaveBeenCalledWith('/v1.1/mindclip/assistant/urgent-todos', { + params: { date: '2026-06-12' }, + }); + }); + + it('omits date param when undefined (server defaults to yesterday)', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ data: { body: {} } }); + await getUrgentTodos(); + const params = apiMock.__instance.get.mock.calls[0][1].params; + expect(params).not.toHaveProperty('date'); + }); +}); From b7c2bf2d890f51e81d75d9f80e0ac85f98ff6da4 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 13 Jun 2026 16:10:03 +0800 Subject: [PATCH 07/57] feat: add mindclip command group with 7 subcommands and option validation --- src/commands/mindclip.ts | 189 ++++++++++++++++++++++++++++++++ tests/commands/mindclip.test.ts | 166 ++++++++++++++++++++++++++++ 2 files changed, 355 insertions(+) create mode 100644 src/commands/mindclip.ts create mode 100644 tests/commands/mindclip.test.ts diff --git a/src/commands/mindclip.ts b/src/commands/mindclip.ts new file mode 100644 index 00000000..2dd6a9e0 --- /dev/null +++ b/src/commands/mindclip.ts @@ -0,0 +1,189 @@ +import { Command } from 'commander'; +import { intArg, enumArg, stringArg, dateArg, weekArg } from '../utils/arg-parsers.js'; +import { printJson } from '../utils/output.js'; +import { + listRecordings, + getRecording, + getSummary, + listTodos, + getDailyRecall, + getWeeklySummary, + getUrgentTodos, +} from '../lib/mindclip.js'; + +export function registerMindclipCommand(program: Command): void { + const mindclip = program + .command('mindclip') + .description('Access AI MindClip recordings, summaries, and to-dos') + .addHelpText( + 'after', + ` +Subcommands: + recordings List recordings across all AI MindClip devices + recording Get a single recording's metadata and transcript + summary Get AI summary for a recording + todos List AI-extracted to-do items + daily Get daily recall summary + weekly Get weekly summary + urgent-todos Get urgent to-dos for a date + +Examples: + switchbot mindclip recordings --device AABBCCDDEEFF --size 10 + switchbot mindclip todos --completed 1 + switchbot mindclip daily --date 2026-06-10 + switchbot mindclip weekly`, + ); + + mindclip + .command('recordings') + .description('List recordings for AI MindClip devices') + .option('--device ', 'Filter by device ID', stringArg('--device')) + .option('--page ', 'Page number (>= 1)', intArg('--page', { min: 1 })) + .option('--size ', 'Results per page (1-100)', intArg('--size', { min: 1, max: 100 })) + .option('--start ', 'Start timestamp in milliseconds', intArg('--start', { min: 0 })) + .option('--end ', 'End timestamp in milliseconds', intArg('--end', { min: 0 })) + .option('--folder ', 'Folder ID', intArg('--folder', { min: 0 })) + .addHelpText( + 'after', + ` +Examples: + switchbot mindclip recordings + switchbot mindclip recordings --device AABBCCDDEEFF --page 2 --size 10`, + ) + .action(async (options) => { + const params = Object.fromEntries( + Object.entries({ + deviceID: options.device, + pageNum: options.page !== undefined ? Number(options.page) : undefined, + pageSize: options.size !== undefined ? Number(options.size) : undefined, + startTime: options.start !== undefined ? Number(options.start) : undefined, + endTime: options.end !== undefined ? Number(options.end) : undefined, + folderID: options.folder !== undefined ? Number(options.folder) : undefined, + }).filter(([, v]) => v !== undefined), + ); + const data = await listRecordings(params); + printJson(data); + }); + + mindclip + .command('recording ') + .description('Get details of a single recording') + .option('--language ', 'Language code for response (e.g. en, zh)', stringArg('--language')) + .addHelpText( + 'after', + ` +Examples: + switchbot mindclip recording 5f3a1c2e9b7d + switchbot mindclip recording 5f3a1c2e9b7d --language en`, + ) + .action(async (id: string, options) => { + const data = await getRecording(id, options.language); + printJson(data); + }); + + mindclip + .command('summary ') + .description('Get AI summary and transcription for a recording') + .addHelpText( + 'after', + ` +Examples: + switchbot mindclip summary 5f3a1c2e9b7d`, + ) + .action(async (id: string) => { + const data = await getSummary(id); + printJson(data); + }); + + mindclip + .command('todos') + .description('List AI-extracted to-do items') + .option( + '--completed ', + 'Filter: 0=all, 1=incomplete, 2=completed [default: 0]', + enumArg('--completed', ['0', '1', '2']), + ) + .option('--page ', 'Page number (>= 1)', intArg('--page', { min: 1 })) + .option('--size ', 'Results per page (1-100)', intArg('--size', { min: 1, max: 100 })) + .option('--device ', 'Filter by device ID', stringArg('--device')) + .option('--file ', 'Filter by recording file ID', stringArg('--file')) + .option('--start ', 'Start timestamp in milliseconds', intArg('--start', { min: 0 })) + .option('--end ', 'End timestamp in milliseconds', intArg('--end', { min: 0 })) + .option( + '--category ', + 'Category: 0=any, 1=work, 2=life, 3=hobby, 4=holiday, 5=other', + intArg('--category', { min: 0, max: 5 }), + ) + .addHelpText( + 'after', + ` +Examples: + switchbot mindclip todos + switchbot mindclip todos --completed 1 --size 5 + switchbot mindclip todos --category 1`, + ) + .action(async (options) => { + const params = Object.fromEntries( + Object.entries({ + completedNum: options.completed !== undefined ? Number(options.completed) : undefined, + pageNum: options.page !== undefined ? Number(options.page) : undefined, + pageSize: options.size !== undefined ? Number(options.size) : undefined, + deviceID: options.device, + fileID: options.file, + startTime: options.start !== undefined ? Number(options.start) : undefined, + endTime: options.end !== undefined ? Number(options.end) : undefined, + category: options.category !== undefined ? Number(options.category) : undefined, + }).filter(([, v]) => v !== undefined), + ); + const data = await listTodos(params); + printJson(data); + }); + + mindclip + .command('daily') + .description('Get daily recall summary (omit --date to get the most recent)') + .option('--date ', 'Date [default: most recent record on server]', dateArg('--date')) + .addHelpText( + 'after', + ` +Examples: + switchbot mindclip daily + switchbot mindclip daily --date 2026-06-10`, + ) + .action(async (options) => { + const data = await getDailyRecall(options.date); + printJson(data); + }); + + mindclip + .command('weekly') + .description('Get weekly summary (omit --week to get the most recent)') + .option('--week ', 'ISO week [default: most recent record on server]', weekArg('--week')) + .addHelpText( + 'after', + ` +Examples: + switchbot mindclip weekly + switchbot mindclip weekly --week 2026-W23`, + ) + .action(async (options) => { + const data = await getWeeklySummary(options.week); + printJson(data); + }); + + mindclip + .command('urgent-todos') + .description("Get urgent to-dos for a date (omit --date to use yesterday's)") + .option('--date ', 'Date [default: yesterday on server]', dateArg('--date')) + .addHelpText( + 'after', + ` +Examples: + switchbot mindclip urgent-todos + switchbot mindclip urgent-todos --date 2026-06-10`, + ) + .action(async (options) => { + const data = await getUrgentTodos(options.date); + printJson(data); + }); +} diff --git a/tests/commands/mindclip.test.ts b/tests/commands/mindclip.test.ts new file mode 100644 index 00000000..a72c083f --- /dev/null +++ b/tests/commands/mindclip.test.ts @@ -0,0 +1,166 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { Command } from 'commander'; +import { registerMindclipCommand } from '../../src/commands/mindclip.js'; + +const mindclipMock = vi.hoisted(() => ({ + listRecordings: vi.fn().mockResolvedValue({}), + getRecording: vi.fn().mockResolvedValue({}), + getSummary: vi.fn().mockResolvedValue({}), + listTodos: vi.fn().mockResolvedValue({}), + getDailyRecall: vi.fn().mockResolvedValue({}), + getWeeklySummary: vi.fn().mockResolvedValue({}), + getUrgentTodos: vi.fn().mockResolvedValue({}), +})); + +vi.mock('../../src/lib/mindclip.js', () => mindclipMock); +vi.mock('../../src/utils/output.js', () => ({ + printJson: vi.fn(), + isJsonMode: vi.fn(() => false), + exitWithError: vi.fn((opts) => { + throw new Error(typeof opts === 'string' ? opts : opts.message); + }), +})); + +function buildProgram(): Command { + const program = new Command().exitOverride(); + registerMindclipCommand(program); + return program; +} + +beforeEach(() => { + Object.values(mindclipMock).forEach((fn) => fn.mockClear()); +}); + +// --------------------------------------------------------------------------- +// recordings validation +// --------------------------------------------------------------------------- +describe('mindclip recordings validation', () => { + it('rejects --page 0 (must be >= 1)', () => { + expect(() => + buildProgram().parse(['node', 'sw', 'mindclip', 'recordings', '--page', '0']), + ).toThrow(); + }); + + it('rejects --size 0', () => { + expect(() => + buildProgram().parse(['node', 'sw', 'mindclip', 'recordings', '--size', '0']), + ).toThrow(); + }); + + it('rejects --size 101', () => { + expect(() => + buildProgram().parse(['node', 'sw', 'mindclip', 'recordings', '--size', '101']), + ).toThrow(); + }); + + it('rejects --start with negative value', () => { + expect(() => + buildProgram().parse(['node', 'sw', 'mindclip', 'recordings', '--start', '-1']), + ).toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// todos validation +// --------------------------------------------------------------------------- +describe('mindclip todos validation', () => { + it('rejects --completed 3 (only 0, 1, 2 allowed)', () => { + expect(() => + buildProgram().parse(['node', 'sw', 'mindclip', 'todos', '--completed', '3']), + ).toThrow(); + }); + + it('rejects --category 6 (max is 5)', () => { + expect(() => + buildProgram().parse(['node', 'sw', 'mindclip', 'todos', '--category', '6']), + ).toThrow(); + }); + + it('rejects --category negative', () => { + expect(() => + buildProgram().parse(['node', 'sw', 'mindclip', 'todos', '--category', '-1']), + ).toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// daily / weekly / urgent-todos validation +// --------------------------------------------------------------------------- +describe('mindclip date validation', () => { + it('rejects --date in MM-DD-YYYY format', () => { + expect(() => + buildProgram().parse(['node', 'sw', 'mindclip', 'daily', '--date', '06-13-2026']), + ).toThrow(); + }); + + it('rejects --date with slashes', () => { + expect(() => + buildProgram().parse(['node', 'sw', 'mindclip', 'urgent-todos', '--date', '2026/06/13']), + ).toThrow(); + }); + + it('rejects --week without dash (2026W23)', () => { + expect(() => + buildProgram().parse(['node', 'sw', 'mindclip', 'weekly', '--week', '2026W23']), + ).toThrow(); + }); + + it('rejects --week W00', () => { + expect(() => + buildProgram().parse(['node', 'sw', 'mindclip', 'weekly', '--week', '2026-W00']), + ).toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// action handler smoke tests (valid args call the right lib function) +// --------------------------------------------------------------------------- +describe('mindclip action handlers', () => { + it('recordings with no options calls listRecordings with empty params', async () => { + await buildProgram().parseAsync(['node', 'sw', 'mindclip', 'recordings']); + expect(mindclipMock.listRecordings).toHaveBeenCalledOnce(); + const params = mindclipMock.listRecordings.mock.calls[0][0]; + expect(Object.keys(params).length).toBe(0); + }); + + it('recording calls getRecording with id', async () => { + await buildProgram().parseAsync(['node', 'sw', 'mindclip', 'recording', 'abc123']); + expect(mindclipMock.getRecording).toHaveBeenCalledWith('abc123', undefined); + }); + + it('recording --language en calls getRecording with language', async () => { + await buildProgram().parseAsync(['node', 'sw', 'mindclip', 'recording', 'abc123', '--language', 'en']); + expect(mindclipMock.getRecording).toHaveBeenCalledWith('abc123', 'en'); + }); + + it('summary calls getSummary', async () => { + await buildProgram().parseAsync(['node', 'sw', 'mindclip', 'summary', 's1']); + expect(mindclipMock.getSummary).toHaveBeenCalledWith('s1'); + }); + + it('todos --completed 1 calls listTodos with completedNum 1', async () => { + await buildProgram().parseAsync(['node', 'sw', 'mindclip', 'todos', '--completed', '1']); + const params = mindclipMock.listTodos.mock.calls[0][0]; + expect(params.completedNum).toBe(1); + }); + + it('daily --date 2026-06-10 calls getDailyRecall with that date', async () => { + await buildProgram().parseAsync(['node', 'sw', 'mindclip', 'daily', '--date', '2026-06-10']); + expect(mindclipMock.getDailyRecall).toHaveBeenCalledWith('2026-06-10'); + }); + + it('daily with no date calls getDailyRecall with undefined', async () => { + await buildProgram().parseAsync(['node', 'sw', 'mindclip', 'daily']); + expect(mindclipMock.getDailyRecall).toHaveBeenCalledWith(undefined); + }); + + it('weekly --week 2026-W23 calls getWeeklySummary', async () => { + await buildProgram().parseAsync(['node', 'sw', 'mindclip', 'weekly', '--week', '2026-W23']); + expect(mindclipMock.getWeeklySummary).toHaveBeenCalledWith('2026-W23'); + }); + + it('urgent-todos with no date calls getUrgentTodos with undefined (server defaults to yesterday)', async () => { + await buildProgram().parseAsync(['node', 'sw', 'mindclip', 'urgent-todos']); + expect(mindclipMock.getUrgentTodos).toHaveBeenCalledWith(undefined); + }); +}); From a3411699da41af6a403410880d1b93a3182fb32f Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 13 Jun 2026 16:14:55 +0800 Subject: [PATCH 08/57] feat: register mindclip command in program-builder and add COMMAND_META entries --- src/commands/capabilities.ts | 7 +++++++ src/program-builder.ts | 4 +++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/commands/capabilities.ts b/src/commands/capabilities.ts index b093384d..f993d05e 100644 --- a/src/commands/capabilities.ts +++ b/src/commands/capabilities.ts @@ -221,6 +221,13 @@ export const COMMAND_META: Record = { 'claude-code setup': ACTION_LOCAL, 'gemini setup': ACTION_LOCAL, 'gemini doctor': READ_LOCAL, + 'mindclip recordings': READ_REMOTE, + 'mindclip recording': READ_REMOTE, + 'mindclip summary': READ_REMOTE, + 'mindclip todos': READ_REMOTE, + 'mindclip daily': READ_REMOTE, + 'mindclip weekly': READ_REMOTE, + 'mindclip urgent-todos': READ_REMOTE, 'uninstall': ACTION_LOCAL, 'upgrade-check': READ_REMOTE, 'webhook setup': ACTION_REMOTE, diff --git a/src/program-builder.ts b/src/program-builder.ts index acf23a6b..719c4988 100644 --- a/src/program-builder.ts +++ b/src/program-builder.ts @@ -33,6 +33,7 @@ import { registerDaemonCommand } from './commands/daemon.js'; import { registerCodexCommand } from './commands/codex.js'; import { registerClaudeCodeCommand } from './commands/claude-code.js'; import { registerGeminiCommand } from './commands/gemini.js'; +import { registerMindclipCommand } from './commands/mindclip.js'; const require = createRequire(import.meta.url); @@ -40,7 +41,7 @@ export const TOP_LEVEL_COMMANDS = [ 'config', 'devices', 'scenes', 'webhook', 'completion', 'mcp', 'quota', 'catalog', 'cache', 'events', 'doctor', 'schema', 'history', 'plan', 'capabilities', 'agent-bootstrap', 'install', 'uninstall', 'status-sync', - 'health', 'upgrade-check', 'daemon', 'reset', 'codex', 'claude-code', 'gemini', + 'health', 'upgrade-check', 'daemon', 'reset', 'codex', 'claude-code', 'gemini', 'mindclip', ] as const; const cacheModeArg = (value: string): string => { @@ -127,6 +128,7 @@ export function buildProgram(): Command { registerCodexCommand(program); registerClaudeCodeCommand(program); registerGeminiCommand(program); + registerMindclipCommand(program); return program; } From 3ddb1e1776b50106fa9a0e55d09bc21e34408a63 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 13 Jun 2026 16:28:39 +0800 Subject: [PATCH 09/57] docs: add mindclip to README and bump version to 3.8.0 --- README.md | 14 +++- .../plans/2026-06-13-ai-mindclip.md | 74 +++++++++---------- package-lock.json | 4 +- package.json | 2 +- 4 files changed, 53 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 3264c063..a321030e 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ Run `switchbot catalog list` to see the full list including aliases and per-comm | **Sensors** _(read-only)_ | Meter · MeterPlus · WoIOSensor · MeterPro · MeterPro(CO2) · WeatherStation · Motion Sensor · Presence Sensor · Contact Sensor · Water Detector · Wallet Finder Card | | **Hubs** _(read-only)_ | Hub · Hub Plus · Hub Mini · Hub 2 · Hub 3 · AI Hub | | **Cameras** _(status only)_ | Indoor Cam · Pan/Tilt Cam · Pan/Tilt Cam 2K · Pan/Tilt Cam Plus 2K · Pan/Tilt Cam Plus 3K · Outdoor Spotlight Cam | -| **Other** | Bot · AI Art Frame · Home Climate Panel · Remote | +| **Other** | Bot · AI Art Frame · AI MindClip · Home Climate Panel · Remote | | **IR virtual remotes** _(via Hub)_ | Air Conditioner · TV · Streamer · Set Top Box · DVD · Speaker · Fan · Light · Others | --- @@ -249,6 +249,18 @@ switchbot scenes list switchbot scenes execute ``` +### `mindclip` + +```bash +switchbot mindclip recordings [--device ] [--page ] [--size ] +switchbot mindclip recording [--language en|zh] +switchbot mindclip summary +switchbot mindclip todos [--completed 0|1|2] [--category 0..5] +switchbot mindclip daily [--date YYYY-MM-DD] +switchbot mindclip weekly [--week YYYY-Www] +switchbot mindclip urgent-todos [--date YYYY-MM-DD] +``` + ### `codex` ```bash diff --git a/docs/superpowers/plans/2026-06-13-ai-mindclip.md b/docs/superpowers/plans/2026-06-13-ai-mindclip.md index 22a98329..87892afc 100644 --- a/docs/superpowers/plans/2026-06-13-ai-mindclip.md +++ b/docs/superpowers/plans/2026-06-13-ai-mindclip.md @@ -36,7 +36,7 @@ - Modify: `src/credentials/prime.ts` (add one export after line 72) - Modify: `tests/credentials/prime.test.ts` (add one `it` block + import) -- [ ] **Step 1: Add the failing test** +- [x] **Step 1: Add the failing test** Open `tests/credentials/prime.test.ts`. Add `clearPrimedCredentials` to the import at line 6, then add this test inside the existing `describe('primeCredentials', ...)` block: @@ -72,7 +72,7 @@ Add this test inside the `describe` block (after line 93): }); ``` -- [ ] **Step 2: Run the failing test** +- [x] **Step 2: Run the failing test** ``` npx vitest run tests/credentials/prime.test.ts @@ -80,7 +80,7 @@ npx vitest run tests/credentials/prime.test.ts Expected: FAIL — `clearPrimedCredentials is not exported` -- [ ] **Step 3: Add the export to prime.ts** +- [x] **Step 3: Add the export to prime.ts** Open `src/credentials/prime.ts`. After the `__resetPrimedCredentials` function (line 70), add: @@ -95,7 +95,7 @@ export function clearPrimedCredentials(): void { } ``` -- [ ] **Step 4: Run the test to verify it passes** +- [x] **Step 4: Run the test to verify it passes** ``` npx vitest run tests/credentials/prime.test.ts @@ -103,7 +103,7 @@ npx vitest run tests/credentials/prime.test.ts Expected: PASS (7 tests) -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add src/credentials/prime.ts tests/credentials/prime.test.ts @@ -118,7 +118,7 @@ git commit -m "feat: export clearPrimedCredentials for cache reset on account sw - Modify: `src/commands/auth.ts` (around line 455–456) - Modify: `src/commands/config.ts` (around line 257) -- [ ] **Step 1: Update auth.ts** +- [x] **Step 1: Update auth.ts** `src/commands/auth.ts` already imports `clearCache, clearStatusCache` at line 28. Add two more imports: @@ -144,7 +144,7 @@ Replace with: idempotencyCache.clear(); ``` -- [ ] **Step 2: Update config.ts** +- [x] **Step 2: Update config.ts** `src/commands/config.ts` currently has no cache-clear imports. Add four imports after the existing imports at the top of the file (after line 10): @@ -176,7 +176,7 @@ Find the `saveConfig(...)` call around line 257. After that call, add: idempotencyCache.clear(); ``` -- [ ] **Step 3: Run the full test suite to confirm no regressions** +- [x] **Step 3: Run the full test suite to confirm no regressions** ``` npx vitest run tests/credentials/ tests/lib/idempotency.test.ts @@ -184,7 +184,7 @@ npx vitest run tests/credentials/ tests/lib/idempotency.test.ts Expected: all pass -- [ ] **Step 4: Commit** +- [x] **Step 4: Commit** ```bash git add src/commands/auth.ts src/commands/config.ts @@ -199,7 +199,7 @@ git commit -m "fix: clear priming and idempotency caches on account credential c - Modify: `src/utils/arg-parsers.ts` (add two exports at the end) - Modify: `tests/utils/arg-parsers.test.ts` (add two describe blocks) -- [ ] **Step 1: Write the failing tests** +- [x] **Step 1: Write the failing tests** Add to the **end** of `tests/utils/arg-parsers.test.ts`: @@ -272,7 +272,7 @@ Update the import at the top of `tests/utils/arg-parsers.test.ts` to include the import { intArg, durationArg, stringArg, enumArg, dateArg, weekArg } from '../../src/utils/arg-parsers.js'; ``` -- [ ] **Step 2: Run the failing tests** +- [x] **Step 2: Run the failing tests** ``` npx vitest run tests/utils/arg-parsers.test.ts @@ -280,7 +280,7 @@ npx vitest run tests/utils/arg-parsers.test.ts Expected: FAIL — `dateArg is not exported`, `weekArg is not exported` -- [ ] **Step 3: Implement dateArg and weekArg in arg-parsers.ts** +- [x] **Step 3: Implement dateArg and weekArg in arg-parsers.ts** Add to the **end** of `src/utils/arg-parsers.ts`: @@ -308,7 +308,7 @@ export function weekArg(flagName: string): (value: string) => string { } ``` -- [ ] **Step 4: Run the tests to verify they pass** +- [x] **Step 4: Run the tests to verify they pass** ``` npx vitest run tests/utils/arg-parsers.test.ts @@ -316,7 +316,7 @@ npx vitest run tests/utils/arg-parsers.test.ts Expected: PASS (all describe blocks) -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add src/utils/arg-parsers.ts tests/utils/arg-parsers.test.ts @@ -331,7 +331,7 @@ git commit -m "feat: add dateArg and weekArg validators for YYYY-MM-DD and YYYY- - Create: `tests/devices/mindclip-catalog.test.ts` - Modify: `src/devices/catalog.ts` (add one entry to the `DEVICE_CATALOG` array) -- [ ] **Step 1: Write the failing test** +- [x] **Step 1: Write the failing test** Create `tests/devices/mindclip-catalog.test.ts`: @@ -368,7 +368,7 @@ describe('AI MindClip catalog entry', () => { }); ``` -- [ ] **Step 2: Run the failing test** +- [x] **Step 2: Run the failing test** ``` npx vitest run tests/devices/mindclip-catalog.test.ts @@ -376,7 +376,7 @@ npx vitest run tests/devices/mindclip-catalog.test.ts Expected: FAIL — `entry` is `undefined` -- [ ] **Step 3: Add AI MindClip to catalog.ts** +- [x] **Step 3: Add AI MindClip to catalog.ts** Open `src/devices/catalog.ts` and find the `DEVICE_CATALOG` array. Locate the entry for `'AI Hub'` or similar read-only device (for reference). Add the following entry in alphabetical order (near the top of the array or with other `A` entries): @@ -392,7 +392,7 @@ Open `src/devices/catalog.ts` and find the `DEVICE_CATALOG` array. Locate the en }, ``` -- [ ] **Step 4: Run the test to verify it passes** +- [x] **Step 4: Run the test to verify it passes** ``` npx vitest run tests/devices/mindclip-catalog.test.ts @@ -400,7 +400,7 @@ npx vitest run tests/devices/mindclip-catalog.test.ts Expected: PASS (4 tests) -- [ ] **Step 5: Run existing catalog tests to confirm no regressions** +- [x] **Step 5: Run existing catalog tests to confirm no regressions** ``` npx vitest run tests/devices/ @@ -408,7 +408,7 @@ npx vitest run tests/devices/ Expected: all pass -- [ ] **Step 6: Commit** +- [x] **Step 6: Commit** ```bash git add src/devices/catalog.ts tests/devices/mindclip-catalog.test.ts @@ -423,7 +423,7 @@ git commit -m "feat: add AI MindClip read-only device to catalog with 5 status f - Create: `src/lib/mindclip.ts` - Create: `tests/lib/mindclip.test.ts` -- [ ] **Step 1: Write the failing tests** +- [x] **Step 1: Write the failing tests** Create `tests/lib/mindclip.test.ts`: @@ -626,7 +626,7 @@ describe('getUrgentTodos', () => { }); ``` -- [ ] **Step 2: Run the failing tests** +- [x] **Step 2: Run the failing tests** ``` npx vitest run tests/lib/mindclip.test.ts @@ -634,7 +634,7 @@ npx vitest run tests/lib/mindclip.test.ts Expected: FAIL — `Cannot find module '../../src/lib/mindclip.js'` -- [ ] **Step 3: Implement src/lib/mindclip.ts** +- [x] **Step 3: Implement src/lib/mindclip.ts** Create `src/lib/mindclip.ts`: @@ -720,7 +720,7 @@ export async function getUrgentTodos(date?: string): Promise { } ``` -- [ ] **Step 4: Run the tests to verify they pass** +- [x] **Step 4: Run the tests to verify they pass** ``` npx vitest run tests/lib/mindclip.test.ts @@ -728,7 +728,7 @@ npx vitest run tests/lib/mindclip.test.ts Expected: PASS (all describe blocks, ~16 tests) -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add src/lib/mindclip.ts tests/lib/mindclip.test.ts @@ -743,7 +743,7 @@ git commit -m "feat: add MindClip API helper functions for 7 custom endpoints" - Create: `src/commands/mindclip.ts` - Create: `tests/commands/mindclip.test.ts` -- [ ] **Step 1: Write the failing tests** +- [x] **Step 1: Write the failing tests** Create `tests/commands/mindclip.test.ts`: @@ -915,7 +915,7 @@ describe('mindclip action handlers', () => { }); ``` -- [ ] **Step 2: Run the failing tests** +- [x] **Step 2: Run the failing tests** ``` npx vitest run tests/commands/mindclip.test.ts @@ -923,7 +923,7 @@ npx vitest run tests/commands/mindclip.test.ts Expected: FAIL — `Cannot find module '../../src/commands/mindclip.js'` -- [ ] **Step 3: Implement src/commands/mindclip.ts** +- [x] **Step 3: Implement src/commands/mindclip.ts** Create `src/commands/mindclip.ts`: @@ -1126,7 +1126,7 @@ Examples: } ``` -- [ ] **Step 4: Run the tests to verify they pass** +- [x] **Step 4: Run the tests to verify they pass** ``` npx vitest run tests/commands/mindclip.test.ts @@ -1134,7 +1134,7 @@ npx vitest run tests/commands/mindclip.test.ts Expected: PASS (all describe blocks, ~22 tests) -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add src/commands/mindclip.ts tests/commands/mindclip.test.ts @@ -1148,7 +1148,7 @@ git commit -m "feat: add mindclip command group with 7 subcommands and option va **Files:** - Modify: `src/program-builder.ts` (add import + registration + constant entry) -- [ ] **Step 1: Update program-builder.ts** +- [x] **Step 1: Update program-builder.ts** Add the import after the `registerCodexCommand` import (line 33): @@ -1173,7 +1173,7 @@ Find the `buildProgram` function and add the registration call alongside the oth registerMindclipCommand(program); ``` -- [ ] **Step 2: Verify help output** +- [x] **Step 2: Verify help output** ``` npx ts-node --esm src/main.ts mindclip --help @@ -1196,7 +1196,7 @@ Commands: urgent-todos Get urgent to-dos for a date (omit --date to use yesterday's) ``` -- [ ] **Step 3: Run the full test suite** +- [x] **Step 3: Run the full test suite** ``` npx vitest run @@ -1204,7 +1204,7 @@ npx vitest run Expected: all tests pass, no regressions -- [ ] **Step 4: Commit** +- [x] **Step 4: Commit** ```bash git add src/program-builder.ts @@ -1240,6 +1240,6 @@ git commit -m "feat: register mindclip command group in program builder" ### Type consistency -- `listTodos` receives `fileID` as `string | undefined` (the API field is a string ID). In the command handler, `options.file` is an integer string validated by `intArg('--file', {min:0})`, then converted to a string via `String(options.file)` before passing to `listTodos`. This matches `ListTodosParams.fileID?: string`. -- All numeric options use `Number(options.x)` conversion in action handlers since Commander's `argParser` returns a `string`. -- `compact` in `lib/mindclip.ts` correctly removes `undefined` keys before the request is built. +- `listTodos` 接收 `fileID` 为 `string | undefined`(API 字段是字符串 ID)。命令层使用 `stringArg('--file')` 直接把字符串透传给 `listTodos`,不做整数→字符串转换 —— 这与 spec 中 `ListTodosParams.fileID?: string` 的语义一致。 +- 其他数值型选项在 action handler 中通过 `Number(options.x)` 转换,因为 Commander 的 `argParser` 返回 `string`。 +- `lib/mindclip.ts` 中的 `compact` 在请求构造前正确剔除 `undefined` 字段。 diff --git a/package-lock.json b/package-lock.json index 25bf9d9b..25cfdf5f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@switchbot/openapi-cli", - "version": "3.7.9", + "version": "3.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@switchbot/openapi-cli", - "version": "3.7.9", + "version": "3.8.0", "license": "MIT", "workspaces": [ "packages/*" diff --git a/package.json b/package.json index 91bac94e..a6f209a9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@switchbot/openapi-cli", - "version": "3.7.9", + "version": "3.8.0", "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", From de8d814c1ac4da85791afb4d98e8812b08ae6ea0 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 13 Jun 2026 17:39:53 +0800 Subject: [PATCH 10/57] test: extend MCP tool-list enumeration to 31 with 7 mindclip tools --- src/mcp/tool-profiles.ts | 7 +++++++ tests/commands/mcp.test.ts | 9 ++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/mcp/tool-profiles.ts b/src/mcp/tool-profiles.ts index 9d5a024e..c4c5b4b5 100644 --- a/src/mcp/tool-profiles.ts +++ b/src/mcp/tool-profiles.ts @@ -11,6 +11,13 @@ const CORE_READ = [ 'aggregate_device_history', 'account_overview', 'plan_suggest', + 'mindclip_list_recordings', + 'mindclip_get_recording', + 'mindclip_get_summary', + 'mindclip_list_todos', + 'mindclip_daily_recall', + 'mindclip_weekly_summary', + 'mindclip_urgent_todos', ] as const; const CORE_ACTION = ['send_command', 'run_scene', 'plan_run'] as const; diff --git a/tests/commands/mcp.test.ts b/tests/commands/mcp.test.ts index 3de7ca0e..62f34b7a 100644 --- a/tests/commands/mcp.test.ts +++ b/tests/commands/mcp.test.ts @@ -163,7 +163,7 @@ describe('mcp server', () => { delete process.env.SWITCHBOT_ALLOW_DIRECT_DESTRUCTIVE; }); - it('exposes the twenty-four tools with titles and input schemas', async () => { + it('exposes the thirty-one tools with titles and input schemas', async () => { const { client } = await pair(); const { tools } = await client.listTools(); @@ -179,6 +179,13 @@ describe('mcp server', () => { 'get_device_status', 'list_devices', 'list_scenes', + 'mindclip_daily_recall', + 'mindclip_get_recording', + 'mindclip_get_summary', + 'mindclip_list_recordings', + 'mindclip_list_todos', + 'mindclip_urgent_todos', + 'mindclip_weekly_summary', 'plan_run', 'plan_suggest', 'policy_add_rule', From a2a3f2f82491f53f91d051a58f57d0a10ff90755 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 13 Jun 2026 17:41:40 +0800 Subject: [PATCH 11/57] feat: add mindclip_list_recordings, mindclip_get_recording, mindclip_get_summary MCP tools --- src/commands/mcp.ts | 93 ++++++++++++++++++++++++++++++++++++++ tests/commands/mcp.test.ts | 45 ++++++++++++++++++ 2 files changed, 138 insertions(+) diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index 247f8bd7..0ae5cfb6 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -21,6 +21,15 @@ import { toMcpIrDeviceShape, } from '../lib/devices.js'; import { fetchScenes, executeScene } from '../lib/scenes.js'; +import { + listRecordings, + getRecording, + getSummary, + listTodos, + getDailyRecall, + getWeeklySummary, + getUrgentTodos, +} from '../lib/mindclip.js'; import { findCatalogEntry, deriveSafetyTier, @@ -1134,6 +1143,90 @@ Tool profile: ${profileName} (${allowedTools.size} tools loaded).${profileName ! } ); + // ---- mindclip_list_recordings ------------------------------------------- + if (!skip('mindclip_list_recordings')) + server.registerTool( + 'mindclip_list_recordings', + { + title: 'List AI MindClip recordings', + description: + 'List voice recordings captured by AI MindClip devices. Optional filters: deviceID, time range (startTime/endTime in ms epoch), folderID, plus pagination.', + _meta: { agentSafetyTier: 'read' }, + inputSchema: z.object({ + deviceID: z.string().optional().describe('Filter by SwitchBot device ID'), + pageNum: z.number().int().min(1).optional().describe('Page number (>= 1)'), + pageSize: z.number().int().min(1).max(100).optional().describe('Results per page (1-100)'), + startTime: z.number().int().min(0).optional().describe('Start timestamp in ms since epoch'), + endTime: z.number().int().min(0).optional().describe('End timestamp in ms since epoch'), + folderID: z.number().int().min(0).optional().describe('Filter by folder ID'), + }).strict(), + }, + async (args) => { + try { + const data = await listRecordings(args); + return { + content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], + structuredContent: { data: data as Record }, + }; + } catch (err) { + return mcpError('api', 1, err instanceof Error ? err.message : String(err)); + } + } + ); + + // ---- mindclip_get_recording --------------------------------------------- + if (!skip('mindclip_get_recording')) + server.registerTool( + 'mindclip_get_recording', + { + title: 'Get one AI MindClip recording', + description: + 'Fetch metadata and transcript for a single recording by its ID. Pass an optional language code (e.g. "en", "zh") to override transcription locale.', + _meta: { agentSafetyTier: 'read' }, + inputSchema: z.object({ + id: z.string().min(1).describe('Recording ID (from mindclip_list_recordings)'), + language: z.string().optional().describe('Language code, e.g. "en" or "zh"'), + }).strict(), + }, + async ({ id, language }) => { + try { + const data = await getRecording(id, language); + return { + content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], + structuredContent: { data: data as Record }, + }; + } catch (err) { + return mcpError('api', 1, err instanceof Error ? err.message : String(err)); + } + } + ); + + // ---- mindclip_get_summary ----------------------------------------------- + if (!skip('mindclip_get_summary')) + server.registerTool( + 'mindclip_get_summary', + { + title: 'Get AI summary for a MindClip recording', + description: + 'Fetch the AI-generated summary (key points, action items, transcript highlights) for a recording.', + _meta: { agentSafetyTier: 'read' }, + inputSchema: z.object({ + id: z.string().min(1).describe('Recording ID (from mindclip_list_recordings)'), + }).strict(), + }, + async ({ id }) => { + try { + const data = await getSummary(id); + return { + content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], + structuredContent: { data: data as Record }, + }; + } catch (err) { + return mcpError('api', 1, err instanceof Error ? err.message : String(err)); + } + } + ); + // ---- policy_validate ----------------------------------------------------- if (!skip('policy_validate')) server.registerTool( diff --git a/tests/commands/mcp.test.ts b/tests/commands/mcp.test.ts index 62f34b7a..c8e06f5b 100644 --- a/tests/commands/mcp.test.ts +++ b/tests/commands/mcp.test.ts @@ -583,6 +583,51 @@ describe('mcp server', () => { expect(res.isError).toBe(true); }); + it('mindclip_list_recordings calls /v1.1/mindclip/recordings and returns body', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ data: { body: { list: [{ id: 'r1' }] } } }); + const { client } = await pair(); + const res = await client.callTool({ name: 'mindclip_list_recordings', arguments: {} }); + expect(res.isError).toBeFalsy(); + const parsed = JSON.parse((res.content as Array<{ text: string }>)[0].text); + expect(parsed).toEqual({ list: [{ id: 'r1' }] }); + expect(apiMock.__instance.get).toHaveBeenCalledWith('/v1.1/mindclip/recordings', { + params: {}, + }); + }); + + it('mindclip_list_recordings forwards device, page, size, and time-range params', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ data: { body: {} } }); + const { client } = await pair(); + await client.callTool({ + name: 'mindclip_list_recordings', + arguments: { deviceID: 'AABBCC', pageNum: 2, pageSize: 10, startTime: 1000, endTime: 2000, folderID: 3 }, + }); + expect(apiMock.__instance.get).toHaveBeenCalledWith('/v1.1/mindclip/recordings', { + params: { deviceID: 'AABBCC', pageNum: 2, pageSize: 10, startTime: 1000, endTime: 2000, folderID: 3 }, + }); + }); + + it('mindclip_get_recording calls /v1.1/mindclip/recordings/{id} with optional language', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ data: { body: { id: 'r1' } } }); + const { client } = await pair(); + await client.callTool({ + name: 'mindclip_get_recording', + arguments: { id: 'r1', language: 'en' }, + }); + expect(apiMock.__instance.get).toHaveBeenCalledWith('/v1.1/mindclip/recordings/r1', { + params: { language: 'en' }, + }); + }); + + it('mindclip_get_summary calls /v1.1/mindclip/summaries/{id}', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ data: { body: { summary: 'ok' } } }); + const { client } = await pair(); + const res = await client.callTool({ name: 'mindclip_get_summary', arguments: { id: 's1' } }); + const parsed = JSON.parse((res.content as Array<{ text: string }>)[0].text); + expect(parsed).toEqual({ summary: 'ok' }); + expect(apiMock.__instance.get).toHaveBeenCalledWith('/v1.1/mindclip/summaries/s1', { params: {} }); + }); + it('run_scene POSTs the scene execute endpoint', async () => { apiMock.__instance.post.mockResolvedValueOnce({ data: { statusCode: 100, body: {} } }); const { client } = await pair(); From 09431e7539fb658cae0ca4054b75584abc29af65 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 13 Jun 2026 17:42:42 +0800 Subject: [PATCH 12/57] feat: add mindclip_list_todos MCP tool with 8 optional filters --- src/commands/mcp.ts | 35 ++++++++++++++++++++++++++++++++++ tests/commands/mcp.test.ts | 39 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index 0ae5cfb6..14a8df45 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -1227,6 +1227,41 @@ Tool profile: ${profileName} (${allowedTools.size} tools loaded).${profileName ! } ); + // ---- mindclip_list_todos ------------------------------------------------ + if (!skip('mindclip_list_todos')) + server.registerTool( + 'mindclip_list_todos', + { + title: 'List AI MindClip to-do items', + description: + 'List AI-extracted to-dos pulled from voice recordings. Filters: completedNum (0=all/1=incomplete/2=completed), category (0=any/1=work/2=life/3=hobby/4=holiday/5=other), pagination, deviceID, fileID, time range.', + _meta: { agentSafetyTier: 'read' }, + inputSchema: z.object({ + completedNum: z.number().int().min(0).max(2).optional() + .describe('0=all (default), 1=incomplete, 2=completed'), + category: z.number().int().min(0).max(5).optional() + .describe('0=any, 1=work, 2=life, 3=hobby, 4=holiday, 5=other'), + pageNum: z.number().int().min(1).optional().describe('Page number (>= 1)'), + pageSize: z.number().int().min(1).max(100).optional().describe('Results per page (1-100)'), + deviceID: z.string().optional().describe('Filter by SwitchBot device ID'), + fileID: z.string().optional().describe('Filter by source recording file ID'), + startTime: z.number().int().min(0).optional().describe('Start timestamp in ms since epoch'), + endTime: z.number().int().min(0).optional().describe('End timestamp in ms since epoch'), + }).strict(), + }, + async (args) => { + try { + const data = await listTodos(args); + return { + content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], + structuredContent: { data: data as Record }, + }; + } catch (err) { + return mcpError('api', 1, err instanceof Error ? err.message : String(err)); + } + } + ); + // ---- policy_validate ----------------------------------------------------- if (!skip('policy_validate')) server.registerTool( diff --git a/tests/commands/mcp.test.ts b/tests/commands/mcp.test.ts index c8e06f5b..d45ea194 100644 --- a/tests/commands/mcp.test.ts +++ b/tests/commands/mcp.test.ts @@ -628,6 +628,45 @@ describe('mcp server', () => { expect(apiMock.__instance.get).toHaveBeenCalledWith('/v1.1/mindclip/summaries/s1', { params: {} }); }); + it('mindclip_list_todos calls /v1.1/mindclip/todos and returns body', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ data: { body: { items: [{ id: 't1' }] } } }); + const { client } = await pair(); + const res = await client.callTool({ name: 'mindclip_list_todos', arguments: {} }); + const parsed = JSON.parse((res.content as Array<{ text: string }>)[0].text); + expect(parsed).toEqual({ items: [{ id: 't1' }] }); + expect(apiMock.__instance.get).toHaveBeenCalledWith('/v1.1/mindclip/todos', { params: {} }); + }); + + it('mindclip_list_todos forwards completed/category/device/file/time filters', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ data: { body: {} } }); + const { client } = await pair(); + await client.callTool({ + name: 'mindclip_list_todos', + arguments: { + completedNum: 1, + category: 2, + pageNum: 1, + pageSize: 20, + deviceID: 'D1', + fileID: 'F1', + startTime: 100, + endTime: 200, + }, + }); + expect(apiMock.__instance.get).toHaveBeenCalledWith('/v1.1/mindclip/todos', { + params: { + completedNum: 1, + category: 2, + pageNum: 1, + pageSize: 20, + deviceID: 'D1', + fileID: 'F1', + startTime: 100, + endTime: 200, + }, + }); + }); + it('run_scene POSTs the scene execute endpoint', async () => { apiMock.__instance.post.mockResolvedValueOnce({ data: { statusCode: 100, body: {} } }); const { client } = await pair(); From 8ed9ed75967f5fe0e935892e7a23c59585c48249 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 13 Jun 2026 17:44:15 +0800 Subject: [PATCH 13/57] feat: add mindclip_daily_recall, mindclip_weekly_summary, mindclip_urgent_todos MCP tools --- src/commands/mcp.ts | 81 ++++++++++++++++++++++++++++++++++++++ tests/commands/mcp.test.ts | 33 ++++++++++++++++ 2 files changed, 114 insertions(+) diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index 14a8df45..32afd333 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -1262,6 +1262,87 @@ Tool profile: ${profileName} (${allowedTools.size} tools loaded).${profileName ! } ); + // ---- mindclip_daily_recall ---------------------------------------------- + if (!skip('mindclip_daily_recall')) + server.registerTool( + 'mindclip_daily_recall', + { + title: 'Get the daily recall summary', + description: + "Fetch the AI-curated daily recall (key moments, decisions, action items extracted from the day's recordings). Omit `date` to get the most recent day available on the server.", + _meta: { agentSafetyTier: 'read' }, + inputSchema: z.object({ + date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional() + .describe('Date in YYYY-MM-DD format (e.g. "2026-06-13"). Omit for server default (most recent).'), + }).strict(), + }, + async ({ date }) => { + try { + const data = await getDailyRecall(date); + return { + content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], + structuredContent: { data: data as Record }, + }; + } catch (err) { + return mcpError('api', 1, err instanceof Error ? err.message : String(err)); + } + } + ); + + // ---- mindclip_weekly_summary -------------------------------------------- + if (!skip('mindclip_weekly_summary')) + server.registerTool( + 'mindclip_weekly_summary', + { + title: 'Get the weekly summary', + description: + 'Fetch the AI-curated weekly summary across all recordings in an ISO week. Omit `week` to get the most recent week available on the server.', + _meta: { agentSafetyTier: 'read' }, + inputSchema: z.object({ + week: z.string().regex(/^\d{4}-W(0[1-9]|[1-4]\d|5[0-3])$/).optional() + .describe('ISO week in YYYY-Www format, weeks 01-53 (e.g. "2026-W23"). Omit for server default (most recent).'), + }).strict(), + }, + async ({ week }) => { + try { + const data = await getWeeklySummary(week); + return { + content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], + structuredContent: { data: data as Record }, + }; + } catch (err) { + return mcpError('api', 1, err instanceof Error ? err.message : String(err)); + } + } + ); + + // ---- mindclip_urgent_todos ---------------------------------------------- + if (!skip('mindclip_urgent_todos')) + server.registerTool( + 'mindclip_urgent_todos', + { + title: 'Get urgent to-dos for a date', + description: + "Fetch the AI-curated list of urgent to-dos surfaced for a date (deadlines, follow-ups, time-sensitive actions). Omit `date` and the server defaults to yesterday's items.", + _meta: { agentSafetyTier: 'read' }, + inputSchema: z.object({ + date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional() + .describe('Date in YYYY-MM-DD format. Omit for server default (yesterday).'), + }).strict(), + }, + async ({ date }) => { + try { + const data = await getUrgentTodos(date); + return { + content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], + structuredContent: { data: data as Record }, + }; + } catch (err) { + return mcpError('api', 1, err instanceof Error ? err.message : String(err)); + } + } + ); + // ---- policy_validate ----------------------------------------------------- if (!skip('policy_validate')) server.registerTool( diff --git a/tests/commands/mcp.test.ts b/tests/commands/mcp.test.ts index d45ea194..4afc1cc8 100644 --- a/tests/commands/mcp.test.ts +++ b/tests/commands/mcp.test.ts @@ -667,6 +667,39 @@ describe('mcp server', () => { }); }); + it('mindclip_daily_recall calls /v1.1/mindclip/assistant/daily with date param', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ data: { body: {} } }); + const { client } = await pair(); + await client.callTool({ + name: 'mindclip_daily_recall', + arguments: { date: '2026-06-13' }, + }); + expect(apiMock.__instance.get).toHaveBeenCalledWith('/v1.1/mindclip/assistant/daily', { + params: { date: '2026-06-13' }, + }); + }); + + it('mindclip_weekly_summary calls /v1.1/mindclip/assistant/weekly with week param', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ data: { body: {} } }); + const { client } = await pair(); + await client.callTool({ + name: 'mindclip_weekly_summary', + arguments: { week: '2026-W23' }, + }); + expect(apiMock.__instance.get).toHaveBeenCalledWith('/v1.1/mindclip/assistant/weekly', { + params: { week: '2026-W23' }, + }); + }); + + it('mindclip_urgent_todos calls /v1.1/mindclip/assistant/urgent-todos and omits date when not provided', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ data: { body: {} } }); + const { client } = await pair(); + await client.callTool({ name: 'mindclip_urgent_todos', arguments: {} }); + expect(apiMock.__instance.get).toHaveBeenCalledWith('/v1.1/mindclip/assistant/urgent-todos', { + params: {}, + }); + }); + it('run_scene POSTs the scene execute endpoint', async () => { apiMock.__instance.post.mockResolvedValueOnce({ data: { statusCode: 100, body: {} } }); const { client } = await pair(); From 52ae444e241afa555c56ef7289e50c3a1da521c4 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 13 Jun 2026 17:49:12 +0800 Subject: [PATCH 14/57] docs: bump MCP tool count to 31 and add mindclip authority-chain rows --- README.md | 4 ++-- packages/claude-code-plugin/README.md | 2 +- .../plugins/switchbot/skills/switchbot/SKILL.md | 1 + .../codex-plugin/plugins/switchbot/skills/switchbot/SKILL.md | 1 + packages/codex-plugin/skills/switchbot/SKILL.md | 1 + packages/gemini-extension/GEMINI.md | 1 + packages/gemini-extension/README.md | 4 ++-- packages/gemini-extension/gemini-extension.json | 2 +- 8 files changed, 10 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index a321030e..b2f234d4 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,7 @@ The optional skill package [`@switchbot/claude-code-plugin`](https://www.npmjs.c ## Gemini CLI integration -The Gemini extension is in [`packages/gemini-extension/`](./packages/gemini-extension/) — it provides 24 MCP tools, a GEMINI.md context file, and 23 slash commands. +The Gemini extension is in [`packages/gemini-extension/`](./packages/gemini-extension/) — it provides 31 MCP tools, a GEMINI.md context file, and 23 slash commands. **Recommended — paste into Gemini CLI chat:** @@ -294,7 +294,7 @@ switchbot config list-profiles ### `mcp` ```bash -switchbot mcp serve # stdio MCP server — 24 tools +switchbot mcp serve # stdio MCP server — 31 tools ``` ### `webhook` diff --git a/packages/claude-code-plugin/README.md b/packages/claude-code-plugin/README.md index 8c694186..5d32209e 100644 --- a/packages/claude-code-plugin/README.md +++ b/packages/claude-code-plugin/README.md @@ -1,6 +1,6 @@ # @switchbot/claude-code-plugin -SwitchBot plugin for [Claude Code](https://claude.ai/claude-code) — wires Claude Code to the SwitchBot OpenAPI CLI MCP server, exposing 24 smart-home tools with policy-based safety gates. +SwitchBot plugin for [Claude Code](https://claude.ai/claude-code) — wires Claude Code to the SwitchBot OpenAPI CLI MCP server, exposing 31 smart-home tools with policy-based safety gates. ## Installation diff --git a/packages/claude-code-plugin/plugins/switchbot/skills/switchbot/SKILL.md b/packages/claude-code-plugin/plugins/switchbot/skills/switchbot/SKILL.md index 3a1e3ac7..3a11d30b 100644 --- a/packages/claude-code-plugin/plugins/switchbot/skills/switchbot/SKILL.md +++ b/packages/claude-code-plugin/plugins/switchbot/skills/switchbot/SKILL.md @@ -20,6 +20,7 @@ Drive the user's SwitchBot smart home through the `switchbot` CLI. Always query | What's this device doing right now? | `switchbot devices status --json` | | What can I do with this specific device type? | `switchbot devices describe --json` | | What scenes are configured? | `switchbot scenes list --json` | +| What's on the user's AI MindClip (recordings, todos, daily/weekly summaries)? | `mindclip_list_recordings`, `mindclip_get_summary`, `mindclip_list_todos`, `mindclip_daily_recall`, `mindclip_weekly_summary`, `mindclip_urgent_todos` MCP tools | | What's in the user's `policy.yaml`? | `cat ~/.config/openclaw/switchbot/policy.yaml` | | Is my quota OK? | `switchbot quota status --json` | | Is the setup healthy? | `switchbot doctor --json` | diff --git a/packages/codex-plugin/plugins/switchbot/skills/switchbot/SKILL.md b/packages/codex-plugin/plugins/switchbot/skills/switchbot/SKILL.md index ab084d34..a440bb47 100644 --- a/packages/codex-plugin/plugins/switchbot/skills/switchbot/SKILL.md +++ b/packages/codex-plugin/plugins/switchbot/skills/switchbot/SKILL.md @@ -20,6 +20,7 @@ Drive the user's SwitchBot smart home through the `switchbot` CLI. Always query | What's this device doing right now? | `switchbot devices status --json` | | What can I do with this specific device type? | `switchbot devices describe --json` | | What scenes are configured? | `switchbot scenes list --json` | +| What's on the user's AI MindClip (recordings, todos, daily/weekly summaries)? | `switchbot mindclip recordings/recording/summary/todos/daily/weekly/urgent-todos --json` | | What's in the user's `policy.yaml`? | `cat ~/.config/openclaw/switchbot/policy.yaml` | | Is my quota OK? | `switchbot quota status --json` | | Is the setup healthy? | `switchbot doctor --json` | diff --git a/packages/codex-plugin/skills/switchbot/SKILL.md b/packages/codex-plugin/skills/switchbot/SKILL.md index ab084d34..a440bb47 100644 --- a/packages/codex-plugin/skills/switchbot/SKILL.md +++ b/packages/codex-plugin/skills/switchbot/SKILL.md @@ -20,6 +20,7 @@ Drive the user's SwitchBot smart home through the `switchbot` CLI. Always query | What's this device doing right now? | `switchbot devices status --json` | | What can I do with this specific device type? | `switchbot devices describe --json` | | What scenes are configured? | `switchbot scenes list --json` | +| What's on the user's AI MindClip (recordings, todos, daily/weekly summaries)? | `switchbot mindclip recordings/recording/summary/todos/daily/weekly/urgent-todos --json` | | What's in the user's `policy.yaml`? | `cat ~/.config/openclaw/switchbot/policy.yaml` | | Is my quota OK? | `switchbot quota status --json` | | Is the setup healthy? | `switchbot doctor --json` | diff --git a/packages/gemini-extension/GEMINI.md b/packages/gemini-extension/GEMINI.md index 7e6ccf3d..9ba6c6b9 100644 --- a/packages/gemini-extension/GEMINI.md +++ b/packages/gemini-extension/GEMINI.md @@ -15,6 +15,7 @@ Drive the user's SwitchBot smart home through the `switchbot` CLI and MCP tools. | What's this device doing right now? | `get_device_status` MCP tool | | What can I do with this specific device type? | `describe_device` MCP tool | | What scenes are configured? | `list_scenes` MCP tool | +| What's on the user's AI MindClip (recordings, todos, daily/weekly summaries)? | `mindclip_list_recordings`, `mindclip_get_summary`, `mindclip_list_todos`, `mindclip_daily_recall`, `mindclip_weekly_summary`, `mindclip_urgent_todos` MCP tools | | What's in the user's policy? | `switchbot policy validate --live --json` | | Is my quota OK? | `switchbot quota status --json` | | Is the setup healthy? | `switchbot doctor --json` | diff --git a/packages/gemini-extension/README.md b/packages/gemini-extension/README.md index 430d1542..81c9c7df 100644 --- a/packages/gemini-extension/README.md +++ b/packages/gemini-extension/README.md @@ -1,7 +1,7 @@ # SwitchBot Gemini CLI Extension Gemini CLI native extension for SwitchBot smart-home control through the authoritative -`switchbot` CLI MCP server (24 tools, policy-based safety gates). +`switchbot` CLI MCP server (31 tools, policy-based safety gates). ## Install @@ -36,7 +36,7 @@ This writes the MCP server entry directly to `~/.gemini/settings.json`. ## What the extension provides -- 24 MCP tools for device control, scene execution, automation rules, and diagnostics +- 31 MCP tools for device control, scene execution, automation rules, and diagnostics - `GEMINI.md` context file (auto-loaded) with safety tiers, name resolution, authority chain - 23 slash commands: diff --git a/packages/gemini-extension/gemini-extension.json b/packages/gemini-extension/gemini-extension.json index 213d7194..06fec922 100644 --- a/packages/gemini-extension/gemini-extension.json +++ b/packages/gemini-extension/gemini-extension.json @@ -1,7 +1,7 @@ { "name": "switchbot", "version": "0.1.0", - "description": "Control SwitchBot smart-home devices from Gemini CLI via MCP (24 tools, policy-based safety gates)", + "description": "Control SwitchBot smart-home devices from Gemini CLI via MCP (31 tools, policy-based safety gates)", "contextFileName": "GEMINI.md", "mcpServers": { "switchbot": { From 3db6580d737e89778f218ef7b24a78dd960265b4 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 13 Jun 2026 17:52:37 +0800 Subject: [PATCH 15/57] test: update tool-profile counts to 17/20/31 and add outputSchema to 7 mindclip tools --- src/commands/mcp.ts | 21 +++++++++++++++++++++ tests/mcp/tool-profiles.test.ts | 18 +++++++++--------- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index 32afd333..c0e0f593 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -1160,6 +1160,9 @@ Tool profile: ${profileName} (${allowedTools.size} tools loaded).${profileName ! endTime: z.number().int().min(0).optional().describe('End timestamp in ms since epoch'), folderID: z.number().int().min(0).optional().describe('Filter by folder ID'), }).strict(), + outputSchema: { + data: z.unknown().describe('Recordings page envelope as returned by /v1.1/mindclip/recordings (opaque body)'), + }, }, async (args) => { try { @@ -1187,6 +1190,9 @@ Tool profile: ${profileName} (${allowedTools.size} tools loaded).${profileName ! id: z.string().min(1).describe('Recording ID (from mindclip_list_recordings)'), language: z.string().optional().describe('Language code, e.g. "en" or "zh"'), }).strict(), + outputSchema: { + data: z.unknown().describe('Single recording envelope as returned by /v1.1/mindclip/recordings/{id} (opaque body)'), + }, }, async ({ id, language }) => { try { @@ -1213,6 +1219,9 @@ Tool profile: ${profileName} (${allowedTools.size} tools loaded).${profileName ! inputSchema: z.object({ id: z.string().min(1).describe('Recording ID (from mindclip_list_recordings)'), }).strict(), + outputSchema: { + data: z.unknown().describe('AI summary envelope as returned by /v1.1/mindclip/summaries/{id} (opaque body)'), + }, }, async ({ id }) => { try { @@ -1248,6 +1257,9 @@ Tool profile: ${profileName} (${allowedTools.size} tools loaded).${profileName ! startTime: z.number().int().min(0).optional().describe('Start timestamp in ms since epoch'), endTime: z.number().int().min(0).optional().describe('End timestamp in ms since epoch'), }).strict(), + outputSchema: { + data: z.unknown().describe('Todos page envelope as returned by /v1.1/mindclip/todos (opaque body)'), + }, }, async (args) => { try { @@ -1275,6 +1287,9 @@ Tool profile: ${profileName} (${allowedTools.size} tools loaded).${profileName ! date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional() .describe('Date in YYYY-MM-DD format (e.g. "2026-06-13"). Omit for server default (most recent).'), }).strict(), + outputSchema: { + data: z.unknown().describe('Daily recall envelope as returned by /v1.1/mindclip/assistant/daily (opaque body)'), + }, }, async ({ date }) => { try { @@ -1302,6 +1317,9 @@ Tool profile: ${profileName} (${allowedTools.size} tools loaded).${profileName ! week: z.string().regex(/^\d{4}-W(0[1-9]|[1-4]\d|5[0-3])$/).optional() .describe('ISO week in YYYY-Www format, weeks 01-53 (e.g. "2026-W23"). Omit for server default (most recent).'), }).strict(), + outputSchema: { + data: z.unknown().describe('Weekly summary envelope as returned by /v1.1/mindclip/assistant/weekly (opaque body)'), + }, }, async ({ week }) => { try { @@ -1329,6 +1347,9 @@ Tool profile: ${profileName} (${allowedTools.size} tools loaded).${profileName ! date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional() .describe('Date in YYYY-MM-DD format. Omit for server default (yesterday).'), }).strict(), + outputSchema: { + data: z.unknown().describe('Urgent todos envelope as returned by /v1.1/mindclip/assistant/urgent-todos (opaque body)'), + }, }, async ({ date }) => { try { diff --git a/tests/mcp/tool-profiles.test.ts b/tests/mcp/tool-profiles.test.ts index 2002b09c..918e13de 100644 --- a/tests/mcp/tool-profiles.test.ts +++ b/tests/mcp/tool-profiles.test.ts @@ -4,16 +4,16 @@ import { createSwitchBotMcpServer, listRegisteredTools } from '../../src/command describe('tool-profiles', () => { describe('TOOL_PROFILES sets', () => { - it('readonly has 10 tools (core read only)', () => { - expect(TOOL_PROFILES.readonly.size).toBe(10); + it('readonly has 17 tools (core read only)', () => { + expect(TOOL_PROFILES.readonly.size).toBe(17); }); - it('default has 13 tools (core read + action)', () => { - expect(TOOL_PROFILES.default.size).toBe(13); + it('default has 20 tools (core read + action)', () => { + expect(TOOL_PROFILES.default.size).toBe(20); }); - it('all has 24 tools', () => { - expect(TOOL_PROFILES.all.size).toBe(24); + it('all has 31 tools', () => { + expect(TOOL_PROFILES.all.size).toBe(31); }); it('readonly is a subset of default', () => { @@ -65,9 +65,9 @@ describe('tool-profiles', () => { describe('createSwitchBotMcpServer respects toolProfile', () => { it.each<[ToolProfile, number]>([ - ['readonly', 10], - ['default', 13], - ['all', 24], + ['readonly', 17], + ['default', 20], + ['all', 31], ])('profile "%s" registers %d tools', (profile, expected) => { const server = createSwitchBotMcpServer({ toolProfile: profile }); expect(listRegisteredTools(server)).toHaveLength(expected); From c30d7a038912dae4e2abc585115fc292b3232c47 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 13 Jun 2026 17:56:15 +0800 Subject: [PATCH 16/57] fix: clear in-memory caches in reset command for long-running processes --- src/commands/reset.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/commands/reset.ts b/src/commands/reset.ts index dec567dd..787203f8 100644 --- a/src/commands/reset.ts +++ b/src/commands/reset.ts @@ -9,6 +9,9 @@ import { isDryRun, getConfigPath } from '../utils/flags.js'; import { selectCredentialStore } from '../credentials/keychain.js'; import { listProfiles } from '../config.js'; import { getActiveProfile } from '../lib/request-context.js'; +import { clearCache, clearStatusCache } from '../devices/cache.js'; +import { clearPrimedCredentials } from '../credentials/prime.js'; +import { idempotencyCache } from '../lib/idempotency.js'; const BASE = path.join(os.homedir(), '.switchbot'); @@ -167,6 +170,12 @@ export function registerResetCommand(program: Command): void { results.push({ key: item.key, label: item.label, ...result }); } + // ── In-memory caches (matters for long-running processes: MCP, daemon) ── + clearCache(); + clearStatusCache(); + clearPrimedCredentials(); + idempotencyCache.clear(); + if (isJsonMode()) { const failed = results.filter(r => r.status === 'failed').length; if (failed > 0) { From 652dbc35f065fd180e7a675c5320ec25ffc6f69e Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 13 Jun 2026 17:56:43 +0800 Subject: [PATCH 17/57] fix: wrap mindclip recording and summary action handlers in try/catch --- src/commands/mindclip.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/commands/mindclip.ts b/src/commands/mindclip.ts index 2dd6a9e0..063f5b72 100644 --- a/src/commands/mindclip.ts +++ b/src/commands/mindclip.ts @@ -1,6 +1,6 @@ import { Command } from 'commander'; import { intArg, enumArg, stringArg, dateArg, weekArg } from '../utils/arg-parsers.js'; -import { printJson } from '../utils/output.js'; +import { printJson, handleError } from '../utils/output.js'; import { listRecordings, getRecording, @@ -77,8 +77,12 @@ Examples: switchbot mindclip recording 5f3a1c2e9b7d --language en`, ) .action(async (id: string, options) => { - const data = await getRecording(id, options.language); - printJson(data); + try { + const data = await getRecording(id, options.language); + printJson(data); + } catch (err) { + handleError(err); + } }); mindclip @@ -91,8 +95,12 @@ Examples: switchbot mindclip summary 5f3a1c2e9b7d`, ) .action(async (id: string) => { - const data = await getSummary(id); - printJson(data); + try { + const data = await getSummary(id); + printJson(data); + } catch (err) { + handleError(err); + } }); mindclip From b3cb5df143f354e5c807387bd9f34e9284ad78ae Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 13 Jun 2026 18:00:10 +0800 Subject: [PATCH 18/57] fix: wrap remaining mindclip action handlers (recordings, todos, daily, weekly, urgent-todos) in try/catch --- src/commands/mindclip.ts | 40 ++++++++++++++++++++++++++++++---------- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/src/commands/mindclip.ts b/src/commands/mindclip.ts index 063f5b72..60acde37 100644 --- a/src/commands/mindclip.ts +++ b/src/commands/mindclip.ts @@ -61,8 +61,12 @@ Examples: folderID: options.folder !== undefined ? Number(options.folder) : undefined, }).filter(([, v]) => v !== undefined), ); - const data = await listRecordings(params); - printJson(data); + try { + const data = await listRecordings(params); + printJson(data); + } catch (err) { + handleError(err); + } }); mindclip @@ -143,8 +147,12 @@ Examples: category: options.category !== undefined ? Number(options.category) : undefined, }).filter(([, v]) => v !== undefined), ); - const data = await listTodos(params); - printJson(data); + try { + const data = await listTodos(params); + printJson(data); + } catch (err) { + handleError(err); + } }); mindclip @@ -159,8 +167,12 @@ Examples: switchbot mindclip daily --date 2026-06-10`, ) .action(async (options) => { - const data = await getDailyRecall(options.date); - printJson(data); + try { + const data = await getDailyRecall(options.date); + printJson(data); + } catch (err) { + handleError(err); + } }); mindclip @@ -175,8 +187,12 @@ Examples: switchbot mindclip weekly --week 2026-W23`, ) .action(async (options) => { - const data = await getWeeklySummary(options.week); - printJson(data); + try { + const data = await getWeeklySummary(options.week); + printJson(data); + } catch (err) { + handleError(err); + } }); mindclip @@ -191,7 +207,11 @@ Examples: switchbot mindclip urgent-todos --date 2026-06-10`, ) .action(async (options) => { - const data = await getUrgentTodos(options.date); - printJson(data); + try { + const data = await getUrgentTodos(options.date); + printJson(data); + } catch (err) { + handleError(err); + } }); } From 41ccb155e9f9cac76d37f14454f9b338e6fff936 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 13 Jun 2026 18:05:21 +0800 Subject: [PATCH 19/57] fix: export COMSPEC in pre-push hook to match pre-commit (Git Bash on Windows) --- .githooks/pre-push | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.githooks/pre-push b/.githooks/pre-push index ef6cec72..796dc7a5 100644 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -4,5 +4,12 @@ set -eu REPO_ROOT="$(git rev-parse --show-toplevel)" cd "$REPO_ROOT" +# Windows: Git Bash clears COMSPEC, which causes npm's child_process.spawn to +# receive an undefined shell path and fail with ERR_INVALID_ARG_TYPE. +if [ -z "${COMSPEC:-}" ] && [ -f "/c/Windows/System32/cmd.exe" ]; then + COMSPEC="C:\\Windows\\System32\\cmd.exe" + export COMSPEC +fi + echo "[pre-push] release gate" npm run verify:release-gate From d9fbb333963ad3a8003a45e08d475b7ae07aeb00 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 13 Jun 2026 18:23:01 +0800 Subject: [PATCH 20/57] fix: stop reset masking errors and sync capabilities MCP tool list reset.ts: switch to the pure in-memory resetListCache/resetStatusCache helpers. The previous clearCache/clearStatusCache rerun unlinkSync on files the data-files loop had already attempted to delete; on a permission-denied path that re-throw skipped both the in-memory clear and the result table. Disk deletion now stays the sole responsibility of the data-files loop where errors are reported into results. capabilities.ts: derive MCP_TOOLS from TOOL_PROFILES.all instead of hand-maintaining a separate array. The hardcoded list had drifted to 11 entries while mcp serve registers 31. Add a tool-profiles.test.ts case that asserts the advertised set equals the names registered by createSwitchBotMcpServer({ toolProfile: 'all' }) so any future drift fails CI. --- CHANGELOG.md | 5 +++++ src/commands/capabilities.ts | 18 +++++------------- src/commands/reset.ts | 10 +++++++--- tests/mcp/tool-profiles.test.ts | 10 ++++++++++ 4 files changed, 27 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b650c82a..71218e43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Fixed + +- **`reset` no longer aborts before printing the result summary** — the in-memory cache cleanup (`clearCache` / `clearStatusCache`) was rerunning `unlinkSync` on a file the data-file loop had already attempted to delete. On a permission-denied path that re-throw skipped both the in-memory clear and the result table. The reset command now uses the pure in-memory `resetListCache` / `resetStatusCache` helpers; disk deletion stays the sole responsibility of the data-file loop, where errors are reported into `results`. +- **`capabilities --surface mcp` lists every registered MCP tool** — `MCP_TOOLS` was a hand-maintained array that had drifted: it advertised 11 tools while `mcp serve` registered 31 (7 mindclip, 7 policy/plan, 4 audit/rules, plus 13 others). The list is now derived from the `TOOL_PROFILES.all` single source of truth, and a new test in `tool-profiles.test.ts` asserts the advertised set matches what `createSwitchBotMcpServer({ toolProfile: 'all' })` actually registers. + ## [3.7.9] ### Added diff --git a/src/commands/capabilities.ts b/src/commands/capabilities.ts index f993d05e..17692534 100644 --- a/src/commands/capabilities.ts +++ b/src/commands/capabilities.ts @@ -11,6 +11,7 @@ import { loadCache } from '../devices/cache.js'; import { printJson } from '../utils/output.js'; import { enumArg, stringArg } from '../utils/arg-parsers.js'; import { IDENTITY } from './identity.js'; +import { TOOL_PROFILES } from '../mcp/tool-profiles.js'; /** Collect the distinct catalog safety tiers actually used across the given entries. Sorted. */ function collectSafetyTiersInUse(entries: DeviceCatalogEntry[]): SafetyTier[] { @@ -240,19 +241,10 @@ function metaFor(command: string): CommandMeta | null { return COMMAND_META[command] ?? null; } -const MCP_TOOLS = [ - 'list_devices', - 'get_device_status', - 'send_command', - 'describe_device', - 'list_scenes', - 'run_scene', - 'search_catalog', - 'account_overview', - 'get_device_history', - 'query_device_history', - 'aggregate_device_history', -]; +// Derived from the single source of truth in src/mcp/tool-profiles.ts so that +// `capabilities --surface mcp` never drifts behind the actual MCP server tool +// registration. Sorted for stable output. +export const MCP_TOOLS = [...TOOL_PROFILES.all].sort(); const IDEMPOTENCY_CONTRACT = { flag: '--idempotency-key ', diff --git a/src/commands/reset.ts b/src/commands/reset.ts index 787203f8..748f96c4 100644 --- a/src/commands/reset.ts +++ b/src/commands/reset.ts @@ -9,7 +9,7 @@ import { isDryRun, getConfigPath } from '../utils/flags.js'; import { selectCredentialStore } from '../credentials/keychain.js'; import { listProfiles } from '../config.js'; import { getActiveProfile } from '../lib/request-context.js'; -import { clearCache, clearStatusCache } from '../devices/cache.js'; +import { resetListCache, resetStatusCache } from '../devices/cache.js'; import { clearPrimedCredentials } from '../credentials/prime.js'; import { idempotencyCache } from '../lib/idempotency.js'; @@ -171,8 +171,12 @@ export function registerResetCommand(program: Command): void { } // ── In-memory caches (matters for long-running processes: MCP, daemon) ── - clearCache(); - clearStatusCache(); + // Use the pure in-memory resetters: the data-files loop above has already + // attempted disk deletion and recorded any failures into `results`. Calling + // disk-deleting variants here would re-throw on permission errors and + // abort before the reset summary is printed. + resetListCache(); + resetStatusCache(); clearPrimedCredentials(); idempotencyCache.clear(); diff --git a/tests/mcp/tool-profiles.test.ts b/tests/mcp/tool-profiles.test.ts index 918e13de..76464650 100644 --- a/tests/mcp/tool-profiles.test.ts +++ b/tests/mcp/tool-profiles.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { TOOL_PROFILES, resolveToolProfile, type ToolProfile } from '../../src/mcp/tool-profiles.js'; import { createSwitchBotMcpServer, listRegisteredTools } from '../../src/commands/mcp.js'; +import { MCP_TOOLS } from '../../src/commands/capabilities.js'; describe('tool-profiles', () => { describe('TOOL_PROFILES sets', () => { @@ -88,4 +89,13 @@ describe('tool-profiles', () => { expect(tools).not.toContain('plan_run'); }); }); + + describe('capabilities MCP_TOOLS stays in sync with registered tools', () => { + it('MCP_TOOLS matches the names registered under toolProfile=all', () => { + const server = createSwitchBotMcpServer({ toolProfile: 'all' }); + const registered = [...listRegisteredTools(server)].sort(); + const advertised = [...MCP_TOOLS].sort(); + expect(advertised).toEqual(registered); + }); + }); }); From 666a6ee386507b7390898da7f61818a21b913583 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 13 Jun 2026 18:30:28 +0800 Subject: [PATCH 21/57] chore: patch bump three plugins to publish MindClip-aware skill content Without a version bump, .github/workflows/publish.yml's check_unpublished gate would skip these packages on the next release and the README/SKILL/ GEMINI.md edits made for the MindClip work would never reach end users. - @switchbot/claude-code-plugin 0.1.3 -> 0.1.4 (SKILL.md adds MindClip MCP-tool row; README updated to 31 tools) - @switchbot/codex-plugin 0.1.5 -> 0.1.6 (skills/switchbot/SKILL.md and plugins/switchbot/skills/switchbot/SKILL.md add MindClip CLI-command row) - @switchbot/gemini-extension 0.1.0 -> 0.1.1 (GEMINI.md adds MindClip MCP-tool row; gemini-extension.json description and README updated to 31 tools; gemini-extension.json version bumped in lockstep with package.json) @switchbot/openclaw-skill stays at 0.1.2 (no changes since main). --- package-lock.json | 6 +++--- packages/claude-code-plugin/package.json | 2 +- packages/codex-plugin/package.json | 2 +- packages/gemini-extension/gemini-extension.json | 2 +- packages/gemini-extension/package.json | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index 25cfdf5f..7e802087 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6616,7 +6616,7 @@ }, "packages/claude-code-plugin": { "name": "@switchbot/claude-code-plugin", - "version": "0.1.3", + "version": "0.1.4", "license": "MIT", "bin": { "switchbot-claude-auth": "bin/auth.js" @@ -6635,7 +6635,7 @@ }, "packages/codex-plugin": { "name": "@switchbot/codex-plugin", - "version": "0.1.5", + "version": "0.1.6", "license": "MIT", "bin": { "switchbot-codex-auth": "bin/auth.js", @@ -6655,7 +6655,7 @@ }, "packages/gemini-extension": { "name": "@switchbot/gemini-extension", - "version": "0.1.0", + "version": "0.1.1", "license": "MIT", "engines": { "node": ">=18" diff --git a/packages/claude-code-plugin/package.json b/packages/claude-code-plugin/package.json index 80a87d3f..91838b15 100644 --- a/packages/claude-code-plugin/package.json +++ b/packages/claude-code-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@switchbot/claude-code-plugin", - "version": "0.1.3", + "version": "0.1.4", "type": "module", "description": "SwitchBot Claude Code plugin — wires Claude Code to the SwitchBot CLI MCP server (24 tools, zero Node.js dependencies)", "homepage": "https://github.com/OpenWonderLabs/switchbot-openapi-cli/tree/main/packages/claude-code-plugin", diff --git a/packages/codex-plugin/package.json b/packages/codex-plugin/package.json index 36dbac61..a19ee939 100644 --- a/packages/codex-plugin/package.json +++ b/packages/codex-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@switchbot/codex-plugin", - "version": "0.1.5", + "version": "0.1.6", "type": "module", "description": "SwitchBot Codex plugin — wires Codex to the SwitchBot CLI MCP server (24 tools, zero Node.js dependencies)", "homepage": "https://github.com/OpenWonderLabs/switchbot-openapi-cli/tree/main/packages/codex-plugin", diff --git a/packages/gemini-extension/gemini-extension.json b/packages/gemini-extension/gemini-extension.json index 06fec922..9d638f12 100644 --- a/packages/gemini-extension/gemini-extension.json +++ b/packages/gemini-extension/gemini-extension.json @@ -1,6 +1,6 @@ { "name": "switchbot", - "version": "0.1.0", + "version": "0.1.1", "description": "Control SwitchBot smart-home devices from Gemini CLI via MCP (31 tools, policy-based safety gates)", "contextFileName": "GEMINI.md", "mcpServers": { diff --git a/packages/gemini-extension/package.json b/packages/gemini-extension/package.json index eab8d764..381a5993 100644 --- a/packages/gemini-extension/package.json +++ b/packages/gemini-extension/package.json @@ -1,6 +1,6 @@ { "name": "@switchbot/gemini-extension", - "version": "0.1.0", + "version": "0.1.1", "type": "module", "description": "SwitchBot Gemini CLI extension — wires Gemini CLI to the SwitchBot MCP server (24 tools) via the native Extension system", "homepage": "https://github.com/OpenWonderLabs/switchbot-openapi-cli/tree/main/packages/gemini-extension", From 9458e92792c4d19e52c725159b587a30aaf82197 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 13 Jun 2026 18:34:19 +0800 Subject: [PATCH 22/57] docs: align tool-count copy at "31 tools" (was 24) across all surfaces The MindClip work added 7 MCP tools, bringing the registered total from 24 to 31, but several user-visible strings were missed. This commit sweeps them so npm pages, CLI help, plugin manifests, and the Gemini settings.json entry all agree. - npm package descriptions: claude-code-plugin, codex-plugin, gemini-extension package.json all switch "(24 tools, ...)" -> "(31 ...)" - Claude Code plugin marketplace.json description - Codex plugin .mcp.json (top-level + plugins/switchbot/.mcp.json) - gemini-checks.ts MCP server entry written to ~/.gemini/settings.json - src/commands/mcp.ts: `mcp --help` heading "twenty-four" -> "thirty-one" with the 7 mindclip tools added to the bullet list - src/commands/mcp.ts: `mcp tools --tools ` option help string corrected from "(default 13 / readonly 10 / all 24)" to the actual "(default 20 / readonly 17 / all 31)" reflected in tool-profiles.ts --- .../.claude-plugin/marketplace.json | 2 +- packages/claude-code-plugin/package.json | 2 +- packages/codex-plugin/.mcp.json | 2 +- packages/codex-plugin/package.json | 2 +- packages/codex-plugin/plugins/switchbot/.mcp.json | 2 +- packages/gemini-extension/package.json | 2 +- src/commands/mcp.ts | 13 ++++++++++--- src/install/gemini-checks.ts | 2 +- 8 files changed, 17 insertions(+), 10 deletions(-) diff --git a/packages/claude-code-plugin/.claude-plugin/marketplace.json b/packages/claude-code-plugin/.claude-plugin/marketplace.json index ff6d3578..cbe84c9e 100644 --- a/packages/claude-code-plugin/.claude-plugin/marketplace.json +++ b/packages/claude-code-plugin/.claude-plugin/marketplace.json @@ -1,7 +1,7 @@ { "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", "name": "switchbot", - "description": "SwitchBot smart-home plugin for Claude Code — MCP server with 24 tools for controlling devices and scenes", + "description": "SwitchBot smart-home plugin for Claude Code — MCP server with 31 tools for controlling devices and scenes", "owner": { "name": "OpenWonderLabs", "email": "developer@wondertechlabs.com" diff --git a/packages/claude-code-plugin/package.json b/packages/claude-code-plugin/package.json index 91838b15..a9b83150 100644 --- a/packages/claude-code-plugin/package.json +++ b/packages/claude-code-plugin/package.json @@ -2,7 +2,7 @@ "name": "@switchbot/claude-code-plugin", "version": "0.1.4", "type": "module", - "description": "SwitchBot Claude Code plugin — wires Claude Code to the SwitchBot CLI MCP server (24 tools, zero Node.js dependencies)", + "description": "SwitchBot Claude Code plugin — wires Claude Code to the SwitchBot CLI MCP server (31 tools, zero Node.js dependencies)", "homepage": "https://github.com/OpenWonderLabs/switchbot-openapi-cli/tree/main/packages/claude-code-plugin", "repository": { "type": "git", diff --git a/packages/codex-plugin/.mcp.json b/packages/codex-plugin/.mcp.json index f27b53b9..6217993a 100644 --- a/packages/codex-plugin/.mcp.json +++ b/packages/codex-plugin/.mcp.json @@ -3,7 +3,7 @@ "switchbot": { "command": "switchbot", "args": ["mcp", "serve", "--tools", "all"], - "description": "SwitchBot smart-home MCP server (24 tools, via CLI)" + "description": "SwitchBot smart-home MCP server (31 tools, via CLI)" } } } diff --git a/packages/codex-plugin/package.json b/packages/codex-plugin/package.json index a19ee939..695ce5bc 100644 --- a/packages/codex-plugin/package.json +++ b/packages/codex-plugin/package.json @@ -2,7 +2,7 @@ "name": "@switchbot/codex-plugin", "version": "0.1.6", "type": "module", - "description": "SwitchBot Codex plugin — wires Codex to the SwitchBot CLI MCP server (24 tools, zero Node.js dependencies)", + "description": "SwitchBot Codex plugin — wires Codex to the SwitchBot CLI MCP server (31 tools, zero Node.js dependencies)", "homepage": "https://github.com/OpenWonderLabs/switchbot-openapi-cli/tree/main/packages/codex-plugin", "repository": { "type": "git", diff --git a/packages/codex-plugin/plugins/switchbot/.mcp.json b/packages/codex-plugin/plugins/switchbot/.mcp.json index f27b53b9..6217993a 100644 --- a/packages/codex-plugin/plugins/switchbot/.mcp.json +++ b/packages/codex-plugin/plugins/switchbot/.mcp.json @@ -3,7 +3,7 @@ "switchbot": { "command": "switchbot", "args": ["mcp", "serve", "--tools", "all"], - "description": "SwitchBot smart-home MCP server (24 tools, via CLI)" + "description": "SwitchBot smart-home MCP server (31 tools, via CLI)" } } } diff --git a/packages/gemini-extension/package.json b/packages/gemini-extension/package.json index 381a5993..ba086a2d 100644 --- a/packages/gemini-extension/package.json +++ b/packages/gemini-extension/package.json @@ -2,7 +2,7 @@ "name": "@switchbot/gemini-extension", "version": "0.1.1", "type": "module", - "description": "SwitchBot Gemini CLI extension — wires Gemini CLI to the SwitchBot MCP server (24 tools) via the native Extension system", + "description": "SwitchBot Gemini CLI extension — wires Gemini CLI to the SwitchBot MCP server (31 tools) via the native Extension system", "homepage": "https://github.com/OpenWonderLabs/switchbot-openapi-cli/tree/main/packages/gemini-extension", "repository": { "type": "git", diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index c0e0f593..251a38d8 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -2529,7 +2529,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-four tools: + The MCP server exposes thirty-one 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) @@ -2541,6 +2541,13 @@ export function registerMcpCommand(program: Command): void { - get_device_history fetch raw JSONL history records for a device - query_device_history filter + page history records with field/time predicates - aggregate_device_history compute count/min/max/avg/sum/p50/p95 over history records + - mindclip_list_recordings list AI MindClip voice recordings (filters: device, time range, folder, paging) + - mindclip_get_recording fetch metadata + transcript for one recording + - mindclip_get_summary AI summary (key points, action items, transcript highlights) for a recording + - mindclip_list_todos list AI-extracted to-dos (filters: completion, category, source recording, time range) + - mindclip_daily_recall AI-curated daily recall (key moments, decisions, action items) + - mindclip_weekly_summary AI-curated weekly summary across recordings in an ISO week + - mindclip_urgent_todos AI-curated list of urgent to-dos for a date (deadlines, follow-ups) - policy_validate check policy.yaml against the embedded schema + offline semantics (set live=true to resolve aliases and rule targets against current inventory) - policy_new scaffold a starter policy.yaml (action — confirm first) @@ -2583,7 +2590,7 @@ Inspect locally: mcp .command('tools') .description('Print the registered MCP tools in human or JSON form') - .option('--tools ', 'Tool profile: "default" (13 tools), "readonly" (10), or "all" (24). Lists all when omitted', stringArg('--tools'), 'all') + .option('--tools ', 'Tool profile: "default" (20 tools), "readonly" (17), or "all" (31). Lists all when omitted', stringArg('--tools'), 'all') .action((opts: { tools?: string }) => { try { printMcpToolDirectory(resolveToolProfile(opts.tools)); } catch (e) { handleError(e); } @@ -2592,7 +2599,7 @@ Inspect locally: mcp .command('list-tools') .description('Alias of `mcp tools`') - .option('--tools ', 'Tool profile: "default" (13 tools), "readonly" (10), or "all" (24). Lists all when omitted', stringArg('--tools'), 'all') + .option('--tools ', 'Tool profile: "default" (20 tools), "readonly" (17), or "all" (31). Lists all when omitted', stringArg('--tools'), 'all') .action((opts: { tools?: string }) => { try { printMcpToolDirectory(resolveToolProfile(opts.tools)); } catch (e) { handleError(e); } diff --git a/src/install/gemini-checks.ts b/src/install/gemini-checks.ts index 25b1ba2d..33dd827a 100644 --- a/src/install/gemini-checks.ts +++ b/src/install/gemini-checks.ts @@ -95,7 +95,7 @@ export function registerMcp(): RegisterMcpResult { [MCP_SERVER_NAME]: { command: 'switchbot', args: ['mcp', 'serve', '--tools', 'all'], - description: 'SwitchBot smart-home MCP server (24 tools)', + description: 'SwitchBot smart-home MCP server (31 tools)', }, }; fs.mkdirSync(path.dirname(GEMINI_SETTINGS_PATH), { recursive: true }); From 79c579f988439cfff64203a92ec63ade5c1b40c4 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 13 Jun 2026 19:02:02 +0800 Subject: [PATCH 23/57] refactor(mcp)!: shrink tool footprint 31 -> 25 and default plugins to default profile Three breaking changes that together cut per-session MCP cost: [B] Consolidate 7 mindclip tools into 3 - mindclip_recordings (action: list | get | summary) - mindclip_list_todos (unchanged) - mindclip_recall (period: daily | weekly | urgent_todos) Replaces: mindclip_list_recordings, mindclip_get_recording, mindclip_get_summary, mindclip_daily_recall, mindclip_weekly_summary, mindclip_urgent_todos. Each branch validates its own required params in the handler. [C] Consolidate 3 device-history tools into 1 - device_history (mode: raw | query | aggregate) Replaces: get_device_history, query_device_history, aggregate_device_history. Per-mode required-arg validation in handler. [A] Default plugins to the default tool profile Removed --tools all from packages/claude-code-plugin/plugins/switchbot/.mcp.json, packages/codex-plugin/.mcp.json, packages/codex-plugin/plugins/switchbot/.mcp.json, packages/gemini-extension/gemini-extension.json, src/install/claude-code-checks.ts, and src/install/gemini-checks.ts. Admin tools (policy / audit / rules) are now opt-in via the user adding --tools all. The CLI default for mcp serve is already "default", so removing the arg is a no-op for the CLI. Profile counts after consolidation: readonly 17 -> 11, default 20 -> 14, all 31 -> 25. Tests: - tests/mcp/tool-profiles.test.ts: assert new counts (11/14/25) - tests/commands/mcp.test.ts: rewrite all mindclip + history tool tests to use new names with action/period/mode args; add coverage for the handler-level validation errors (missing id, missing deviceId, missing metrics). - tests/commands/strict-schemas.test.ts: rewrite the strict-key tests against device_history's three modes. - tests/mcp/tool-schema-completeness.test.ts: device_history input schema describes every per-mode field. - tests/mcp/tool-meta.test.ts: device_history is read tier. - tests/commands/capabilities.test.ts: surfaces.mcp.tools includes device_history. - tests/install/gemini-checks.test.ts + packages/gemini-extension/ tests/manifest.test.js: assert the new args without --tools all. Docs: - mcp --help bullet list rewritten with the 25 surviving tools. - --tools help string corrected to (default 14 / readonly 11 / all 25) at all three call sites (serve, tools, list-tools). - README, docs/agent-guide.md, SKILL.md (claude-code), GEMINI.md, and each plugin README updated to use the new tool names and counts. - All package.json / marketplace.json / .mcp.json / gemini-extension.json descriptions reflow as "default 14, --tools all for 25". Migration: MCP clients calling the old tool names must move to the new name and pass the discriminator (action | period | mode). Existing config files that still pass --tools all keep working - the flag is honored, just no longer the recommended default. --- CHANGELOG.md | 12 +- README.md | 4 +- docs/agent-guide.md | 29 +- examples/quickstart/mqtt-tail.service.example | 2 +- .../.claude-plugin/marketplace.json | 2 +- packages/claude-code-plugin/README.md | 10 +- packages/claude-code-plugin/package.json | 2 +- .../plugins/switchbot/.mcp.json | 2 +- .../switchbot/skills/switchbot/SKILL.md | 4 +- packages/codex-plugin/.mcp.json | 4 +- packages/codex-plugin/README.md | 2 +- packages/codex-plugin/package.json | 2 +- .../codex-plugin/plugins/switchbot/.mcp.json | 4 +- packages/gemini-extension/GEMINI.md | 2 +- packages/gemini-extension/README.md | 4 +- .../commands/switchbot/history.toml | 6 +- .../gemini-extension/gemini-extension.json | 4 +- packages/gemini-extension/package.json | 2 +- .../gemini-extension/tests/manifest.test.js | 4 +- src/commands/mcp.ts | 538 +++++++----------- src/install/claude-code-checks.ts | 2 +- src/install/gemini-checks.ts | 4 +- src/mcp/tool-profiles.ts | 12 +- tests/commands/capabilities.test.ts | 9 +- tests/commands/mcp.test.ts | 126 ++-- tests/commands/strict-schemas.test.ts | 13 +- tests/install/gemini-checks.test.ts | 2 +- tests/mcp/tool-meta.test.ts | 4 +- tests/mcp/tool-profiles.test.ts | 18 +- tests/mcp/tool-schema-completeness.test.ts | 12 +- 30 files changed, 403 insertions(+), 438 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71218e43..f4ab9be0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,20 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### BREAKING + +- **MCP tool consolidation: 31 → 25 tools** — the seven MindClip read tools collapse into three (`mindclip_recordings` with `action: "list" | "get" | "summary"`, `mindclip_list_todos` (unchanged), and `mindclip_recall` with `period: "daily" | "weekly" | "urgent_todos"`); the three device-history tools collapse into one (`device_history` with `mode: "raw" | "query" | "aggregate"`). MCP clients that called the old names (`mindclip_list_recordings` / `mindclip_get_recording` / `mindclip_get_summary` / `mindclip_daily_recall` / `mindclip_weekly_summary` / `mindclip_urgent_todos` / `get_device_history` / `query_device_history` / `aggregate_device_history`) must move to the new tool name and pass the discriminator. The CLI commands (`switchbot mindclip recordings/recording/summary/...`, `switchbot history show/range/aggregate`) are unchanged. Rationale: the old layout sent ~10 highly-redundant tool descriptions and JSON schemas in every model session; consolidating cuts the per-session token cost and the agent's tool-picking overhead by ~20%. +- **Plugins switch to the `default` tool profile** — `@switchbot/claude-code-plugin`, `@switchbot/codex-plugin`, and `@switchbot/gemini-extension` now register the MCP server as `switchbot mcp serve` (without `--tools all`). The default profile exposes 14 tools (read + action). To get the 11 admin tools (policy / audit / automation rules), users opt in by adding `--tools all` to their MCP config — the same flag the CLI has always supported. Existing installations keep working: `registerMcp` / `claude mcp add` / `codex plugin` re-registration writes the new args; manual configs need a one-line edit. Rationale: most agents never invoke admin tools, but every session paid for their schemas. + +### Changed + +- **Profile counts**: `readonly` 17 → 11, `default` 20 → 14, `all` 31 → 25. +- **`mcp tools --tools ` help text**, `mcp serve` help bullet list, README/SKILL.md/GEMINI.md tables, and all package descriptions updated to reflect the new counts. + ### Fixed - **`reset` no longer aborts before printing the result summary** — the in-memory cache cleanup (`clearCache` / `clearStatusCache`) was rerunning `unlinkSync` on a file the data-file loop had already attempted to delete. On a permission-denied path that re-throw skipped both the in-memory clear and the result table. The reset command now uses the pure in-memory `resetListCache` / `resetStatusCache` helpers; disk deletion stays the sole responsibility of the data-file loop, where errors are reported into `results`. -- **`capabilities --surface mcp` lists every registered MCP tool** — `MCP_TOOLS` was a hand-maintained array that had drifted: it advertised 11 tools while `mcp serve` registered 31 (7 mindclip, 7 policy/plan, 4 audit/rules, plus 13 others). The list is now derived from the `TOOL_PROFILES.all` single source of truth, and a new test in `tool-profiles.test.ts` asserts the advertised set matches what `createSwitchBotMcpServer({ toolProfile: 'all' })` actually registers. +- **`capabilities --surface mcp` lists every registered MCP tool** — `MCP_TOOLS` was a hand-maintained array that had drifted. The list is now derived from the `TOOL_PROFILES.all` single source of truth, and a new test in `tool-profiles.test.ts` asserts the advertised set matches what `createSwitchBotMcpServer({ toolProfile: 'all' })` actually registers, so any future drift fails CI. ## [3.7.9] diff --git a/README.md b/README.md index b2f234d4..1b5049ca 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,7 @@ The optional skill package [`@switchbot/claude-code-plugin`](https://www.npmjs.c ## Gemini CLI integration -The Gemini extension is in [`packages/gemini-extension/`](./packages/gemini-extension/) — it provides 31 MCP tools, a GEMINI.md context file, and 23 slash commands. +The Gemini extension is in [`packages/gemini-extension/`](./packages/gemini-extension/) — it provides up to 25 MCP tools (14 in the default profile, 25 with `--tools all`), a GEMINI.md context file, and 23 slash commands. **Recommended — paste into Gemini CLI chat:** @@ -294,7 +294,7 @@ switchbot config list-profiles ### `mcp` ```bash -switchbot mcp serve # stdio MCP server — 31 tools +switchbot mcp serve # stdio MCP server — default 14 tools (use --tools all for 25) ``` ### `webhook` diff --git a/docs/agent-guide.md b/docs/agent-guide.md index b8578568..eb9e81c2 100644 --- a/docs/agent-guide.md +++ b/docs/agent-guide.md @@ -88,9 +88,7 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) | `search_catalog` | Look up device type by name/alias | read | | `describe_device` | Catalog-derived capabilities + optional live status | read | | `account_overview` | Cold-start snapshot (devices/scenes/quota/cache/MQTT) | read | -| `get_device_history` | Latest state + ring history from disk | read | -| `query_device_history` | Time-range query over JSONL history | read | -| `aggregate_device_history` | Bucketed statistics over history | read | +| `device_history` | Read locally-persisted history. mode: "raw" (latest + ring) / "query" (time-range JSONL) / "aggregate" (bucketed stats) | read | | `policy_validate` | Validate policy.yaml | read | | `policy_new` | Scaffold a starter policy file | action | | `policy_migrate` | Upgrade policy schema in-place | action | @@ -107,21 +105,30 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) 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. -### `get_device_history` — zero-cost state lookup +### `device_history` — zero-cost state lookup -Reads `~/.switchbot/device-history/.json` written by `events mqtt-tail`. Requires no API call and costs zero quota. +Reads `~/.switchbot/device-history/.json` (mode="raw") or `.jsonl` (mode="query"/"aggregate") written by `events mqtt-tail`. Requires no API call and costs zero quota. ```json -// Without deviceId — list all devices with stored history -{ "tool": "get_device_history" } +// mode=raw, no deviceId — list all devices with stored history +{ "tool": "device_history", "mode": "raw" } // → { "devices": [{ "deviceId": "ABC123", "latest": { "t": "...", "payload": {...} } }] } -// With deviceId — latest + rolling history (default 20, max 100 entries) -{ "tool": "get_device_history", "deviceId": "ABC123", "limit": 5 } +// mode=raw, with deviceId — latest + rolling history (default 20, max 100 entries) +{ "tool": "device_history", "mode": "raw", "deviceId": "ABC123", "limit": 5 } // → { "deviceId": "ABC123", "latest": {...}, "history": [{...}, ...] } + +// mode=query — time-range filtered JSONL records +{ "tool": "device_history", "mode": "query", "deviceId": "ABC123", "since": "1h" } +// → { "deviceId": "ABC123", "count": 42, "records": [{...}, ...] } + +// mode=aggregate — bucketed numeric statistics +{ "tool": "device_history", "mode": "aggregate", "deviceId": "ABC123", + "metrics": ["temperature","humidity"], "since": "24h", "bucket": "1h" } +// → { "deviceId": "ABC123", "buckets": [{ "t": "...", "metrics": { "temperature": {"count":12,"avg":21.4} } }, ...], ... } ``` -**Workflow**: run `switchbot events mqtt-tail` in the background (e.g. with pm2) to keep the history files fresh; then call `get_device_history` from any MCP session without consuming REST quota. +**Workflow**: run `switchbot events mqtt-tail` in the background (e.g. with pm2) to keep the history files fresh; then call `device_history` from any MCP session without consuming REST quota. #### Device-history directory layout @@ -131,7 +138,7 @@ After `events mqtt-tail` runs on a device, `~/.switchbot/device-history/` contai Source of truth for `history range` and `history aggregate`. Rotated at ~50 MB (up to 3 segments). - `.json`: latest 100-entry ring buffer. - Written on every MQTT event. Read by MCP `get_device_history` + Written on every MQTT event. Read by MCP `device_history` (mode="raw") for fast, zero-quota retrieval. - `__control.jsonl`: MQTT connection lifecycle events (heartbeat, connect, disconnect). Not a device log; used for diagnostics. diff --git a/examples/quickstart/mqtt-tail.service.example b/examples/quickstart/mqtt-tail.service.example index 75d9e86e..eb6da88d 100644 --- a/examples/quickstart/mqtt-tail.service.example +++ b/examples/quickstart/mqtt-tail.service.example @@ -4,7 +4,7 @@ # # Keeps the MQTT subscriber alive in the background so device shadow # updates land in a JSONL stream even when your shell is closed. -# Output is consumed by `switchbot mcp` (for the `get_device_history` +# Output is consumed by `switchbot mcp` (for the `device_history` # tool) and by the rules engine. # # Install: diff --git a/packages/claude-code-plugin/.claude-plugin/marketplace.json b/packages/claude-code-plugin/.claude-plugin/marketplace.json index cbe84c9e..bbef9354 100644 --- a/packages/claude-code-plugin/.claude-plugin/marketplace.json +++ b/packages/claude-code-plugin/.claude-plugin/marketplace.json @@ -1,7 +1,7 @@ { "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", "name": "switchbot", - "description": "SwitchBot smart-home plugin for Claude Code — MCP server with 31 tools for controlling devices and scenes", + "description": "SwitchBot smart-home plugin for Claude Code — MCP server with 25 tools for controlling devices and scenes (default profile shows 14)", "owner": { "name": "OpenWonderLabs", "email": "developer@wondertechlabs.com" diff --git a/packages/claude-code-plugin/README.md b/packages/claude-code-plugin/README.md index 5d32209e..680091d8 100644 --- a/packages/claude-code-plugin/README.md +++ b/packages/claude-code-plugin/README.md @@ -1,6 +1,6 @@ # @switchbot/claude-code-plugin -SwitchBot plugin for [Claude Code](https://claude.ai/claude-code) — wires Claude Code to the SwitchBot OpenAPI CLI MCP server, exposing 31 smart-home tools with policy-based safety gates. +SwitchBot plugin for [Claude Code](https://claude.ai/claude-code) — wires Claude Code to the SwitchBot OpenAPI CLI MCP server, exposing up to 25 smart-home tools (14 in the default profile, 25 with `--tools all`) with policy-based safety gates. ## Installation @@ -10,6 +10,12 @@ npm install -g @switchbot/claude-code-plugin Then register the MCP server with Claude Code: +```bash +claude mcp add switchbot -- switchbot mcp serve +``` + +To also expose the admin tools (policy / audit / automation rules), add `--tools all`: + ```bash claude mcp add switchbot -- switchbot mcp serve --tools all ``` @@ -34,7 +40,7 @@ switchbot-claude-auth ## What it does -Registers the `switchbot` MCP server (`switchbot mcp serve --tools all`) with Claude Code. The skill document (`plugins/switchbot/skills/switchbot/SKILL.md`) guides Claude Code in safely controlling devices, reading sensors, running scenes, and respecting policy-based safety tiers. +Registers the `switchbot` MCP server (`switchbot mcp serve` — default profile) with Claude Code. Add `--tools all` to expose the policy/audit/rules tools alongside the core 14. The skill document (`plugins/switchbot/skills/switchbot/SKILL.md`) guides Claude Code in safely controlling devices, reading sensors, running scenes, and respecting policy-based safety tiers. ## Related packages diff --git a/packages/claude-code-plugin/package.json b/packages/claude-code-plugin/package.json index a9b83150..494f4dce 100644 --- a/packages/claude-code-plugin/package.json +++ b/packages/claude-code-plugin/package.json @@ -2,7 +2,7 @@ "name": "@switchbot/claude-code-plugin", "version": "0.1.4", "type": "module", - "description": "SwitchBot Claude Code plugin — wires Claude Code to the SwitchBot CLI MCP server (31 tools, zero Node.js dependencies)", + "description": "SwitchBot Claude Code plugin — wires Claude Code to the SwitchBot CLI MCP server (default 14 tools; `--tools all` for 25)", "homepage": "https://github.com/OpenWonderLabs/switchbot-openapi-cli/tree/main/packages/claude-code-plugin", "repository": { "type": "git", diff --git a/packages/claude-code-plugin/plugins/switchbot/.mcp.json b/packages/claude-code-plugin/plugins/switchbot/.mcp.json index 37025324..798ca008 100644 --- a/packages/claude-code-plugin/plugins/switchbot/.mcp.json +++ b/packages/claude-code-plugin/plugins/switchbot/.mcp.json @@ -2,7 +2,7 @@ "mcpServers": { "switchbot": { "command": "switchbot", - "args": ["mcp", "serve", "--tools", "all"] + "args": ["mcp", "serve"] } } } diff --git a/packages/claude-code-plugin/plugins/switchbot/skills/switchbot/SKILL.md b/packages/claude-code-plugin/plugins/switchbot/skills/switchbot/SKILL.md index 3a11d30b..5cd02f43 100644 --- a/packages/claude-code-plugin/plugins/switchbot/skills/switchbot/SKILL.md +++ b/packages/claude-code-plugin/plugins/switchbot/skills/switchbot/SKILL.md @@ -20,7 +20,7 @@ Drive the user's SwitchBot smart home through the `switchbot` CLI. Always query | What's this device doing right now? | `switchbot devices status --json` | | What can I do with this specific device type? | `switchbot devices describe --json` | | What scenes are configured? | `switchbot scenes list --json` | -| What's on the user's AI MindClip (recordings, todos, daily/weekly summaries)? | `mindclip_list_recordings`, `mindclip_get_summary`, `mindclip_list_todos`, `mindclip_daily_recall`, `mindclip_weekly_summary`, `mindclip_urgent_todos` MCP tools | +| What's on the user's AI MindClip (recordings, todos, daily/weekly summaries)? | `mindclip_recordings` (action: list/get/summary), `mindclip_list_todos`, `mindclip_recall` (period: daily/weekly/urgent_todos) MCP tools | | What's in the user's `policy.yaml`? | `cat ~/.config/openclaw/switchbot/policy.yaml` | | Is my quota OK? | `switchbot quota status --json` | | Is the setup healthy? | `switchbot doctor --json` | @@ -35,7 +35,7 @@ Drive the user's SwitchBot smart home through the `switchbot` CLI. Always query ## Network requirements -Claude Code registers the SwitchBot MCP server via `claude mcp add switchbot -- switchbot mcp serve --tools all` — or via `.mcp.json` in managed environments. No manual setup is required once the MCP server is registered. The MCP server needs outbound HTTPS to `api.switch-bot.com`. If connection errors appear, see `references/claude-code-network.md`. +Claude Code registers the SwitchBot MCP server via `claude mcp add switchbot -- switchbot mcp serve` — or via `.mcp.json` in managed environments. The default profile exposes 14 tools (read + action); add `--tools all` to also expose admin tools (policy, audit, rules — 25 total). No manual setup is required once the MCP server is registered. The MCP server needs outbound HTTPS to `api.switch-bot.com`. If connection errors appear, see `references/claude-code-network.md`. --- diff --git a/packages/codex-plugin/.mcp.json b/packages/codex-plugin/.mcp.json index 6217993a..71c9380b 100644 --- a/packages/codex-plugin/.mcp.json +++ b/packages/codex-plugin/.mcp.json @@ -2,8 +2,8 @@ "mcpServers": { "switchbot": { "command": "switchbot", - "args": ["mcp", "serve", "--tools", "all"], - "description": "SwitchBot smart-home MCP server (31 tools, via CLI)" + "args": ["mcp", "serve"], + "description": "SwitchBot smart-home MCP server (default 14 tools; `--tools all` for 25)" } } } diff --git a/packages/codex-plugin/README.md b/packages/codex-plugin/README.md index 00c0c717..44457f96 100644 --- a/packages/codex-plugin/README.md +++ b/packages/codex-plugin/README.md @@ -6,7 +6,7 @@ Codex plugin for SwitchBot smart-home control through the authoritative ## What it installs - A Codex skill at `skills/switchbot/SKILL.md` -- An MCP server definition that runs `switchbot mcp serve --tools all` +- An MCP server definition that runs `switchbot mcp serve` (default profile, 14 tools; pass `--tools all` to add the policy/audit/rules tools for 25 total) - A best-effort `onInstall` hook that runs non-interactive setup when the CLI is present - Legacy helper binaries: `switchbot-codex-auth` and `switchbot-codex-install` diff --git a/packages/codex-plugin/package.json b/packages/codex-plugin/package.json index 695ce5bc..9fa419ca 100644 --- a/packages/codex-plugin/package.json +++ b/packages/codex-plugin/package.json @@ -2,7 +2,7 @@ "name": "@switchbot/codex-plugin", "version": "0.1.6", "type": "module", - "description": "SwitchBot Codex plugin — wires Codex to the SwitchBot CLI MCP server (31 tools, zero Node.js dependencies)", + "description": "SwitchBot Codex plugin — wires Codex to the SwitchBot CLI MCP server (default 14 tools; `--tools all` for 25)", "homepage": "https://github.com/OpenWonderLabs/switchbot-openapi-cli/tree/main/packages/codex-plugin", "repository": { "type": "git", diff --git a/packages/codex-plugin/plugins/switchbot/.mcp.json b/packages/codex-plugin/plugins/switchbot/.mcp.json index 6217993a..71c9380b 100644 --- a/packages/codex-plugin/plugins/switchbot/.mcp.json +++ b/packages/codex-plugin/plugins/switchbot/.mcp.json @@ -2,8 +2,8 @@ "mcpServers": { "switchbot": { "command": "switchbot", - "args": ["mcp", "serve", "--tools", "all"], - "description": "SwitchBot smart-home MCP server (31 tools, via CLI)" + "args": ["mcp", "serve"], + "description": "SwitchBot smart-home MCP server (default 14 tools; `--tools all` for 25)" } } } diff --git a/packages/gemini-extension/GEMINI.md b/packages/gemini-extension/GEMINI.md index 9ba6c6b9..d56079cd 100644 --- a/packages/gemini-extension/GEMINI.md +++ b/packages/gemini-extension/GEMINI.md @@ -15,7 +15,7 @@ Drive the user's SwitchBot smart home through the `switchbot` CLI and MCP tools. | What's this device doing right now? | `get_device_status` MCP tool | | What can I do with this specific device type? | `describe_device` MCP tool | | What scenes are configured? | `list_scenes` MCP tool | -| What's on the user's AI MindClip (recordings, todos, daily/weekly summaries)? | `mindclip_list_recordings`, `mindclip_get_summary`, `mindclip_list_todos`, `mindclip_daily_recall`, `mindclip_weekly_summary`, `mindclip_urgent_todos` MCP tools | +| What's on the user's AI MindClip (recordings, todos, daily/weekly summaries)? | `mindclip_recordings` (action: list/get/summary), `mindclip_list_todos`, `mindclip_recall` (period: daily/weekly/urgent_todos) MCP tools | | What's in the user's policy? | `switchbot policy validate --live --json` | | Is my quota OK? | `switchbot quota status --json` | | Is the setup healthy? | `switchbot doctor --json` | diff --git a/packages/gemini-extension/README.md b/packages/gemini-extension/README.md index 81c9c7df..b7d272c0 100644 --- a/packages/gemini-extension/README.md +++ b/packages/gemini-extension/README.md @@ -1,7 +1,7 @@ # SwitchBot Gemini CLI Extension Gemini CLI native extension for SwitchBot smart-home control through the authoritative -`switchbot` CLI MCP server (31 tools, policy-based safety gates). +`switchbot` CLI MCP server (default 14 tools, `--tools all` for 25, policy-based safety gates). ## Install @@ -36,7 +36,7 @@ This writes the MCP server entry directly to `~/.gemini/settings.json`. ## What the extension provides -- 31 MCP tools for device control, scene execution, automation rules, and diagnostics +- Up to 25 MCP tools for device control, scene execution, automation rules, and diagnostics (14 in the default profile; the rest are admin tools — policy/audit/rules — gated behind `--tools all`) - `GEMINI.md` context file (auto-loaded) with safety tiers, name resolution, authority chain - 23 slash commands: diff --git a/packages/gemini-extension/commands/switchbot/history.toml b/packages/gemini-extension/commands/switchbot/history.toml index 6573d465..d06c4a09 100644 --- a/packages/gemini-extension/commands/switchbot/history.toml +++ b/packages/gemini-extension/commands/switchbot/history.toml @@ -3,9 +3,9 @@ prompt = """ Show the history and trends for the SwitchBot device the user names. Resolve the device name to a deviceId (alias → exact → prefix → substring → fuzzy). -Use get_device_history to see recent state changes. If the user asks about trends or statistics, -use aggregate_device_history with appropriate metrics (temperature, humidity, power, battery) -and time range (default: last 24h). For specific time queries use query_device_history with +Use device_history (mode="raw") to see recent state changes. If the user asks about trends or statistics, +use device_history (mode="aggregate") with appropriate metrics (temperature, humidity, power, battery) +and time range (default: last 24h). For specific time queries use device_history (mode="query") with the since parameter (e.g. "7d", "1h", "30m"). Present the data as a summary with key stats and notable events. diff --git a/packages/gemini-extension/gemini-extension.json b/packages/gemini-extension/gemini-extension.json index 9d638f12..27adbacb 100644 --- a/packages/gemini-extension/gemini-extension.json +++ b/packages/gemini-extension/gemini-extension.json @@ -1,12 +1,12 @@ { "name": "switchbot", "version": "0.1.1", - "description": "Control SwitchBot smart-home devices from Gemini CLI via MCP (31 tools, policy-based safety gates)", + "description": "Control SwitchBot smart-home devices from Gemini CLI via MCP (default 14 tools; `--tools all` for 25, policy-based safety gates)", "contextFileName": "GEMINI.md", "mcpServers": { "switchbot": { "command": "switchbot", - "args": ["mcp", "serve", "--tools", "all"], + "args": ["mcp", "serve"], "env": { "SWITCHBOT_TOKEN": "${SWITCHBOT_TOKEN}", "SWITCHBOT_SECRET": "${SWITCHBOT_SECRET}" diff --git a/packages/gemini-extension/package.json b/packages/gemini-extension/package.json index ba086a2d..34d230d5 100644 --- a/packages/gemini-extension/package.json +++ b/packages/gemini-extension/package.json @@ -2,7 +2,7 @@ "name": "@switchbot/gemini-extension", "version": "0.1.1", "type": "module", - "description": "SwitchBot Gemini CLI extension — wires Gemini CLI to the SwitchBot MCP server (31 tools) via the native Extension system", + "description": "SwitchBot Gemini CLI extension — wires Gemini CLI to the SwitchBot MCP server (default 14 tools; `--tools all` for 25) via the native Extension system", "homepage": "https://github.com/OpenWonderLabs/switchbot-openapi-cli/tree/main/packages/gemini-extension", "repository": { "type": "git", diff --git a/packages/gemini-extension/tests/manifest.test.js b/packages/gemini-extension/tests/manifest.test.js index 15dce088..8ede1f62 100644 --- a/packages/gemini-extension/tests/manifest.test.js +++ b/packages/gemini-extension/tests/manifest.test.js @@ -22,11 +22,11 @@ describe('gemini-extension.json manifest', () => { assert.equal(manifest.contextFileName, 'GEMINI.md'); }); - it('mcpServers.switchbot uses switchbot mcp serve --tools all', () => { + it('mcpServers.switchbot uses switchbot mcp serve (default profile)', () => { const server = manifest.mcpServers?.switchbot; assert.ok(server, 'mcpServers.switchbot must be defined'); assert.equal(server.command, 'switchbot'); - assert.deepEqual(server.args, ['mcp', 'serve', '--tools', 'all']); + assert.deepEqual(server.args, ['mcp', 'serve']); }); it('mcpServers.switchbot has no unsupported fields', () => { diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index 251a38d8..2cbf0fad 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -368,93 +368,167 @@ Tool profile: ${profileName} (${allowedTools.size} tools loaded).${profileName ! } ); - // ---- get_device_history ---------------------------------------------------- - if (!skip('get_device_history')) + // ---- device_history ---------------------------------------------------- + // Consolidates the previous get_device_history / query_device_history / + // aggregate_device_history trio. The `mode` discriminator selects which + // shape of result is returned. All modes are read-only and zero quota cost + // (data comes from the local ~/.switchbot/device-history/ store). + if (!skip('device_history')) server.registerTool( - 'get_device_history', + 'device_history', { - title: 'Get locally-persisted device state history', + title: 'Local device history (raw / query / aggregate)', description: - 'Return device state history recorded from MQTT events (persisted to ~/.switchbot/device-history/). ' + - 'No API call — zero quota cost. Use when you need recent historical readings or want to avoid a live API call. ' + - 'Omit deviceId to list all devices with stored history.', + 'Read locally-persisted device state history captured from MQTT events. ' + + 'No API call — zero quota cost. Pick `mode`: ' + + '"raw" returns the latest entry plus the most recent N records (or, if deviceId is omitted, a list of devices with stored history); ' + + '"query" returns time-ranged records (since OR from/to) with optional field projection and limit; ' + + '"aggregate" returns bucketed statistics (count/min/max/avg/sum/p50/p95) over numeric metrics.', _meta: { agentSafetyTier: 'read' }, inputSchema: z.object({ - deviceId: z.string().optional().describe('Device MAC address (deviceId). Omit to list all devices with history.'), - limit: z.number().int().min(1).max(100).optional().describe('Max history entries to return (default 20, max 100)'), + mode: z.enum(['raw', 'query', 'aggregate']).describe( + '"raw": latest entry + recent N records (limit, default 20); "query": time-ranged record list; "aggregate": bucketed stats.', + ), + deviceId: z.string().optional().describe( + 'Device MAC address. Required for query/aggregate. For raw, omit to list all devices with stored history.', + ), + // raw mode + limit: z.number().int().min(1).max(10000).optional().describe( + 'raw: max history entries (default 20, max 100). query: max records (default 1000, max 10000).', + ), + // query / aggregate mode (time range) + since: z.string().optional().describe('Relative window ending now, e.g. "30s","15m","1h","7d". Mutually exclusive with from/to.'), + from: z.string().optional().describe('Range start (ISO-8601). Used by query/aggregate.'), + to: z.string().optional().describe('Range end (ISO-8601). Used by query/aggregate.'), + // query mode + fields: z.array(z.string()).optional().describe('query: project these payload fields; omit for full payload.'), + // aggregate mode + metrics: z.array(z.string().min(1)).optional().describe( + 'aggregate (required): one or more numeric payload field names (e.g. ["temperature","humidity"]).', + ), + aggs: z.array(z.enum(ALL_AGG_FNS as unknown as [AggFn, ...AggFn[]])).optional() + .describe('aggregate: aggregation functions per metric (default ["count","avg"]).'), + bucket: z.string().optional().describe('aggregate: bucket width like "5m","1h","1d". Omit for a single bucket spanning the full range.'), + maxBucketSamples: z.number().int().positive().max(MAX_SAMPLE_CAP).optional() + .describe(`aggregate: per-bucket sample cap (default 10000, max ${MAX_SAMPLE_CAP}). partial=true when any bucket was capped.`), }).strict(), outputSchema: { + // raw mode (deviceId set) deviceId: z.string().optional(), latest: z.unknown().optional(), history: z.array(z.unknown()).optional(), + // raw mode (deviceId omitted) devices: z.array(z.object({ deviceId: z.string(), latest: z.unknown() })).optional(), - }, - }, - async ({ deviceId, limit }) => { - if (deviceId) { - const latest = deviceHistoryStore.getLatest(deviceId); - const history = deviceHistoryStore.getHistory(deviceId, limit ?? 20); - const result = { deviceId, latest, history }; - return { - content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], - structuredContent: result, - }; - } - const ids = deviceHistoryStore.listDevices(); - const devices = ids.map((id) => ({ deviceId: id, latest: deviceHistoryStore.getLatest(id) })); - const result = { devices }; - return { - content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], - structuredContent: result, - }; - } - ); - - // ---- query_device_history -------------------------------------------------- - if (!skip('query_device_history')) - server.registerTool( - 'query_device_history', - { - title: 'Query time-ranged device history', - description: - 'Return records from the append-only JSONL history (~/.switchbot/device-history/.jsonl) ' + - 'filtered by a relative duration (since) or absolute ISO-8601 range (from/to). ' + - 'No API call — zero quota cost. Use for trend questions like "how many times did this switch turn on last week".', - _meta: { agentSafetyTier: 'read' }, - inputSchema: z.object({ - deviceId: z.string().describe('Device ID to query'), - since: z.string().optional().describe('Relative window ending now, e.g. "30s", "15m", "1h", "7d". Mutually exclusive with from/to.'), - from: z.string().optional().describe('Range start (ISO-8601).'), - to: z.string().optional().describe('Range end (ISO-8601).'), - fields: z.array(z.string()).optional().describe('Project these payload fields; omit for the full payload.'), - limit: z.number().int().min(1).max(10000).optional().describe('Max records to return (default 1000).'), - }).strict(), - outputSchema: { - deviceId: z.string(), - count: z.number().int(), + // query mode + count: z.number().int().optional(), records: z.array(z.object({ t: z.string(), topic: z.string(), deviceType: z.string().optional(), payload: z.unknown(), - })), + })).optional(), + // aggregate mode + bucket: z.string().optional(), + from: z.string().optional(), + to: z.string().optional(), + metrics: z.array(z.string()).optional(), + aggs: z.array(z.enum(ALL_AGG_FNS as unknown as [AggFn, ...AggFn[]])).optional(), + buckets: z.array(z.object({ + t: z.string(), + metrics: z.record(z.string(), z.object({ + count: z.number().optional(), + min: z.number().optional(), + max: z.number().optional(), + avg: z.number().optional(), + sum: z.number().optional(), + p50: z.number().optional(), + p95: z.number().optional(), + })), + })).optional(), + partial: z.boolean().optional(), + notes: z.array(z.string()).optional(), }, }, - async ({ deviceId, since, from, to, fields, limit }) => { - if (since && (from || to)) { - return mcpError('usage', 2, '--since is mutually exclusive with --from/--to.'); - } - try { - const records = await queryDeviceHistory(deviceId, { since, from, to, fields, limit }); - const result = { deviceId, count: records.length, records }; + async (args) => { + // ---- raw mode ------------------------------------------------------ + if (args.mode === 'raw') { + if (args.deviceId) { + const latest = deviceHistoryStore.getLatest(args.deviceId); + const history = deviceHistoryStore.getHistory(args.deviceId, args.limit ?? 20); + const result = { deviceId: args.deviceId, latest, history }; + return { + content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], + structuredContent: result, + }; + } + const ids = deviceHistoryStore.listDevices(); + const devices = ids.map((id) => ({ deviceId: id, latest: deviceHistoryStore.getLatest(id) })); + const result = { devices }; return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], structuredContent: result, }; - } catch (err) { - const msg = err instanceof Error ? err.message : 'history query failed'; - return mcpError('usage', 2, msg); } + + // ---- query mode ---------------------------------------------------- + if (args.mode === 'query') { + if (!args.deviceId) { + return mcpError('usage', 2, 'device_history: mode="query" requires `deviceId`.'); + } + if (args.since && (args.from || args.to)) { + return mcpError('usage', 2, 'device_history: `since` is mutually exclusive with `from`/`to`.'); + } + try { + const records = await queryDeviceHistory(args.deviceId, { + since: args.since, + from: args.from, + to: args.to, + fields: args.fields, + limit: args.limit, + }); + const result = { deviceId: args.deviceId, count: records.length, records }; + return { + content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], + structuredContent: result, + }; + } catch (err) { + const msg = err instanceof Error ? err.message : 'history query failed'; + return mcpError('usage', 2, msg); + } + } + + // ---- aggregate mode ------------------------------------------------ + if (!args.deviceId) { + return mcpError('usage', 2, 'device_history: mode="aggregate" requires `deviceId`.'); + } + if (!args.metrics || args.metrics.length === 0) { + return mcpError('usage', 2, 'device_history: mode="aggregate" requires at least one entry in `metrics`.'); + } + const opts: AggOptions = { + since: args.since, + from: args.from, + to: args.to, + metrics: args.metrics, + aggs: args.aggs, + bucket: args.bucket, + maxBucketSamples: args.maxBucketSamples, + }; + const res = await aggregateDeviceHistory(args.deviceId, opts); + const structured: Record = { + deviceId: res.deviceId, + from: res.from, + to: res.to, + metrics: res.metrics, + aggs: res.aggs, + buckets: res.buckets, + partial: res.partial, + notes: res.notes, + }; + if (res.bucket !== undefined) structured.bucket = res.bucket; + return { + content: [{ type: 'text', text: JSON.stringify(res, null, 2) }], + structuredContent: structured, + }; } ); @@ -941,110 +1015,6 @@ Tool profile: ${profileName} (${allowedTools.size} tools loaded).${profileName ! } ); - // ---- aggregate_device_history -------------------------------------------- - if (!skip('aggregate_device_history')) - server.registerTool( - 'aggregate_device_history', - { - title: 'Aggregate device history', - description: - 'Bucketed statistics (count/min/max/avg/sum/p50/p95) over JSONL-recorded device history. Read-only; no network calls.', - _meta: { agentSafetyTier: 'read' }, - inputSchema: z - .object({ - deviceId: z.string().min(1).describe('Device ID to aggregate over (must exist in ~/.switchbot/device-history/).'), - since: z - .string() - .optional() - .describe('Relative window ending now, e.g. "30s", "15m", "1h", "7d". Mutually exclusive with from/to.'), - from: z.string().optional().describe('Range start (ISO-8601). Requires `to`.'), - to: z.string().optional().describe('Range end (ISO-8601). Requires `from`.'), - metrics: z - .array(z.string().min(1)) - .min(1) - .describe('One or more numeric payload field names to aggregate (e.g. ["temperature","humidity"]).'), - aggs: z - .array(z.enum(ALL_AGG_FNS as unknown as [AggFn, ...AggFn[]])) - .optional() - .describe('Aggregation functions to apply per metric (default: ["count","avg"]).'), - bucket: z - .string() - .optional() - .describe('Bucket width like "5m", "1h", "1d". Omit for a single bucket spanning the full range.'), - maxBucketSamples: z - .number() - .int() - .positive() - .max(MAX_SAMPLE_CAP) - .optional() - .describe(`Sample cap per bucket to bound memory (default ${10_000}, max ${MAX_SAMPLE_CAP}). partial=true in the result when any bucket was capped.`), - }) - .strict(), - outputSchema: { - deviceId: z.string(), - bucket: z.string().optional().describe('Bucket width echoed back when specified; omitted for single-bucket results.'), - from: z.string().describe('Effective range start (ISO-8601).'), - to: z.string().describe('Effective range end (ISO-8601).'), - metrics: z.array(z.string()).describe('Metrics that were requested.'), - aggs: z - .array(z.enum(ALL_AGG_FNS as unknown as [AggFn, ...AggFn[]])) - .describe('Aggregation functions that were applied.'), - buckets: z - .array( - z.object({ - t: z.string().describe('Bucket start timestamp (ISO-8601).'), - metrics: z - .record( - z.string(), - z - .object({ - count: z.number().optional(), - min: z.number().optional(), - max: z.number().optional(), - avg: z.number().optional(), - sum: z.number().optional(), - p50: z.number().optional(), - p95: z.number().optional(), - }) - .describe('Per-aggregate function result for this metric in this bucket.'), - ) - .describe('Per-metric result keyed by metric name.'), - }), - ) - .describe('Time-ordered buckets; empty when no records match.'), - partial: z.boolean().describe('True if any bucket was sample-capped; retry with a higher maxBucketSamples or a narrower range for exact values.'), - notes: z.array(z.string()).describe('Human-readable notes about the aggregation (e.g. "metric X is non-numeric").'), - }, - }, - async (args) => { - const opts: AggOptions = { - since: args.since, - from: args.from, - to: args.to, - metrics: args.metrics, - aggs: args.aggs, - bucket: args.bucket, - maxBucketSamples: args.maxBucketSamples, - }; - const res = await aggregateDeviceHistory(args.deviceId, opts); - const structured: Record = { - deviceId: res.deviceId, - from: res.from, - to: res.to, - metrics: res.metrics, - aggs: res.aggs, - buckets: res.buckets, - partial: res.partial, - notes: res.notes, - }; - if (res.bucket !== undefined) structured.bucket = res.bucket; - return { - content: [{ type: 'text', text: JSON.stringify(res, null, 2) }], - structuredContent: structured, - }; - }, - ); - // ---- account_overview --------------------------------------------------- if (!skip('account_overview')) server.registerTool( @@ -1143,89 +1113,59 @@ Tool profile: ${profileName} (${allowedTools.size} tools loaded).${profileName ! } ); - // ---- mindclip_list_recordings ------------------------------------------- - if (!skip('mindclip_list_recordings')) + // ---- mindclip_recordings ----------------------------------------------- + // Consolidates the previous mindclip_list_recordings / mindclip_get_recording / + // mindclip_get_summary trio. The `action` discriminator selects the operation; + // each branch validates its own required params at handler time. + if (!skip('mindclip_recordings')) server.registerTool( - 'mindclip_list_recordings', + 'mindclip_recordings', { - title: 'List AI MindClip recordings', + title: 'AI MindClip recordings (list / get / summary)', description: - 'List voice recordings captured by AI MindClip devices. Optional filters: deviceID, time range (startTime/endTime in ms epoch), folderID, plus pagination.', + 'Read recordings captured by AI MindClip devices. Pick `action`: ' + + '"list" returns a paginated page (filters: deviceID, time range, folderID, paging); ' + + '"get" fetches metadata + transcript for one recording (requires `id`; optional `language`); ' + + '"summary" returns the AI-generated summary (key points, action items) for a recording (requires `id`).', _meta: { agentSafetyTier: 'read' }, inputSchema: z.object({ - deviceID: z.string().optional().describe('Filter by SwitchBot device ID'), - pageNum: z.number().int().min(1).optional().describe('Page number (>= 1)'), - pageSize: z.number().int().min(1).max(100).optional().describe('Results per page (1-100)'), - startTime: z.number().int().min(0).optional().describe('Start timestamp in ms since epoch'), - endTime: z.number().int().min(0).optional().describe('End timestamp in ms since epoch'), - folderID: z.number().int().min(0).optional().describe('Filter by folder ID'), + action: z.enum(['list', 'get', 'summary']).describe( + '"list": browse recordings page; "get": fetch one by id; "summary": fetch AI summary by id.', + ), + // get / summary + id: z.string().min(1).optional().describe('Recording ID — required when action="get" or "summary".'), + language: z.string().optional().describe('Language code (e.g. "en", "zh") — only honored when action="get".'), + // list filters + deviceID: z.string().optional().describe('Filter by SwitchBot device ID — list only.'), + pageNum: z.number().int().min(1).optional().describe('Page number (>= 1) — list only.'), + pageSize: z.number().int().min(1).max(100).optional().describe('Results per page (1-100) — list only.'), + startTime: z.number().int().min(0).optional().describe('Start timestamp in ms since epoch — list only.'), + endTime: z.number().int().min(0).optional().describe('End timestamp in ms since epoch — list only.'), + folderID: z.number().int().min(0).optional().describe('Filter by folder ID — list only.'), }).strict(), outputSchema: { - data: z.unknown().describe('Recordings page envelope as returned by /v1.1/mindclip/recordings (opaque body)'), + data: z.unknown().describe('Operation-shaped envelope: list -> recordings page, get -> single recording, summary -> AI summary body.'), }, }, async (args) => { try { - const data = await listRecordings(args); - return { - content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], - structuredContent: { data: data as Record }, - }; - } catch (err) { - return mcpError('api', 1, err instanceof Error ? err.message : String(err)); - } - } - ); - - // ---- mindclip_get_recording --------------------------------------------- - if (!skip('mindclip_get_recording')) - server.registerTool( - 'mindclip_get_recording', - { - title: 'Get one AI MindClip recording', - description: - 'Fetch metadata and transcript for a single recording by its ID. Pass an optional language code (e.g. "en", "zh") to override transcription locale.', - _meta: { agentSafetyTier: 'read' }, - inputSchema: z.object({ - id: z.string().min(1).describe('Recording ID (from mindclip_list_recordings)'), - language: z.string().optional().describe('Language code, e.g. "en" or "zh"'), - }).strict(), - outputSchema: { - data: z.unknown().describe('Single recording envelope as returned by /v1.1/mindclip/recordings/{id} (opaque body)'), - }, - }, - async ({ id, language }) => { - try { - const data = await getRecording(id, language); - return { - content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], - structuredContent: { data: data as Record }, - }; - } catch (err) { - return mcpError('api', 1, err instanceof Error ? err.message : String(err)); - } - } - ); - - // ---- mindclip_get_summary ----------------------------------------------- - if (!skip('mindclip_get_summary')) - server.registerTool( - 'mindclip_get_summary', - { - title: 'Get AI summary for a MindClip recording', - description: - 'Fetch the AI-generated summary (key points, action items, transcript highlights) for a recording.', - _meta: { agentSafetyTier: 'read' }, - inputSchema: z.object({ - id: z.string().min(1).describe('Recording ID (from mindclip_list_recordings)'), - }).strict(), - outputSchema: { - data: z.unknown().describe('AI summary envelope as returned by /v1.1/mindclip/summaries/{id} (opaque body)'), - }, - }, - async ({ id }) => { - try { - const data = await getSummary(id); + let data: unknown; + if (args.action === 'list') { + data = await listRecordings({ + deviceID: args.deviceID, + pageNum: args.pageNum, + pageSize: args.pageSize, + startTime: args.startTime, + endTime: args.endTime, + folderID: args.folderID, + }); + } else if (args.action === 'get') { + if (!args.id) return mcpError('usage', 2, 'mindclip_recordings: action="get" requires `id`.'); + data = await getRecording(args.id, args.language); + } else { + if (!args.id) return mcpError('usage', 2, 'mindclip_recordings: action="summary" requires `id`.'); + data = await getSummary(args.id); + } return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], structuredContent: { data: data as Record }, @@ -1274,86 +1214,46 @@ Tool profile: ${profileName} (${allowedTools.size} tools loaded).${profileName ! } ); - // ---- mindclip_daily_recall ---------------------------------------------- - if (!skip('mindclip_daily_recall')) + // ---- mindclip_recall ---------------------------------------------------- + // Consolidates mindclip_daily_recall / mindclip_weekly_summary / + // mindclip_urgent_todos. The `period` discriminator selects which AI-curated + // assistant view to fetch. + if (!skip('mindclip_recall')) server.registerTool( - 'mindclip_daily_recall', + 'mindclip_recall', { - title: 'Get the daily recall summary', + title: 'AI MindClip recall (daily / weekly / urgent)', description: - "Fetch the AI-curated daily recall (key moments, decisions, action items extracted from the day's recordings). Omit `date` to get the most recent day available on the server.", + 'Fetch AI-curated assistant views over MindClip recordings. Pick `period`: ' + + '"daily" returns the daily recall (key moments, decisions, action items) for a date; ' + + '"weekly" returns the weekly summary across recordings in an ISO week; ' + + '"urgent_todos" returns the AI-surfaced urgent to-dos for a date (deadlines, follow-ups). ' + + 'Daily/urgent_todos accept optional `date` (YYYY-MM-DD); weekly accepts optional `week` (YYYY-Www). ' + + 'Omit the time arg to get the server default (most recent for daily/weekly; yesterday for urgent_todos).', _meta: { agentSafetyTier: 'read' }, inputSchema: z.object({ + period: z.enum(['daily', 'weekly', 'urgent_todos']).describe( + '"daily": daily recall by date; "weekly": weekly summary by ISO week; "urgent_todos": urgent to-dos by date.', + ), date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional() - .describe('Date in YYYY-MM-DD format (e.g. "2026-06-13"). Omit for server default (most recent).'), - }).strict(), - outputSchema: { - data: z.unknown().describe('Daily recall envelope as returned by /v1.1/mindclip/assistant/daily (opaque body)'), - }, - }, - async ({ date }) => { - try { - const data = await getDailyRecall(date); - return { - content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], - structuredContent: { data: data as Record }, - }; - } catch (err) { - return mcpError('api', 1, err instanceof Error ? err.message : String(err)); - } - } - ); - - // ---- mindclip_weekly_summary -------------------------------------------- - if (!skip('mindclip_weekly_summary')) - server.registerTool( - 'mindclip_weekly_summary', - { - title: 'Get the weekly summary', - description: - 'Fetch the AI-curated weekly summary across all recordings in an ISO week. Omit `week` to get the most recent week available on the server.', - _meta: { agentSafetyTier: 'read' }, - inputSchema: z.object({ + .describe('YYYY-MM-DD — used by period="daily" or "urgent_todos"; omit for server default.'), week: z.string().regex(/^\d{4}-W(0[1-9]|[1-4]\d|5[0-3])$/).optional() - .describe('ISO week in YYYY-Www format, weeks 01-53 (e.g. "2026-W23"). Omit for server default (most recent).'), - }).strict(), - outputSchema: { - data: z.unknown().describe('Weekly summary envelope as returned by /v1.1/mindclip/assistant/weekly (opaque body)'), - }, - }, - async ({ week }) => { - try { - const data = await getWeeklySummary(week); - return { - content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], - structuredContent: { data: data as Record }, - }; - } catch (err) { - return mcpError('api', 1, err instanceof Error ? err.message : String(err)); - } - } - ); - - // ---- mindclip_urgent_todos ---------------------------------------------- - if (!skip('mindclip_urgent_todos')) - server.registerTool( - 'mindclip_urgent_todos', - { - title: 'Get urgent to-dos for a date', - description: - "Fetch the AI-curated list of urgent to-dos surfaced for a date (deadlines, follow-ups, time-sensitive actions). Omit `date` and the server defaults to yesterday's items.", - _meta: { agentSafetyTier: 'read' }, - inputSchema: z.object({ - date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional() - .describe('Date in YYYY-MM-DD format. Omit for server default (yesterday).'), + .describe('YYYY-Www (weeks 01-53) — used by period="weekly"; omit for server default.'), }).strict(), outputSchema: { - data: z.unknown().describe('Urgent todos envelope as returned by /v1.1/mindclip/assistant/urgent-todos (opaque body)'), + data: z.unknown().describe('Period-shaped envelope: daily -> daily recall, weekly -> weekly summary, urgent_todos -> urgent todos list.'), }, }, - async ({ date }) => { + async (args) => { try { - const data = await getUrgentTodos(date); + let data: unknown; + if (args.period === 'daily') { + data = await getDailyRecall(args.date); + } else if (args.period === 'weekly') { + data = await getWeeklySummary(args.week); + } else { + data = await getUrgentTodos(args.date); + } return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], structuredContent: { data: data as Record }, @@ -2529,7 +2429,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 thirty-one tools: + The MCP server exposes twenty-five 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) @@ -2538,16 +2438,10 @@ export function registerMcpCommand(program: Command): void { - search_catalog offline catalog search by type/alias - describe_device metadata + commands + (optionally) live status for one device - account_overview single cold-start snapshot: devices + scenes + quota + cache + MQTT state - - get_device_history fetch raw JSONL history records for a device - - query_device_history filter + page history records with field/time predicates - - aggregate_device_history compute count/min/max/avg/sum/p50/p95 over history records - - mindclip_list_recordings list AI MindClip voice recordings (filters: device, time range, folder, paging) - - mindclip_get_recording fetch metadata + transcript for one recording - - mindclip_get_summary AI summary (key points, action items, transcript highlights) for a recording - - mindclip_list_todos list AI-extracted to-dos (filters: completion, category, source recording, time range) - - mindclip_daily_recall AI-curated daily recall (key moments, decisions, action items) - - mindclip_weekly_summary AI-curated weekly summary across recordings in an ISO week - - mindclip_urgent_todos AI-curated list of urgent to-dos for a date (deadlines, follow-ups) + - device_history read locally-persisted device history (mode: "raw" | "query" | "aggregate") + - mindclip_recordings AI MindClip recordings (action: "list" | "get" | "summary") + - mindclip_list_todos list AI-extracted to-dos (filters: completion, category, source recording, time range) + - mindclip_recall AI-curated assistant views (period: "daily" | "weekly" | "urgent_todos") - policy_validate check policy.yaml against the embedded schema + offline semantics (set live=true to resolve aliases and rule targets against current inventory) - policy_new scaffold a starter policy.yaml (action — confirm first) @@ -2590,7 +2484,7 @@ Inspect locally: mcp .command('tools') .description('Print the registered MCP tools in human or JSON form') - .option('--tools ', 'Tool profile: "default" (20 tools), "readonly" (17), or "all" (31). Lists all when omitted', stringArg('--tools'), 'all') + .option('--tools ', 'Tool profile: "default" (14 tools), "readonly" (11), or "all" (25). Lists all when omitted', stringArg('--tools'), 'all') .action((opts: { tools?: string }) => { try { printMcpToolDirectory(resolveToolProfile(opts.tools)); } catch (e) { handleError(e); } @@ -2599,7 +2493,7 @@ Inspect locally: mcp .command('list-tools') .description('Alias of `mcp tools`') - .option('--tools ', 'Tool profile: "default" (20 tools), "readonly" (17), or "all" (31). Lists all when omitted', stringArg('--tools'), 'all') + .option('--tools ', 'Tool profile: "default" (14 tools), "readonly" (11), or "all" (25). Lists all when omitted', stringArg('--tools'), 'all') .action((opts: { tools?: string }) => { try { printMcpToolDirectory(resolveToolProfile(opts.tools)); } catch (e) { handleError(e); } @@ -2613,7 +2507,7 @@ Inspect locally: .option('--auth-token ', 'Bearer token for HTTP requests (required for --bind 0.0.0.0; falls back to SWITCHBOT_MCP_TOKEN env var)', stringArg('--auth-token')) .option('--cors-origin ', 'Allowed CORS origin(s) for HTTP (repeatable)', stringArg('--cors-origin')) .option('--rate-limit ', 'Max requests per minute per profile (default 60)', intArg('--rate-limit', { min: 1 }), '60') - .option('--tools ', 'Tool profile: "default" (13 tools), "readonly" (10), or "all" (24)', stringArg('--tools'), 'default') + .option('--tools ', 'Tool profile: "default" (14 tools), "readonly" (11), or "all" (25)', stringArg('--tools'), 'default') .addHelpText('after', ` Examples: $ switchbot mcp serve diff --git a/src/install/claude-code-checks.ts b/src/install/claude-code-checks.ts index 3c44ef35..301648a3 100644 --- a/src/install/claude-code-checks.ts +++ b/src/install/claude-code-checks.ts @@ -14,7 +14,7 @@ export interface RegisterMcpResult { const CLAUDE_CMD = 'claude'; const MCP_SERVER_NAME = 'switchbot'; -const MCP_ADD_ARGS = ['switchbot', 'mcp', 'serve', '--tools', 'all']; +const MCP_ADD_ARGS = ['switchbot', 'mcp', 'serve']; function spawnStr(cmd: string, args: string[], timeout = 10_000) { const r = spawnSync(cmd, args, { diff --git a/src/install/gemini-checks.ts b/src/install/gemini-checks.ts index 33dd827a..b92e7f12 100644 --- a/src/install/gemini-checks.ts +++ b/src/install/gemini-checks.ts @@ -94,8 +94,8 @@ export function registerMcp(): RegisterMcpResult { ...mcpServers, [MCP_SERVER_NAME]: { command: 'switchbot', - args: ['mcp', 'serve', '--tools', 'all'], - description: 'SwitchBot smart-home MCP server (31 tools)', + args: ['mcp', 'serve'], + description: 'SwitchBot smart-home MCP server (default: 14 tools; `--tools all` for 25)', }, }; fs.mkdirSync(path.dirname(GEMINI_SETTINGS_PATH), { recursive: true }); diff --git a/src/mcp/tool-profiles.ts b/src/mcp/tool-profiles.ts index c4c5b4b5..98c3c735 100644 --- a/src/mcp/tool-profiles.ts +++ b/src/mcp/tool-profiles.ts @@ -3,21 +3,15 @@ export type ToolProfile = 'default' | 'readonly' | 'all'; const CORE_READ = [ 'list_devices', 'get_device_status', - 'get_device_history', - 'query_device_history', + 'device_history', 'list_scenes', 'search_catalog', 'describe_device', - 'aggregate_device_history', 'account_overview', 'plan_suggest', - 'mindclip_list_recordings', - 'mindclip_get_recording', - 'mindclip_get_summary', + 'mindclip_recordings', 'mindclip_list_todos', - 'mindclip_daily_recall', - 'mindclip_weekly_summary', - 'mindclip_urgent_todos', + 'mindclip_recall', ] as const; const CORE_ACTION = ['send_command', 'run_scene', 'plan_run'] as const; diff --git a/tests/commands/capabilities.test.ts b/tests/commands/capabilities.test.ts index 7122c27e..a50ba90a 100644 --- a/tests/commands/capabilities.test.ts +++ b/tests/commands/capabilities.test.ts @@ -150,14 +150,13 @@ describe('capabilities', () => { expect(cat.safetyTiersInUse).toBeUndefined(); }); - it('surfaces.mcp.tools includes send_command, account_overview, get_device_history and query_device_history', async () => { + it('surfaces.mcp.tools includes send_command, account_overview and device_history', async () => { const out = await runCapabilities(); const mcp = (out.surfaces as Record).mcp; expect(mcp.tools.length).toBeGreaterThanOrEqual(9); expect(mcp.tools).toContain('send_command'); expect(mcp.tools).toContain('account_overview'); - expect(mcp.tools).toContain('get_device_history'); - expect(mcp.tools).toContain('query_device_history'); + expect(mcp.tools).toContain('device_history'); expect(mcp.resources).toEqual(['switchbot://events']); }); @@ -269,10 +268,10 @@ describe('capabilities B3/B4', () => { expect(agg!.mutating).toBe(false); }); - it('surfaces.mcp.tools includes aggregate_device_history', async () => { + it('surfaces.mcp.tools includes device_history (consolidated raw/query/aggregate)', async () => { const out = await runCapabilitiesWith([]); const mcp = (out.surfaces as Record).mcp; - expect(mcp.tools).toContain('aggregate_device_history'); + expect(mcp.tools).toContain('device_history'); }); it('devices meta set appears in compact capabilities output (bug #40)', async () => { diff --git a/tests/commands/mcp.test.ts b/tests/commands/mcp.test.ts index 4afc1cc8..963c7761 100644 --- a/tests/commands/mcp.test.ts +++ b/tests/commands/mcp.test.ts @@ -163,7 +163,7 @@ describe('mcp server', () => { delete process.env.SWITCHBOT_ALLOW_DIRECT_DESTRUCTIVE; }); - it('exposes the thirty-one tools with titles and input schemas', async () => { + it('exposes the twenty-five tools with titles and input schemas', async () => { const { client } = await pair(); const { tools } = await client.listTools(); @@ -171,21 +171,16 @@ describe('mcp server', () => { expect(names).toEqual( [ 'account_overview', - 'aggregate_device_history', 'audit_query', 'audit_stats', 'describe_device', - 'get_device_history', + 'device_history', 'get_device_status', 'list_devices', 'list_scenes', - 'mindclip_daily_recall', - 'mindclip_get_recording', - 'mindclip_get_summary', - 'mindclip_list_recordings', 'mindclip_list_todos', - 'mindclip_urgent_todos', - 'mindclip_weekly_summary', + 'mindclip_recall', + 'mindclip_recordings', 'plan_run', 'plan_suggest', 'policy_add_rule', @@ -193,7 +188,6 @@ describe('mcp server', () => { 'policy_migrate', 'policy_new', 'policy_validate', - 'query_device_history', 'rule_notifications', 'rules_explain', 'rules_simulate', @@ -583,10 +577,10 @@ describe('mcp server', () => { expect(res.isError).toBe(true); }); - it('mindclip_list_recordings calls /v1.1/mindclip/recordings and returns body', async () => { + it('mindclip_recordings action=list calls /v1.1/mindclip/recordings and returns body', async () => { apiMock.__instance.get.mockResolvedValueOnce({ data: { body: { list: [{ id: 'r1' }] } } }); const { client } = await pair(); - const res = await client.callTool({ name: 'mindclip_list_recordings', arguments: {} }); + const res = await client.callTool({ name: 'mindclip_recordings', arguments: { action: 'list' } }); expect(res.isError).toBeFalsy(); const parsed = JSON.parse((res.content as Array<{ text: string }>)[0].text); expect(parsed).toEqual({ list: [{ id: 'r1' }] }); @@ -595,39 +589,55 @@ describe('mcp server', () => { }); }); - it('mindclip_list_recordings forwards device, page, size, and time-range params', async () => { + it('mindclip_recordings action=list forwards device, page, size, and time-range params', async () => { apiMock.__instance.get.mockResolvedValueOnce({ data: { body: {} } }); const { client } = await pair(); await client.callTool({ - name: 'mindclip_list_recordings', - arguments: { deviceID: 'AABBCC', pageNum: 2, pageSize: 10, startTime: 1000, endTime: 2000, folderID: 3 }, + name: 'mindclip_recordings', + arguments: { action: 'list', deviceID: 'AABBCC', pageNum: 2, pageSize: 10, startTime: 1000, endTime: 2000, folderID: 3 }, }); expect(apiMock.__instance.get).toHaveBeenCalledWith('/v1.1/mindclip/recordings', { params: { deviceID: 'AABBCC', pageNum: 2, pageSize: 10, startTime: 1000, endTime: 2000, folderID: 3 }, }); }); - it('mindclip_get_recording calls /v1.1/mindclip/recordings/{id} with optional language', async () => { + it('mindclip_recordings action=get calls /v1.1/mindclip/recordings/{id} with optional language', async () => { apiMock.__instance.get.mockResolvedValueOnce({ data: { body: { id: 'r1' } } }); const { client } = await pair(); await client.callTool({ - name: 'mindclip_get_recording', - arguments: { id: 'r1', language: 'en' }, + name: 'mindclip_recordings', + arguments: { action: 'get', id: 'r1', language: 'en' }, }); expect(apiMock.__instance.get).toHaveBeenCalledWith('/v1.1/mindclip/recordings/r1', { params: { language: 'en' }, }); }); - it('mindclip_get_summary calls /v1.1/mindclip/summaries/{id}', async () => { + it('mindclip_recordings action=get rejects missing id with a usage error', async () => { + const { client } = await pair(); + const res = await client.callTool({ name: 'mindclip_recordings', arguments: { action: 'get' } }); + expect(res.isError).toBeTruthy(); + const text = (res.content as Array<{ text: string }>)[0].text; + expect(text).toContain('action="get" requires `id`'); + }); + + it('mindclip_recordings action=summary calls /v1.1/mindclip/summaries/{id}', async () => { apiMock.__instance.get.mockResolvedValueOnce({ data: { body: { summary: 'ok' } } }); const { client } = await pair(); - const res = await client.callTool({ name: 'mindclip_get_summary', arguments: { id: 's1' } }); + const res = await client.callTool({ name: 'mindclip_recordings', arguments: { action: 'summary', id: 's1' } }); const parsed = JSON.parse((res.content as Array<{ text: string }>)[0].text); expect(parsed).toEqual({ summary: 'ok' }); expect(apiMock.__instance.get).toHaveBeenCalledWith('/v1.1/mindclip/summaries/s1', { params: {} }); }); + it('mindclip_recordings action=summary rejects missing id with a usage error', async () => { + const { client } = await pair(); + const res = await client.callTool({ name: 'mindclip_recordings', arguments: { action: 'summary' } }); + expect(res.isError).toBeTruthy(); + const text = (res.content as Array<{ text: string }>)[0].text; + expect(text).toContain('action="summary" requires `id`'); + }); + it('mindclip_list_todos calls /v1.1/mindclip/todos and returns body', async () => { apiMock.__instance.get.mockResolvedValueOnce({ data: { body: { items: [{ id: 't1' }] } } }); const { client } = await pair(); @@ -667,34 +677,34 @@ describe('mcp server', () => { }); }); - it('mindclip_daily_recall calls /v1.1/mindclip/assistant/daily with date param', async () => { + it('mindclip_recall period=daily calls /v1.1/mindclip/assistant/daily with date param', async () => { apiMock.__instance.get.mockResolvedValueOnce({ data: { body: {} } }); const { client } = await pair(); await client.callTool({ - name: 'mindclip_daily_recall', - arguments: { date: '2026-06-13' }, + name: 'mindclip_recall', + arguments: { period: 'daily', date: '2026-06-13' }, }); expect(apiMock.__instance.get).toHaveBeenCalledWith('/v1.1/mindclip/assistant/daily', { params: { date: '2026-06-13' }, }); }); - it('mindclip_weekly_summary calls /v1.1/mindclip/assistant/weekly with week param', async () => { + it('mindclip_recall period=weekly calls /v1.1/mindclip/assistant/weekly with week param', async () => { apiMock.__instance.get.mockResolvedValueOnce({ data: { body: {} } }); const { client } = await pair(); await client.callTool({ - name: 'mindclip_weekly_summary', - arguments: { week: '2026-W23' }, + name: 'mindclip_recall', + arguments: { period: 'weekly', week: '2026-W23' }, }); expect(apiMock.__instance.get).toHaveBeenCalledWith('/v1.1/mindclip/assistant/weekly', { params: { week: '2026-W23' }, }); }); - it('mindclip_urgent_todos calls /v1.1/mindclip/assistant/urgent-todos and omits date when not provided', async () => { + it('mindclip_recall period=urgent_todos calls /v1.1/mindclip/assistant/urgent-todos and omits date when not provided', async () => { apiMock.__instance.get.mockResolvedValueOnce({ data: { body: {} } }); const { client } = await pair(); - await client.callTool({ name: 'mindclip_urgent_todos', arguments: {} }); + await client.callTool({ name: 'mindclip_recall', arguments: { period: 'urgent_todos' } }); expect(apiMock.__instance.get).toHaveBeenCalledWith('/v1.1/mindclip/assistant/urgent-todos', { params: {}, }); @@ -757,28 +767,28 @@ describe('mcp server', () => { ); }); - it('lists aggregate_device_history with _meta.agentSafetyTier=read', async () => { + it('lists device_history with _meta.agentSafetyTier=read', async () => { const { client } = await pair(); const { tools } = await client.listTools(); - const tool = tools.find((t) => t.name === 'aggregate_device_history'); - expect(tool, 'aggregate_device_history should be listed').toBeDefined(); + const tool = tools.find((t) => t.name === 'device_history'); + expect(tool, 'device_history should be listed').toBeDefined(); expect((tool as { _meta?: { agentSafetyTier?: string } } | undefined)?._meta?.agentSafetyTier).toBe('read'); }); - it('aggregate_device_history rejects unknown input keys with -32602', async () => { + it('device_history mode=aggregate rejects unknown input keys with -32602', async () => { const { client } = await pair(); const res = await client.callTool({ - name: 'aggregate_device_history', - arguments: { deviceId: 'DEV1', metrics: ['temperature'], bogusField: 'nope' }, + name: 'device_history', + arguments: { mode: 'aggregate', deviceId: 'DEV1', metrics: ['temperature'], bogusField: 'nope' }, }); expect(res.isError).toBe(true); const text = (res.content as Array<{ type: string; text: string }>)[0].text; expect(text).toMatch(/-32602|unrecognized_keys|Unrecognized key/i); }); - it('aggregate_device_history returns the same shape as the CLI --json.data', async () => { + it('device_history mode=aggregate returns the same shape as the CLI --json.data', async () => { const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'mcp-agg-test-')); vi.spyOn(os, 'homedir').mockReturnValue(tmpHome); @@ -793,8 +803,9 @@ describe('mcp server', () => { const { client } = await pair(); const res = await client.callTool({ - name: 'aggregate_device_history', + name: 'device_history', arguments: { + mode: 'aggregate', deviceId: 'DEV1', from: '2026-04-19T00:00:00.000Z', to: '2026-04-20T00:00:00.000Z', @@ -820,6 +831,49 @@ describe('mcp server', () => { } }); + it('device_history mode=raw with deviceId returns latest + history', async () => { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'mcp-raw-test-')); + vi.spyOn(os, 'homedir').mockReturnValue(tmpHome); + try { + const histDir = path.join(tmpHome, '.switchbot', 'device-history'); + fs.mkdirSync(histDir, { recursive: true }); + fs.writeFileSync( + path.join(histDir, 'DEV2.jsonl'), + JSON.stringify({ t: '2026-05-01T00:00:00.000Z', topic: 't/DEV2', payload: { battery: 80 } }) + '\n', + ); + const { client } = await pair(); + const res = await client.callTool({ name: 'device_history', arguments: { mode: 'raw', deviceId: 'DEV2' } }); + expect(res.isError).toBeFalsy(); + const sc = (res as { structuredContent?: { deviceId?: string; latest?: unknown; history?: unknown[] } }) + .structuredContent; + expect(sc?.deviceId).toBe('DEV2'); + expect(sc?.latest).toBeDefined(); + expect(Array.isArray(sc?.history)).toBe(true); + } finally { + vi.restoreAllMocks(); + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }); + + it('device_history mode=query rejects missing deviceId with a usage error', async () => { + const { client } = await pair(); + const res = await client.callTool({ name: 'device_history', arguments: { mode: 'query' } }); + expect(res.isError).toBe(true); + const text = (res.content as Array<{ text: string }>)[0].text; + expect(text).toContain('mode="query" requires `deviceId`'); + }); + + it('device_history mode=aggregate rejects empty metrics with a usage error', async () => { + const { client } = await pair(); + const res = await client.callTool({ + name: 'device_history', + arguments: { mode: 'aggregate', deviceId: 'DEV1' }, + }); + expect(res.isError).toBe(true); + const text = (res.content as Array<{ text: string }>)[0].text; + expect(text).toContain('requires at least one entry in `metrics`'); + }); + // --------------------------------------------------------------------------- // Bug #38: structured error metadata preserved in MCP tool responses // --------------------------------------------------------------------------- diff --git a/tests/commands/strict-schemas.test.ts b/tests/commands/strict-schemas.test.ts index 10a12d98..6f3a0bc8 100644 --- a/tests/commands/strict-schemas.test.ts +++ b/tests/commands/strict-schemas.test.ts @@ -101,14 +101,14 @@ describe('MCP strict schemas — all tools reject unknown keys', () => { await assertRejectsUnknownKey(client, 'get_device_status', { deviceId: 'D1' }); }); - it('get_device_history rejects unknown keys', async () => { + it('device_history (mode=raw) rejects unknown keys', async () => { const { client } = await pair(); - await assertRejectsUnknownKey(client, 'get_device_history', {}); + await assertRejectsUnknownKey(client, 'device_history', { mode: 'raw' }); }); - it('query_device_history rejects unknown keys', async () => { + it('device_history (mode=query) rejects unknown keys', async () => { const { client } = await pair(); - await assertRejectsUnknownKey(client, 'query_device_history', { deviceId: 'D1' }); + await assertRejectsUnknownKey(client, 'device_history', { mode: 'query', deviceId: 'D1' }); }); it('send_command rejects unknown keys', async () => { @@ -140,9 +140,10 @@ describe('MCP strict schemas — all tools reject unknown keys', () => { await assertRejectsUnknownKey(client, 'describe_device', { deviceId: 'D1' }); }); - it('aggregate_device_history rejects unknown keys', async () => { + it('device_history (mode=aggregate) rejects unknown keys', async () => { const { client } = await pair(); - await assertRejectsUnknownKey(client, 'aggregate_device_history', { + await assertRejectsUnknownKey(client, 'device_history', { + mode: 'aggregate', deviceId: 'D1', metrics: ['temperature'], }); diff --git a/tests/install/gemini-checks.test.ts b/tests/install/gemini-checks.test.ts index c9c41c6e..6822a2b5 100644 --- a/tests/install/gemini-checks.test.ts +++ b/tests/install/gemini-checks.test.ts @@ -118,7 +118,7 @@ describe('registerMcp', () => { const written = writeFileSyncMock.mock.calls[0]?.[1] as string; const parsed = JSON.parse(written); expect(parsed.mcpServers.switchbot.command).toBe('switchbot'); - expect(parsed.mcpServers.switchbot.args).toEqual(['mcp', 'serve', '--tools', 'all']); + expect(parsed.mcpServers.switchbot.args).toEqual(['mcp', 'serve']); }); it('preserves existing top-level keys and other mcpServers entries', () => { diff --git a/tests/mcp/tool-meta.test.ts b/tests/mcp/tool-meta.test.ts index cd8d501d..db6257db 100644 --- a/tests/mcp/tool-meta.test.ts +++ b/tests/mcp/tool-meta.test.ts @@ -116,10 +116,10 @@ describe('MCP tool _meta.agentSafetyTier', () => { expect((tool as any)._meta.agentSafetyTier).toBe('read'); }); - it('aggregate_device_history is marked as read tier', async () => { + it('device_history is marked as read tier', async () => { const { client } = await pair(); const toolsList = await client.listTools(); - const tool = toolsList.tools.find((t) => t.name === 'aggregate_device_history'); + const tool = toolsList.tools.find((t) => t.name === 'device_history'); expect(tool).toBeDefined(); expect((tool as any)._meta.agentSafetyTier).toBe('read'); }); diff --git a/tests/mcp/tool-profiles.test.ts b/tests/mcp/tool-profiles.test.ts index 76464650..1df8086b 100644 --- a/tests/mcp/tool-profiles.test.ts +++ b/tests/mcp/tool-profiles.test.ts @@ -5,16 +5,16 @@ import { MCP_TOOLS } from '../../src/commands/capabilities.js'; describe('tool-profiles', () => { describe('TOOL_PROFILES sets', () => { - it('readonly has 17 tools (core read only)', () => { - expect(TOOL_PROFILES.readonly.size).toBe(17); + it('readonly has 11 tools (core read only)', () => { + expect(TOOL_PROFILES.readonly.size).toBe(11); }); - it('default has 20 tools (core read + action)', () => { - expect(TOOL_PROFILES.default.size).toBe(20); + it('default has 14 tools (core read + action)', () => { + expect(TOOL_PROFILES.default.size).toBe(14); }); - it('all has 31 tools', () => { - expect(TOOL_PROFILES.all.size).toBe(31); + it('all has 25 tools', () => { + expect(TOOL_PROFILES.all.size).toBe(25); }); it('readonly is a subset of default', () => { @@ -66,9 +66,9 @@ describe('tool-profiles', () => { describe('createSwitchBotMcpServer respects toolProfile', () => { it.each<[ToolProfile, number]>([ - ['readonly', 17], - ['default', 20], - ['all', 31], + ['readonly', 11], + ['default', 14], + ['all', 25], ])('profile "%s" registers %d tools', (profile, expected) => { const server = createSwitchBotMcpServer({ toolProfile: profile }); expect(listRegisteredTools(server)).toHaveLength(expected); diff --git a/tests/mcp/tool-schema-completeness.test.ts b/tests/mcp/tool-schema-completeness.test.ts index 99626d19..bfcebe3d 100644 --- a/tests/mcp/tool-schema-completeness.test.ts +++ b/tests/mcp/tool-schema-completeness.test.ts @@ -138,13 +138,13 @@ describe('MCP tool schema completeness', () => { ).toEqual([]); }); - it('aggregate_device_history describes every input argument (P4 regression guard)', () => { - const agg = tools.find((t) => t.name === 'aggregate_device_history'); - expect(agg, 'aggregate_device_history must be registered').toBeDefined(); - const props = agg!.inputSchema?.properties ?? {}; - const expected = ['deviceId', 'since', 'from', 'to', 'metrics', 'aggs', 'bucket', 'maxBucketSamples']; + it('device_history describes every input argument (P4 regression guard)', () => { + const dh = tools.find((t) => t.name === 'device_history'); + expect(dh, 'device_history must be registered').toBeDefined(); + const props = dh!.inputSchema?.properties ?? {}; + const expected = ['mode', 'deviceId', 'limit', 'since', 'from', 'to', 'fields', 'metrics', 'aggs', 'bucket', 'maxBucketSamples']; for (const prop of expected) { - expect(props[prop], `${prop} should appear in aggregate_device_history inputSchema`).toBeDefined(); + expect(props[prop], `${prop} should appear in device_history inputSchema`).toBeDefined(); expect( props[prop].description, `${prop}.description should be a non-empty string`, From e83d2134d800564a3fe59a86294ca899370d742a Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 13 Jun 2026 19:21:07 +0800 Subject: [PATCH 24/57] test(mcp): expand consolidated tools coverage with ~75 new cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds eight groups of test coverage on top of the v3.8.0 consolidation to harden behavior contracts that were previously implicit. G1 — discriminator schema validation (6 cases, strict-schemas.test.ts): each consolidated tool rejects missing or invalid action/period/mode with a -32602 family error, ruling out silent fallback-to-default. G2/G3 — branch routing + parameter forwarding (~14 cases, mcp.test.ts): mindclip_recall period=daily/weekly without their date/week args take the server-default branch; period=urgent_todos with date forwards it; action=get omits language when caller omits it; partial-filter combinations on action=list and mindclip_list_todos forward only the fields they were given. device_history mode=raw without deviceId returns the devices listing shape; mode=raw with limit bounds the history slice; mode=query rejects since combined with from/to; mode=aggregate rejects missing deviceId; mode=query happy path returns count + records; mode=aggregate forwards bucket and maxBucketSamples. G4 — inputSchema introspection (3 cases, tool-schema-completeness.test.ts): mindclip_recordings, mindclip_recall, and device_history each describe every documented field, mark the discriminator as required, and surface the discriminator's enum values via JSON Schema. G5 — retired tool name contract (27 cases, retired-tools.test.ts NEW): the nine retired names (mindclip_list_recordings, mindclip_get_recording, mindclip_get_summary, mindclip_daily_recall, mindclip_weekly_summary, mindclip_urgent_todos, get_device_history, query_device_history, aggregate_device_history) must (a) be absent from createSwitchBotMcpServer({toolProfile:'all'}), (b) be absent from listTools() over the wire, and (c) trigger a method-not-found / -32601 error from callTool. Guards the breaking-change contract in CHANGELOG [Unreleased]. G6 — plugin manifest shape (10 cases across 3 NEW test files): packages/claude-code-plugin/tests/manifest.test.js asserts plugins/switchbot/.mcp.json args = ['mcp','serve'] and rejects --tools/all tokens. packages/codex-plugin/tests/mcp-config.test.js asserts the same for both .mcp.json layouts (top-level + nested). tests/install/claude-code-checks.test.ts mocks spawnSync and asserts registerMcp() invokes `claude mcp add` with default-profile args and that `claude mcp list` short-circuits on already-registered. G7 — boundary tests on filter ranges (12 cases, strict-schemas.test.ts): pageSize 0/1/100/101, pageNum 0, completedNum 3 and 0..2, category 6, mindclip_recall date regex (slashes rejected), week regex (W00/W54 rejected; W01/W53 accepted) — all per the schemas in src/commands/mcp.ts. G8 — cross-branch field leakage (5 cases, mcp.test.ts): the consolidated input schemas accept all-optional per-branch fields, but the handlers must ignore mismatched-branch fields and not forward them to the underlying API. Verified by mocking the api client and asserting both the URL chosen and that stray fields do NOT appear in params (mindclip side) or that the alternate branch was NOT taken (mindclip and device_history sides). Plus a stale comment fix in src/install/claude-code-checks.ts (the inline note still referenced --tools all after the v3.8.0 default flip). Net: vitest main suite 2904 -> 2972 (+68); workspace tests +7 across two new manifest test files. All green; no production code changes outside the comment fix. --- .../claude-code-plugin/tests/manifest.test.js | 39 +++ .../codex-plugin/tests/mcp-config.test.js | 45 +++ src/install/claude-code-checks.ts | 3 +- tests/commands/mcp.test.ts | 327 ++++++++++++++++++ tests/commands/strict-schemas.test.ts | 144 ++++++++ tests/install/claude-code-checks.test.ts | 55 +++ tests/mcp/retired-tools.test.ts | 116 +++++++ tests/mcp/tool-schema-completeness.test.ts | 53 +++ 8 files changed, 781 insertions(+), 1 deletion(-) create mode 100644 packages/claude-code-plugin/tests/manifest.test.js create mode 100644 packages/codex-plugin/tests/mcp-config.test.js create mode 100644 tests/install/claude-code-checks.test.ts create mode 100644 tests/mcp/retired-tools.test.ts diff --git a/packages/claude-code-plugin/tests/manifest.test.js b/packages/claude-code-plugin/tests/manifest.test.js new file mode 100644 index 00000000..a85b0842 --- /dev/null +++ b/packages/claude-code-plugin/tests/manifest.test.js @@ -0,0 +1,39 @@ +/** + * Asserts the `.mcp.json` shipped by the Claude Code plugin registers the + * SwitchBot MCP server using the default profile (no `--tools all`). The + * v3.8.0 consolidation switched defaults so admin tools are opt-in; this + * test guards against an accidental revert. + */ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = fileURLToPath(new URL('.', import.meta.url)); +const mcpJsonPath = resolve(__dirname, '../plugins/switchbot/.mcp.json'); + +describe('claude-code-plugin .mcp.json', () => { + const manifest = JSON.parse(readFileSync(mcpJsonPath, 'utf8')); + + it('declares mcpServers.switchbot', () => { + const server = manifest.mcpServers?.switchbot; + assert.ok(server, 'mcpServers.switchbot must be defined'); + assert.equal(server.command, 'switchbot'); + }); + + it('uses the default tool profile (no --tools all)', () => { + const args = manifest.mcpServers.switchbot.args; + assert.deepEqual(args, ['mcp', 'serve'], `args must be ["mcp","serve"]; got ${JSON.stringify(args)}`); + assert.ok(!args.includes('all'), 'args must not include "all"'); + assert.ok(!args.includes('--tools'), 'args must not pass --tools (rely on CLI default=default)'); + }); + + it('mcpServers.switchbot has no unsupported fields', () => { + const server = manifest.mcpServers.switchbot; + const allowedKeys = new Set(['command', 'args', 'cwd', 'env']); + for (const key of Object.keys(server)) { + assert.ok(allowedKeys.has(key), `unexpected field "${key}" in mcpServers.switchbot`); + } + }); +}); diff --git a/packages/codex-plugin/tests/mcp-config.test.js b/packages/codex-plugin/tests/mcp-config.test.js new file mode 100644 index 00000000..f07afbda --- /dev/null +++ b/packages/codex-plugin/tests/mcp-config.test.js @@ -0,0 +1,45 @@ +/** + * Asserts the two `.mcp.json` files shipped by the Codex plugin both register + * the SwitchBot MCP server using the default profile (no `--tools all`). + * + * Two paths are checked: + * - packages/codex-plugin/.mcp.json (top-level, used by Codex when the + * plugin source root is the package itself) + * - packages/codex-plugin/plugins/switchbot/.mcp.json (nested layout + * used by the marketplace registration) + * + * Both must agree. + */ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = fileURLToPath(new URL('.', import.meta.url)); + +const PATHS = [ + ['top-level', resolve(__dirname, '../.mcp.json')], + ['nested', resolve(__dirname, '../plugins/switchbot/.mcp.json')], +]; + +describe('codex-plugin .mcp.json (both layouts)', () => { + for (const [label, p] of PATHS) { + describe(`${label} at ${p}`, () => { + const manifest = JSON.parse(readFileSync(p, 'utf8')); + + it('declares mcpServers.switchbot pointing at the switchbot CLI', () => { + const server = manifest.mcpServers?.switchbot; + assert.ok(server, `${label}: mcpServers.switchbot must be defined`); + assert.equal(server.command, 'switchbot'); + }); + + it('uses the default tool profile (no --tools all)', () => { + const args = manifest.mcpServers.switchbot.args; + assert.deepEqual(args, ['mcp', 'serve'], `${label}: args must be ["mcp","serve"]; got ${JSON.stringify(args)}`); + assert.ok(!args.includes('all'), `${label}: args must not include "all"`); + assert.ok(!args.includes('--tools'), `${label}: args must not pass --tools`); + }); + }); + } +}); diff --git a/src/install/claude-code-checks.ts b/src/install/claude-code-checks.ts index 301648a3..32cf50c4 100644 --- a/src/install/claude-code-checks.ts +++ b/src/install/claude-code-checks.ts @@ -72,7 +72,8 @@ export function registerMcp(): RegisterMcpResult { return { ok: true, alreadyRegistered: true }; } - // Register via `claude mcp add --scope user switchbot -- switchbot mcp serve --tools all` + // Register via `claude mcp add --scope user switchbot -- switchbot mcp serve` + // (default profile; users who want admin tools can re-register with `--tools all` themselves). const addR = spawnStr( CLAUDE_CMD, ['mcp', 'add', '--scope', 'user', MCP_SERVER_NAME, '--', ...MCP_ADD_ARGS], diff --git a/tests/commands/mcp.test.ts b/tests/commands/mcp.test.ts index 963c7761..f1ed0a01 100644 --- a/tests/commands/mcp.test.ts +++ b/tests/commands/mcp.test.ts @@ -710,6 +710,142 @@ describe('mcp server', () => { }); }); + // --------------------------------------------------------------------------- + // G2/G3: branch-routing + parameter-forwarding completeness for the + // consolidated mindclip tools. Covers default branches the basic tests + // skipped (period=daily/weekly without date/week, action=get without + // language) and partial-filter combinations. + // --------------------------------------------------------------------------- + + it('mindclip_recall period=daily without date hits /v1.1/mindclip/assistant/daily with empty params', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ data: { body: {} } }); + const { client } = await pair(); + await client.callTool({ name: 'mindclip_recall', arguments: { period: 'daily' } }); + expect(apiMock.__instance.get).toHaveBeenCalledWith('/v1.1/mindclip/assistant/daily', { params: {} }); + }); + + it('mindclip_recall period=weekly without week hits /v1.1/mindclip/assistant/weekly with empty params', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ data: { body: {} } }); + const { client } = await pair(); + await client.callTool({ name: 'mindclip_recall', arguments: { period: 'weekly' } }); + expect(apiMock.__instance.get).toHaveBeenCalledWith('/v1.1/mindclip/assistant/weekly', { params: {} }); + }); + + it('mindclip_recall period=urgent_todos with explicit date passes the date through', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ data: { body: {} } }); + const { client } = await pair(); + await client.callTool({ name: 'mindclip_recall', arguments: { period: 'urgent_todos', date: '2026-06-12' } }); + expect(apiMock.__instance.get).toHaveBeenCalledWith('/v1.1/mindclip/assistant/urgent-todos', { + params: { date: '2026-06-12' }, + }); + }); + + it('mindclip_recordings action=get omits language when caller did not pass one', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ data: { body: { id: 'r1' } } }); + const { client } = await pair(); + await client.callTool({ name: 'mindclip_recordings', arguments: { action: 'get', id: 'r1' } }); + expect(apiMock.__instance.get).toHaveBeenCalledWith('/v1.1/mindclip/recordings/r1', { params: {} }); + }); + + it('mindclip_recordings action=list with only deviceID forwards just that filter', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ data: { body: {} } }); + const { client } = await pair(); + await client.callTool({ name: 'mindclip_recordings', arguments: { action: 'list', deviceID: 'AB' } }); + expect(apiMock.__instance.get).toHaveBeenCalledWith('/v1.1/mindclip/recordings', { + params: { deviceID: 'AB' }, + }); + }); + + it('mindclip_recordings action=list with only paging forwards just paging', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ data: { body: {} } }); + const { client } = await pair(); + await client.callTool({ + name: 'mindclip_recordings', + arguments: { action: 'list', pageNum: 4, pageSize: 25 }, + }); + expect(apiMock.__instance.get).toHaveBeenCalledWith('/v1.1/mindclip/recordings', { + params: { pageNum: 4, pageSize: 25 }, + }); + }); + + it('mindclip_list_todos with only deviceID forwards just that filter', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ data: { body: {} } }); + const { client } = await pair(); + await client.callTool({ name: 'mindclip_list_todos', arguments: { deviceID: 'D9' } }); + expect(apiMock.__instance.get).toHaveBeenCalledWith('/v1.1/mindclip/todos', { + params: { deviceID: 'D9' }, + }); + }); + + // --------------------------------------------------------------------------- + // G8: cross-branch field leakage — mindclip side. Extra fields belonging to + // other branches are accepted by the (intentionally permissive) input + // schema but must be ignored at handler time, NOT forwarded to the API. + // --------------------------------------------------------------------------- + + it('mindclip_recordings action=list ignores stray `id` field (no leakage to params)', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ data: { body: {} } }); + const { client } = await pair(); + await client.callTool({ + name: 'mindclip_recordings', + arguments: { action: 'list', id: 'should-be-ignored', deviceID: 'AB' }, + }); + // The list endpoint must be called (NOT /v1.1/mindclip/recordings/should-be-ignored) + expect(apiMock.__instance.get).toHaveBeenCalledWith('/v1.1/mindclip/recordings', { + params: { deviceID: 'AB' }, + }); + // params must not carry id + const params = apiMock.__instance.get.mock.calls[0]?.[1]?.params ?? {}; + expect(params).not.toHaveProperty('id'); + }); + + it('mindclip_recordings action=get ignores stray list-only filters', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ data: { body: { id: 'r1' } } }); + const { client } = await pair(); + await client.callTool({ + name: 'mindclip_recordings', + arguments: { + action: 'get', + id: 'r1', + // these are list-only and must not affect the get request + deviceID: 'AB', + pageSize: 50, + startTime: 1000, + }, + }); + // get hits /recordings/{id} with empty params (no language, no list filters) + expect(apiMock.__instance.get).toHaveBeenCalledWith('/v1.1/mindclip/recordings/r1', { params: {} }); + }); + + it('mindclip_recall period=daily ignores stray `week` field', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ data: { body: {} } }); + const { client } = await pair(); + await client.callTool({ + name: 'mindclip_recall', + arguments: { period: 'daily', date: '2026-06-01', week: '2026-W23' }, + }); + expect(apiMock.__instance.get).toHaveBeenCalledWith('/v1.1/mindclip/assistant/daily', { + params: { date: '2026-06-01' }, + }); + // Must NOT have called the weekly endpoint + const calls = apiMock.__instance.get.mock.calls.map((c) => c[0]); + expect(calls).not.toContain('/v1.1/mindclip/assistant/weekly'); + }); + + it('mindclip_recall period=weekly ignores stray `date` field', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ data: { body: {} } }); + const { client } = await pair(); + await client.callTool({ + name: 'mindclip_recall', + arguments: { period: 'weekly', week: '2026-W23', date: '2026-06-01' }, + }); + expect(apiMock.__instance.get).toHaveBeenCalledWith('/v1.1/mindclip/assistant/weekly', { + params: { week: '2026-W23' }, + }); + const calls = apiMock.__instance.get.mock.calls.map((c) => c[0]); + expect(calls).not.toContain('/v1.1/mindclip/assistant/daily'); + }); + it('run_scene POSTs the scene execute endpoint', async () => { apiMock.__instance.post.mockResolvedValueOnce({ data: { statusCode: 100, body: {} } }); const { client } = await pair(); @@ -874,6 +1010,197 @@ describe('mcp server', () => { expect(text).toContain('requires at least one entry in `metrics`'); }); + // --------------------------------------------------------------------------- + // G2/G3: device_history routing + handler-level validation + // --------------------------------------------------------------------------- + + it('device_history mode=raw without deviceId returns the devices listing shape', async () => { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'mcp-raw-list-')); + vi.spyOn(os, 'homedir').mockReturnValue(tmpHome); + try { + const { client } = await pair(); + const res = await client.callTool({ name: 'device_history', arguments: { mode: 'raw' } }); + expect(res.isError).toBeFalsy(); + const sc = (res as { structuredContent?: { devices?: unknown[]; deviceId?: string } }) + .structuredContent; + expect(Array.isArray(sc?.devices)).toBe(true); + expect(sc?.deviceId, 'should NOT carry a single-device shape').toBeUndefined(); + } finally { + vi.restoreAllMocks(); + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }); + + it('device_history mode=raw forwards `limit` to the per-device branch', async () => { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'mcp-raw-limit-')); + vi.spyOn(os, 'homedir').mockReturnValue(tmpHome); + try { + const histDir = path.join(tmpHome, '.switchbot', 'device-history'); + fs.mkdirSync(histDir, { recursive: true }); + const lines: string[] = []; + for (let i = 0; i < 10; i++) { + lines.push(JSON.stringify({ t: `2026-05-01T0${i}:00:00.000Z`, topic: 't/DEV', payload: { i } })); + } + fs.writeFileSync(path.join(histDir, 'DEV.jsonl'), lines.join('\n') + '\n'); + // Also write the ring-buffer .json that mode=raw reads + fs.writeFileSync( + path.join(histDir, 'DEV.json'), + JSON.stringify({ deviceId: 'DEV', latest: null, history: lines.map((l) => JSON.parse(l)) }), + ); + + const { client } = await pair(); + const res = await client.callTool({ + name: 'device_history', + arguments: { mode: 'raw', deviceId: 'DEV', limit: 3 }, + }); + expect(res.isError).toBeFalsy(); + const sc = (res as { structuredContent?: { history?: unknown[] } }).structuredContent; + expect(Array.isArray(sc?.history)).toBe(true); + expect(sc?.history?.length, 'limit=3 should bound history length').toBeLessThanOrEqual(3); + } finally { + vi.restoreAllMocks(); + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }); + + it('device_history mode=query rejects since combined with from/to', async () => { + const { client } = await pair(); + const res = await client.callTool({ + name: 'device_history', + arguments: { + mode: 'query', + deviceId: 'DEV1', + since: '1h', + from: '2026-01-01T00:00:00.000Z', + }, + }); + expect(res.isError).toBe(true); + const text = (res.content as Array<{ text: string }>)[0].text; + expect(text).toContain('mutually exclusive'); + }); + + it('device_history mode=aggregate rejects missing deviceId with a usage error', async () => { + const { client } = await pair(); + const res = await client.callTool({ + name: 'device_history', + arguments: { mode: 'aggregate', metrics: ['temperature'] }, + }); + expect(res.isError).toBe(true); + const text = (res.content as Array<{ text: string }>)[0].text; + expect(text).toContain('mode="aggregate" requires `deviceId`'); + }); + + it('device_history mode=query happy path returns count + records', async () => { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'mcp-query-')); + vi.spyOn(os, 'homedir').mockReturnValue(tmpHome); + try { + const histDir = path.join(tmpHome, '.switchbot', 'device-history'); + fs.mkdirSync(histDir, { recursive: true }); + const lines = [ + JSON.stringify({ t: '2026-04-19T10:00:00.000Z', topic: 't/Q1', payload: { temperature: 20 } }), + JSON.stringify({ t: '2026-04-19T10:30:00.000Z', topic: 't/Q1', payload: { temperature: 24 } }), + ]; + fs.writeFileSync(path.join(histDir, 'Q1.jsonl'), lines.join('\n') + '\n'); + + const { client } = await pair(); + const res = await client.callTool({ + name: 'device_history', + arguments: { + mode: 'query', + deviceId: 'Q1', + from: '2026-04-19T00:00:00.000Z', + to: '2026-04-20T00:00:00.000Z', + }, + }); + expect(res.isError).toBeFalsy(); + const sc = (res as { structuredContent?: { count?: number; records?: unknown[] } }) + .structuredContent; + expect(sc?.count).toBe(2); + expect(Array.isArray(sc?.records)).toBe(true); + } finally { + vi.restoreAllMocks(); + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }); + + it('device_history mode=aggregate forwards `bucket` and `maxBucketSamples` to the underlying helper', async () => { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'mcp-agg-bucket-')); + vi.spyOn(os, 'homedir').mockReturnValue(tmpHome); + try { + const histDir = path.join(tmpHome, '.switchbot', 'device-history'); + fs.mkdirSync(histDir, { recursive: true }); + // Need at least a few samples to populate buckets + const lines: string[] = []; + for (let i = 0; i < 4; i++) { + lines.push(JSON.stringify({ + t: `2026-04-19T10:0${i}:00.000Z`, topic: 't/AB', payload: { temperature: 20 + i }, + })); + } + fs.writeFileSync(path.join(histDir, 'AB.jsonl'), lines.join('\n') + '\n'); + + const { client } = await pair(); + const res = await client.callTool({ + name: 'device_history', + arguments: { + mode: 'aggregate', + deviceId: 'AB', + metrics: ['temperature'], + bucket: '1m', + maxBucketSamples: 50, + from: '2026-04-19T00:00:00.000Z', + to: '2026-04-20T00:00:00.000Z', + }, + }); + expect(res.isError).toBeFalsy(); + const sc = (res as { structuredContent?: { bucket?: string; buckets?: unknown[] } }) + .structuredContent; + expect(sc?.bucket, 'bucket should echo back when specified').toBe('1m'); + expect(Array.isArray(sc?.buckets)).toBe(true); + } finally { + vi.restoreAllMocks(); + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }); + + // --------------------------------------------------------------------------- + // G8: device_history cross-branch field leakage. mode=raw with stray + // aggregate-only fields should still take the raw branch and not crash; + // mode=query / mode=aggregate similarly ignore mode-mismatched fields. + // --------------------------------------------------------------------------- + + it('device_history mode=raw silently ignores stray `metrics`/`aggs` (no aggregate branch taken)', async () => { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'mcp-raw-leak-')); + vi.spyOn(os, 'homedir').mockReturnValue(tmpHome); + try { + const histDir = path.join(tmpHome, '.switchbot', 'device-history'); + fs.mkdirSync(histDir, { recursive: true }); + fs.writeFileSync(path.join(histDir, 'L1.json'), + JSON.stringify({ deviceId: 'L1', latest: null, history: [] })); + + const { client } = await pair(); + const res = await client.callTool({ + name: 'device_history', + arguments: { + mode: 'raw', + deviceId: 'L1', + // these belong to mode=aggregate but should be ignored + metrics: ['temperature'], + aggs: ['avg'], + bucket: '1h', + }, + }); + expect(res.isError).toBeFalsy(); + const sc = (res as { structuredContent?: { deviceId?: string; buckets?: unknown[] } }) + .structuredContent; + // raw shape: deviceId set, no buckets array + expect(sc?.deviceId).toBe('L1'); + expect(sc?.buckets, 'mode=raw must NOT produce aggregate buckets').toBeUndefined(); + } finally { + vi.restoreAllMocks(); + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }); + // --------------------------------------------------------------------------- // Bug #38: structured error metadata preserved in MCP tool responses // --------------------------------------------------------------------------- diff --git a/tests/commands/strict-schemas.test.ts b/tests/commands/strict-schemas.test.ts index 6f3a0bc8..ac34526b 100644 --- a/tests/commands/strict-schemas.test.ts +++ b/tests/commands/strict-schemas.test.ts @@ -192,3 +192,147 @@ describe('MCP strict schemas — all tools reject unknown keys', () => { await assertRejectsUnknownKey(client, 'audit_stats', {}); }); }); + +// --------------------------------------------------------------------------- +// G1: Discriminator schema validation for the consolidated tools. +// Each new tool requires `action`/`period`/`mode`. Both "missing" and +// "invalid value" must be rejected by the input schema layer (-32602), not +// silently routed to a default branch. +// --------------------------------------------------------------------------- + +/** Assert that a tool call returns a schema validation error (-32602 family). */ +async function assertSchemaReject( + client: Client, + toolName: string, + args: Record, + pattern: RegExp = /-32602|invalid|Invalid|Required|enum|Expected/i, +) { + const res = await client.callTool({ name: toolName, arguments: args }); + expect(res.isError, `${toolName} ${JSON.stringify(args)}: expected isError to be true`).toBe(true); + const text = (res.content as Array<{ type: string; text: string }>)[0].text; + expect(text, `${toolName} ${JSON.stringify(args)}: expected schema-level error`).toMatch(pattern); +} + +describe('G1: discriminator schema validation for consolidated tools', () => { + beforeEach(() => { + apiMock.__instance.get.mockReset(); + apiMock.__instance.post.mockReset(); + }); + + it('mindclip_recordings rejects missing action', async () => { + const { client } = await pair(); + await assertSchemaReject(client, 'mindclip_recordings', {}); + }); + + it('mindclip_recordings rejects invalid action value', async () => { + const { client } = await pair(); + await assertSchemaReject(client, 'mindclip_recordings', { action: 'bogus' }); + }); + + it('mindclip_recall rejects missing period', async () => { + const { client } = await pair(); + await assertSchemaReject(client, 'mindclip_recall', {}); + }); + + it('mindclip_recall rejects invalid period value', async () => { + const { client } = await pair(); + await assertSchemaReject(client, 'mindclip_recall', { period: 'monthly' }); + }); + + it('device_history rejects missing mode', async () => { + const { client } = await pair(); + await assertSchemaReject(client, 'device_history', {}); + }); + + it('device_history rejects invalid mode value', async () => { + const { client } = await pair(); + await assertSchemaReject(client, 'device_history', { mode: 'firehose' }); + }); +}); + +// --------------------------------------------------------------------------- +// G7: Boundary tests for filter ranges and regex patterns. These guard +// against accidentally relaxing min/max bounds or regex anchors during +// future refactors of the consolidated input schemas. +// --------------------------------------------------------------------------- + +describe('G7: boundary values on consolidated tool filters', () => { + beforeEach(() => { + apiMock.__instance.get.mockReset(); + apiMock.__instance.post.mockReset(); + }); + + // ── pageSize / pageNum on mindclip_recordings (action=list) and mindclip_list_todos + it('mindclip_list_todos rejects pageSize=0', async () => { + const { client } = await pair(); + await assertSchemaReject(client, 'mindclip_list_todos', { pageSize: 0 }); + }); + + it('mindclip_list_todos rejects pageSize=101', async () => { + const { client } = await pair(); + await assertSchemaReject(client, 'mindclip_list_todos', { pageSize: 101 }); + }); + + it('mindclip_list_todos accepts pageSize=1 (min boundary)', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ data: { body: {} } }); + const { client } = await pair(); + const res = await client.callTool({ name: 'mindclip_list_todos', arguments: { pageSize: 1 } }); + expect(res.isError).toBeFalsy(); + }); + + it('mindclip_list_todos accepts pageSize=100 (max boundary)', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ data: { body: {} } }); + const { client } = await pair(); + const res = await client.callTool({ name: 'mindclip_list_todos', arguments: { pageSize: 100 } }); + expect(res.isError).toBeFalsy(); + }); + + it('mindclip_list_todos rejects pageNum=0', async () => { + const { client } = await pair(); + await assertSchemaReject(client, 'mindclip_list_todos', { pageNum: 0 }); + }); + + it('mindclip_list_todos rejects completedNum=3', async () => { + const { client } = await pair(); + await assertSchemaReject(client, 'mindclip_list_todos', { completedNum: 3 }); + }); + + it('mindclip_list_todos accepts completedNum=0..2', async () => { + apiMock.__instance.get.mockResolvedValue({ data: { body: {} } }); + const { client } = await pair(); + for (const v of [0, 1, 2]) { + const res = await client.callTool({ name: 'mindclip_list_todos', arguments: { completedNum: v } }); + expect(res.isError, `completedNum=${v}`).toBeFalsy(); + } + }); + + it('mindclip_list_todos rejects category=6', async () => { + const { client } = await pair(); + await assertSchemaReject(client, 'mindclip_list_todos', { category: 6 }); + }); + + // ── date / week regex on mindclip_recall + it('mindclip_recall rejects malformed date (slashes)', async () => { + const { client } = await pair(); + await assertSchemaReject(client, 'mindclip_recall', { period: 'daily', date: '2026/06/13' }); + }); + + it('mindclip_recall rejects week=2026-W00 (out of regex range)', async () => { + const { client } = await pair(); + await assertSchemaReject(client, 'mindclip_recall', { period: 'weekly', week: '2026-W00' }); + }); + + it('mindclip_recall rejects week=2026-W54 (out of regex range)', async () => { + const { client } = await pair(); + await assertSchemaReject(client, 'mindclip_recall', { period: 'weekly', week: '2026-W54' }); + }); + + it('mindclip_recall accepts week=2026-W01 and week=2026-W53 (regex boundaries)', async () => { + apiMock.__instance.get.mockResolvedValue({ data: { body: {} } }); + const { client } = await pair(); + for (const w of ['2026-W01', '2026-W53']) { + const res = await client.callTool({ name: 'mindclip_recall', arguments: { period: 'weekly', week: w } }); + expect(res.isError, `week=${w}`).toBeFalsy(); + } + }); +}); diff --git a/tests/install/claude-code-checks.test.ts b/tests/install/claude-code-checks.test.ts new file mode 100644 index 00000000..98e76dcb --- /dev/null +++ b/tests/install/claude-code-checks.test.ts @@ -0,0 +1,55 @@ +/** + * Asserts `claude-code-checks.registerMcp` invokes `claude mcp add` with the + * default-profile args (`mcp serve`, no `--tools all`). The v3.8.0 + * consolidation moved admin tools (policy / audit / rules) behind opt-in; + * this test guards against accidentally re-adding `--tools all` to the + * `claude mcp add ...` command line. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const spawnSyncMock = vi.hoisted(() => vi.fn()); +vi.mock('node:child_process', () => ({ spawnSync: spawnSyncMock })); + +import { registerMcp } from '../../src/install/claude-code-checks.js'; + +beforeEach(() => { + spawnSyncMock.mockReset(); +}); + +describe('claude-code-checks.registerMcp', () => { + it('invokes `claude mcp add` with the default profile (no --tools all)', () => { + // First spawnSync call is `claude mcp list`; return "not registered yet". + spawnSyncMock.mockReturnValueOnce({ + status: 0, stdout: 'no servers', stderr: '', pid: 1, output: [], signal: null, + }); + // Second call is `claude mcp add ...`; succeed. + spawnSyncMock.mockReturnValueOnce({ + status: 0, stdout: '', stderr: '', pid: 1, output: [], signal: null, + }); + + const result = registerMcp(); + expect(result.ok).toBe(true); + expect(result.alreadyRegistered).toBeUndefined(); + + // Inspect the `claude mcp add` invocation (second call). + const addCall = spawnSyncMock.mock.calls[1]; + expect(addCall, 'expected a second spawnSync call for `claude mcp add`').toBeDefined(); + const [cmd, args] = addCall as [string, string[], unknown]; + expect(cmd).toBe('claude'); + // `claude mcp add --scope user switchbot -- switchbot mcp serve` + expect(args).toEqual(['mcp', 'add', '--scope', 'user', 'switchbot', '--', 'switchbot', 'mcp', 'serve']); + expect(args, 'must NOT include --tools all').not.toContain('--tools'); + expect(args, 'must NOT include "all" as a positional token').not.toContain('all'); + }); + + it('returns alreadyRegistered:true when `claude mcp list` already lists switchbot', () => { + spawnSyncMock.mockReturnValueOnce({ + status: 0, stdout: 'switchbot: registered', stderr: '', pid: 1, output: [], signal: null, + }); + const result = registerMcp(); + expect(result.ok).toBe(true); + expect(result.alreadyRegistered).toBe(true); + // Only one spawn call: the `claude mcp list` probe; no `claude mcp add`. + expect(spawnSyncMock.mock.calls).toHaveLength(1); + }); +}); diff --git a/tests/mcp/retired-tools.test.ts b/tests/mcp/retired-tools.test.ts new file mode 100644 index 00000000..4836c267 --- /dev/null +++ b/tests/mcp/retired-tools.test.ts @@ -0,0 +1,116 @@ +/** + * Retired tools contract: 9 MCP tool names retired by the v3.8.0 consolidation + * must NOT be registered, regardless of profile. They were replaced by: + * - mindclip_list_recordings, mindclip_get_recording, mindclip_get_summary + * -> mindclip_recordings (action: list | get | summary) + * - mindclip_daily_recall, mindclip_weekly_summary, mindclip_urgent_todos + * -> mindclip_recall (period: daily | weekly | urgent_todos) + * - get_device_history, query_device_history, aggregate_device_history + * -> device_history (mode: raw | query | aggregate) + * + * If any of these names ever returns to the registered set, this test guards + * the breaking-change contract documented in CHANGELOG [Unreleased]. + */ +import { describe, it, expect, vi } from 'vitest'; + +const apiMock = vi.hoisted(() => { + const instance = { get: vi.fn(), post: vi.fn() }; + return { + createClient: vi.fn(() => instance), + __instance: instance, + }; +}); + +vi.mock('../../src/api/client.js', () => ({ + createClient: apiMock.createClient, + ApiError: class ApiError extends Error { + constructor(message: string, public readonly code: number) { + super(message); + this.name = 'ApiError'; + } + }, + DryRunSignal: class DryRunSignal extends Error { + constructor(public readonly method: string, public readonly url: string) { + super('dry-run'); + this.name = 'DryRunSignal'; + } + }, +})); + +vi.mock('../../src/devices/cache.js', () => ({ + getCachedDevice: vi.fn(() => null), + updateCacheFromDeviceList: vi.fn(), + loadCache: vi.fn(() => null), + clearCache: vi.fn(), + isListCacheFresh: vi.fn(() => false), + listCacheAgeMs: vi.fn(() => null), + getCachedStatus: vi.fn(() => null), + setCachedStatus: vi.fn(), + clearStatusCache: vi.fn(), + loadStatusCache: vi.fn(() => ({ entries: {} })), + describeCache: vi.fn(() => ({ + list: { path: '', exists: false }, + status: { path: '', exists: false, entryCount: 0 }, + })), +})); + +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { createSwitchBotMcpServer, listRegisteredTools } from '../../src/commands/mcp.js'; + +const RETIRED = [ + 'mindclip_list_recordings', + 'mindclip_get_recording', + 'mindclip_get_summary', + 'mindclip_daily_recall', + 'mindclip_weekly_summary', + 'mindclip_urgent_todos', + 'get_device_history', + 'query_device_history', + 'aggregate_device_history', +] as const; + +async function pair(toolProfile: 'default' | 'readonly' | 'all' = 'all') { + const server = createSwitchBotMcpServer({ toolProfile }); + const client = new Client({ name: 'retired-test', version: '0.0.1' }); + const [clientT, serverT] = InMemoryTransport.createLinkedPair(); + await Promise.all([server.connect(serverT), client.connect(clientT)]); + return { server, client }; +} + +describe('retired MCP tool names (v3.8.0 consolidation contract)', () => { + it.each(RETIRED)('%s is not in createSwitchBotMcpServer({ toolProfile: "all" })', (name) => { + const server = createSwitchBotMcpServer({ toolProfile: 'all' }); + const tools = listRegisteredTools(server); + expect(tools, `retired tool ${name} must not be registered under "all" profile`).not.toContain(name); + }); + + it.each(RETIRED)('%s is absent from listTools() over the wire', async (name) => { + const { client } = await pair('all'); + const { tools } = await client.listTools(); + const names = tools.map((t) => t.name); + expect(names, `retired tool ${name} must not appear in listTools()`).not.toContain(name); + }); + + it.each(RETIRED)('%s callTool returns method-not-found / -32601', async (name) => { + const { client } = await pair('all'); + let caught: unknown; + try { + // The SDK throws on tools/call against unregistered names rather than + // returning an isError content envelope (the latter is for tool-handler + // errors). Either shape is acceptable; both must surface "not found". + const res = await client.callTool({ name, arguments: {} }); + // If the SDK ever stops throwing here, the result must still report error. + expect(res.isError, `${name} should not be invokable`).toBe(true); + const text = (res.content as Array<{ text: string }>)[0]?.text ?? ''; + expect(text).toMatch(/-32601|not found|unknown tool|Method not found/i); + return; + } catch (err) { + caught = err; + } + const msg = caught instanceof Error ? caught.message : String(caught); + expect(msg, `${name} expected method-not-found in error`).toMatch( + /-32601|not found|unknown tool|Method not found/i, + ); + }); +}); diff --git a/tests/mcp/tool-schema-completeness.test.ts b/tests/mcp/tool-schema-completeness.test.ts index bfcebe3d..55a43a6c 100644 --- a/tests/mcp/tool-schema-completeness.test.ts +++ b/tests/mcp/tool-schema-completeness.test.ts @@ -152,4 +152,57 @@ describe('MCP tool schema completeness', () => { expect((props[prop].description ?? '').length).toBeGreaterThan(0); } }); + + it('mindclip_recordings describes every input argument (P4 regression guard)', () => { + const tool = tools.find((t) => t.name === 'mindclip_recordings'); + expect(tool, 'mindclip_recordings must be registered').toBeDefined(); + const props = tool!.inputSchema?.properties ?? {}; + const expected = [ + 'action', + 'id', + 'language', + 'deviceID', + 'pageNum', + 'pageSize', + 'startTime', + 'endTime', + 'folderID', + ]; + for (const prop of expected) { + expect(props[prop], `${prop} should appear in mindclip_recordings inputSchema`).toBeDefined(); + expect( + props[prop].description, + `${prop}.description should be a non-empty string`, + ).toBeTypeOf('string'); + expect((props[prop].description ?? '').length).toBeGreaterThan(0); + } + // action is the discriminator and must be required + an enum + expect(tool!.inputSchema?.required, 'action must be required').toContain('action'); + expect(props.action.enum, 'action must be enum-typed').toEqual(['list', 'get', 'summary']); + }); + + it('mindclip_recall describes every input argument (P4 regression guard)', () => { + const tool = tools.find((t) => t.name === 'mindclip_recall'); + expect(tool, 'mindclip_recall must be registered').toBeDefined(); + const props = tool!.inputSchema?.properties ?? {}; + const expected = ['period', 'date', 'week']; + for (const prop of expected) { + expect(props[prop], `${prop} should appear in mindclip_recall inputSchema`).toBeDefined(); + expect( + props[prop].description, + `${prop}.description should be a non-empty string`, + ).toBeTypeOf('string'); + expect((props[prop].description ?? '').length).toBeGreaterThan(0); + } + expect(tool!.inputSchema?.required, 'period must be required').toContain('period'); + expect(props.period.enum, 'period must be enum-typed').toEqual(['daily', 'weekly', 'urgent_todos']); + }); + + it('device_history surfaces the mode discriminator as required enum', () => { + const dh = tools.find((t) => t.name === 'device_history'); + expect(dh).toBeDefined(); + const props = dh!.inputSchema?.properties ?? {}; + expect(dh!.inputSchema?.required, 'mode must be required').toContain('mode'); + expect(props.mode.enum, 'mode must be enum-typed').toEqual(['raw', 'query', 'aggregate']); + }); }); From ad4f97fc1a06859a88bd88d33544512b78f3405c Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 13 Jun 2026 20:18:31 +0800 Subject: [PATCH 25/57] feat(mcp): include 3 legacy device_history names in read profile for alias registration --- src/mcp/tool-profiles.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/mcp/tool-profiles.ts b/src/mcp/tool-profiles.ts index 98c3c735..614e6ee3 100644 --- a/src/mcp/tool-profiles.ts +++ b/src/mcp/tool-profiles.ts @@ -12,6 +12,10 @@ const CORE_READ = [ 'mindclip_recordings', 'mindclip_list_todos', 'mindclip_recall', + // ---- deprecated aliases (3.x backward-compat; removed in 4.0.0) ---- + 'get_device_history', + 'query_device_history', + 'aggregate_device_history', ] as const; const CORE_ACTION = ['send_command', 'run_scene', 'plan_run'] as const; From c2f02803a31194ff110139e20c3eb6f9b41937f0 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 13 Jun 2026 20:24:37 +0800 Subject: [PATCH 26/57] =?UTF-8?q?test(mcp):=20add=20legacy-alias=20contrac?= =?UTF-8?q?t=20test=20for=20device=5Fhistory=20(failing=20=E2=80=94=20regi?= =?UTF-8?q?strations=20land=20in=20next=20commit)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/mcp/legacy-aliases.test.ts | 159 +++++++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 tests/mcp/legacy-aliases.test.ts diff --git a/tests/mcp/legacy-aliases.test.ts b/tests/mcp/legacy-aliases.test.ts new file mode 100644 index 00000000..edfd97de --- /dev/null +++ b/tests/mcp/legacy-aliases.test.ts @@ -0,0 +1,159 @@ +/** + * Legacy alias contract: 3 device_history MCP tool names retired by the 3.8.0 + * consolidation are kept registered as deprecated aliases that delegate to the + * consolidated `device_history` tool. Removal is scheduled for 4.0.0 + * (see CHANGELOG). + * + * - get_device_history -> device_history(mode="raw") + * - query_device_history -> device_history(mode="query") + * - aggregate_device_history -> device_history(mode="aggregate") + * + * The 6 retired mindclip names (mindclip_list_recordings / _get_recording / + * _get_summary / _daily_recall / _weekly_summary / _urgent_todos) are NOT + * aliased — they were both added and renamed on the unreleased 3.8.0 branch, + * so no published 3.x client could have used them. This test also guards + * against accidentally re-registering them (extra schemas = wasted tokens). + */ +import { describe, it, expect, vi } from 'vitest'; + +const apiMock = vi.hoisted(() => { + const instance = { get: vi.fn(), post: vi.fn() }; + return { + createClient: vi.fn(() => instance), + __instance: instance, + }; +}); + +vi.mock('../../src/api/client.js', () => ({ + createClient: apiMock.createClient, + ApiError: class ApiError extends Error { + constructor(message: string, public readonly code: number) { + super(message); + this.name = 'ApiError'; + } + }, + DryRunSignal: class DryRunSignal extends Error { + constructor(public readonly method: string, public readonly url: string) { + super('dry-run'); + this.name = 'DryRunSignal'; + } + }, +})); + +vi.mock('../../src/devices/cache.js', () => ({ + getCachedDevice: vi.fn(() => null), + updateCacheFromDeviceList: vi.fn(), + loadCache: vi.fn(() => null), + clearCache: vi.fn(), + isListCacheFresh: vi.fn(() => false), + listCacheAgeMs: vi.fn(() => null), + getCachedStatus: vi.fn(() => null), + setCachedStatus: vi.fn(), + clearStatusCache: vi.fn(), + loadStatusCache: vi.fn(() => ({ entries: {} })), + describeCache: vi.fn(() => ({ + list: { path: '', exists: false }, + status: { path: '', exists: false, entryCount: 0 }, + })), +})); + +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { + createSwitchBotMcpServer, + listRegisteredTools, +} from '../../src/commands/mcp.js'; + +const ALIASES = [ + { old: 'get_device_history', discriminator: 'mode="raw"' }, + { old: 'query_device_history', discriminator: 'mode="query"' }, + { old: 'aggregate_device_history', discriminator: 'mode="aggregate"' }, +] as const; + +const NEVER_SHIPPED = [ + 'mindclip_list_recordings', + 'mindclip_get_recording', + 'mindclip_get_summary', + 'mindclip_daily_recall', + 'mindclip_weekly_summary', + 'mindclip_urgent_todos', +] as const; + +async function pair(toolProfile: 'default' | 'readonly' | 'all' = 'all') { + const server = createSwitchBotMcpServer({ toolProfile }); + const client = new Client({ name: 'alias-test', version: '0.0.1' }); + const [clientT, serverT] = InMemoryTransport.createLinkedPair(); + await Promise.all([server.connect(serverT), client.connect(clientT)]); + return { server, client }; +} + +describe('legacy device_history aliases (3.8.0 backward-compat contract)', () => { + it.each(ALIASES.map((a) => a.old))( + '%s is registered under readonly / default / all', + (name) => { + for (const profile of ['readonly', 'default', 'all'] as const) { + const server = createSwitchBotMcpServer({ toolProfile: profile }); + const tools = listRegisteredTools(server); + expect( + tools, + `alias ${name} must be registered under profile=${profile}`, + ).toContain(name); + } + }, + ); + + it.each(ALIASES.map((a) => a.old))( + '%s appears in listTools() over the wire', + async (name) => { + const { client } = await pair('all'); + const { tools } = await client.listTools(); + const names = tools.map((t) => t.name); + expect(names, `alias ${name} must appear in listTools()`).toContain(name); + }, + ); + + it.each(ALIASES)( + '$old description starts with [DEPRECATED ...] and references device_history($discriminator)', + async ({ old, discriminator }) => { + const { client } = await pair('all'); + const { tools } = await client.listTools(); + const t = tools.find((x) => x.name === old); + expect(t, `alias ${old} not found`).toBeDefined(); + expect(t!.description ?? '').toMatch(/^\[DEPRECATED/i); + expect(t!.description ?? '').toContain('device_history'); + expect(t!.description ?? '').toContain(discriminator); + }, + ); + + it.each(ALIASES)( + '$old declares _meta.deprecated=true and _meta.replacement="device_history"', + async ({ old }) => { + const { client } = await pair('all'); + const { tools } = await client.listTools(); + const t = tools.find((x) => x.name === old) as + | { name: string; _meta?: { deprecated?: boolean; replacement?: string } } + | undefined; + expect(t, `alias ${old} not found`).toBeDefined(); + expect(t!._meta?.deprecated).toBe(true); + expect(t!._meta?.replacement).toBe('device_history'); + }, + ); + + it('get_device_history with no args produces the same shape as device_history({mode:"raw"})', async () => { + const { client } = await pair('all'); + const aliasResp = await client.callTool({ name: 'get_device_history', arguments: {} }); + const consolidatedResp = await client.callTool({ name: 'device_history', arguments: { mode: 'raw' } }); + expect(aliasResp.structuredContent).toEqual(consolidatedResp.structuredContent); + }); +}); + +describe('mindclip retired names (never shipped — must NOT be re-registered)', () => { + it.each(NEVER_SHIPPED)('%s is absent from listRegisteredTools under all profile', (name) => { + const server = createSwitchBotMcpServer({ toolProfile: 'all' }); + const tools = listRegisteredTools(server); + expect( + tools, + `${name} was never published — it must not be registered (would bloat schemas without compat benefit)`, + ).not.toContain(name); + }); +}); From d401bc0c7e2cdb08ae6e5fd9cba767575402ae3d Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 13 Jun 2026 20:34:40 +0800 Subject: [PATCH 27/57] feat(mcp): re-add device_history aliases (get_/query_/aggregate_) for 3.x compat Extract query and aggregate branches into module-level helpers (runDeviceHistoryQuery, runDeviceHistoryAggregate) so the 3 deprecated alias tools can delegate without duplicating logic. Register the 3 aliases with deprecated=true _meta and replacement="device_history". Delete retired-tools.test.ts; its coverage is now in legacy-aliases.test.ts. Update mcp.test.ts tool-count assertion (25 -> 28). --- src/commands/mcp.ts | 253 ++++++++++++++++++++++++------- tests/commands/mcp.test.ts | 5 +- tests/mcp/legacy-aliases.test.ts | 45 ++++++ tests/mcp/retired-tools.test.ts | 116 -------------- 4 files changed, 249 insertions(+), 170 deletions(-) delete mode 100644 tests/mcp/retired-tools.test.ts diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index 2cbf0fad..1dbd724e 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -257,6 +257,86 @@ function buildRiskProfile( }; } +// ---- device_history module-level helpers ------------------------------------ +// Extracted so deprecated alias tools can delegate without re-implementing +// the same logic. Bodies are verbatim copies from the consolidated handler. + +async function runDeviceHistoryQuery(args: { + deviceId: string; + since?: string; + from?: string; + to?: string; + fields?: string[]; + limit?: number; +}) { + if (!args.deviceId) { + return mcpError('usage', 2, 'device_history: mode="query" requires `deviceId`.'); + } + if (args.since && (args.from || args.to)) { + return mcpError('usage', 2, 'device_history: `since` is mutually exclusive with `from`/`to`.'); + } + try { + const records = await queryDeviceHistory(args.deviceId, { + since: args.since, + from: args.from, + to: args.to, + fields: args.fields, + limit: args.limit, + }); + const result = { deviceId: args.deviceId, count: records.length, records }; + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + structuredContent: result, + }; + } catch (err) { + const msg = err instanceof Error ? err.message : 'history query failed'; + return mcpError('usage', 2, msg); + } +} + +async function runDeviceHistoryAggregate(args: { + deviceId: string; + since?: string; + from?: string; + to?: string; + metrics: string[]; + aggs?: AggFn[]; + bucket?: string; + maxBucketSamples?: number; +}) { + if (!args.deviceId) { + return mcpError('usage', 2, 'device_history: mode="aggregate" requires `deviceId`.'); + } + if (!args.metrics || args.metrics.length === 0) { + return mcpError('usage', 2, 'device_history: mode="aggregate" requires at least one entry in `metrics`.'); + } + const opts: AggOptions = { + since: args.since, + from: args.from, + to: args.to, + metrics: args.metrics, + aggs: args.aggs, + bucket: args.bucket, + maxBucketSamples: args.maxBucketSamples, + }; + const res = await aggregateDeviceHistory(args.deviceId, opts); + const structured: Record = { + deviceId: res.deviceId, + from: res.from, + to: res.to, + metrics: res.metrics, + aggs: res.aggs, + buckets: res.buckets, + partial: res.partial, + notes: res.notes, + }; + if (res.bucket !== undefined) structured.bucket = res.bucket; + return { + content: [{ type: 'text' as const, text: JSON.stringify(res, null, 2) }], + structuredContent: structured, + }; +} + export function createSwitchBotMcpServer(options?: { eventManager?: EventSubscriptionManager; toolProfile?: ToolProfile }): McpServer { const eventManager = options?.eventManager; const allowedTools = TOOL_PROFILES[options?.toolProfile ?? 'default']; @@ -471,67 +551,134 @@ Tool profile: ${profileName} (${allowedTools.size} tools loaded).${profileName ! } // ---- query mode ---------------------------------------------------- - if (args.mode === 'query') { - if (!args.deviceId) { - return mcpError('usage', 2, 'device_history: mode="query" requires `deviceId`.'); - } - if (args.since && (args.from || args.to)) { - return mcpError('usage', 2, 'device_history: `since` is mutually exclusive with `from`/`to`.'); - } - try { - const records = await queryDeviceHistory(args.deviceId, { - since: args.since, - from: args.from, - to: args.to, - fields: args.fields, - limit: args.limit, - }); - const result = { deviceId: args.deviceId, count: records.length, records }; - return { - content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], - structuredContent: result, - }; - } catch (err) { - const msg = err instanceof Error ? err.message : 'history query failed'; - return mcpError('usage', 2, msg); - } - } + if (args.mode === 'query') return runDeviceHistoryQuery(args as Parameters[0]); // ---- aggregate mode ------------------------------------------------ - if (!args.deviceId) { - return mcpError('usage', 2, 'device_history: mode="aggregate" requires `deviceId`.'); - } - if (!args.metrics || args.metrics.length === 0) { - return mcpError('usage', 2, 'device_history: mode="aggregate" requires at least one entry in `metrics`.'); + return runDeviceHistoryAggregate(args as Parameters[0]); + } + ); + + // ---- deprecated aliases for device_history ------------------------------ + // 3.x backward-compat — removed in 4.0.0. Each alias forwards to the + // consolidated handler with `mode` hardcoded. Schemas mirror the relevant + // subset of the device_history schema so old clients keep their shape. + if (!skip('get_device_history')) + server.registerTool( + 'get_device_history', + { + title: '[Deprecated] Latest + recent device history', + description: + '[DEPRECATED — use device_history(mode="raw")]. ' + + 'Read the latest entry plus the most recent N records for one device, or list devices with stored history when deviceId is omitted. ' + + 'No API call — zero quota cost.', + _meta: { agentSafetyTier: 'read', deprecated: true, replacement: 'device_history' }, + inputSchema: z.object({ + deviceId: z.string().optional(), + limit: z.number().int().min(1).max(100).optional(), + }).strict(), + outputSchema: { + deviceId: z.string().optional(), + latest: z.unknown().optional(), + history: z.array(z.unknown()).optional(), + devices: z.array(z.object({ deviceId: z.string(), latest: z.unknown() })).optional(), + }, + }, + async (args) => { + if (args.deviceId) { + const latest = deviceHistoryStore.getLatest(args.deviceId); + const history = deviceHistoryStore.getHistory(args.deviceId, args.limit ?? 20); + const result = { deviceId: args.deviceId, latest, history }; + return { + content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], + structuredContent: result, + }; } - const opts: AggOptions = { - since: args.since, - from: args.from, - to: args.to, - metrics: args.metrics, - aggs: args.aggs, - bucket: args.bucket, - maxBucketSamples: args.maxBucketSamples, - }; - const res = await aggregateDeviceHistory(args.deviceId, opts); - const structured: Record = { - deviceId: res.deviceId, - from: res.from, - to: res.to, - metrics: res.metrics, - aggs: res.aggs, - buckets: res.buckets, - partial: res.partial, - notes: res.notes, - }; - if (res.bucket !== undefined) structured.bucket = res.bucket; + const ids = deviceHistoryStore.listDevices(); + const devices = ids.map((id) => ({ deviceId: id, latest: deviceHistoryStore.getLatest(id) })); + const result = { devices }; return { - content: [{ type: 'text', text: JSON.stringify(res, null, 2) }], - structuredContent: structured, + content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], + structuredContent: result, }; } ); + if (!skip('query_device_history')) + server.registerTool( + 'query_device_history', + { + title: '[Deprecated] Time-ranged device history query', + description: + '[DEPRECATED — use device_history(mode="query")]. ' + + 'Return time-ranged records (since OR from/to) with optional field projection and limit. No API call.', + _meta: { agentSafetyTier: 'read', deprecated: true, replacement: 'device_history' }, + inputSchema: z.object({ + deviceId: z.string(), + since: z.string().optional(), + from: z.string().optional(), + to: z.string().optional(), + fields: z.array(z.string()).optional(), + limit: z.number().int().min(1).max(10000).optional(), + }).strict(), + outputSchema: { + deviceId: z.string(), + count: z.number().int(), + records: z.array(z.object({ + t: z.string(), + topic: z.string(), + deviceType: z.string().optional(), + payload: z.unknown(), + })), + notes: z.array(z.string()).optional(), + }, + }, + async (args) => runDeviceHistoryQuery(args) + ); + + if (!skip('aggregate_device_history')) + server.registerTool( + 'aggregate_device_history', + { + title: '[Deprecated] Bucketed device-history aggregation', + description: + '[DEPRECATED — use device_history(mode="aggregate")]. ' + + 'Return bucketed statistics (count/min/max/avg/sum/p50/p95) over numeric metrics. No API call.', + _meta: { agentSafetyTier: 'read', deprecated: true, replacement: 'device_history' }, + inputSchema: z.object({ + deviceId: z.string(), + since: z.string().optional(), + from: z.string().optional(), + to: z.string().optional(), + metrics: z.array(z.string().min(1)), + aggs: z.array(z.enum(ALL_AGG_FNS as unknown as [AggFn, ...AggFn[]])).optional(), + bucket: z.string().optional(), + maxBucketSamples: z.number().int().positive().max(MAX_SAMPLE_CAP).optional(), + }).strict(), + outputSchema: { + deviceId: z.string(), + bucket: z.string().optional(), + from: z.string().optional(), + to: z.string().optional(), + metrics: z.array(z.string()), + aggs: z.array(z.enum(ALL_AGG_FNS as unknown as [AggFn, ...AggFn[]])), + buckets: z.array(z.object({ + t: z.string(), + metrics: z.record(z.string(), z.object({ + count: z.number().optional(), + min: z.number().optional(), + max: z.number().optional(), + avg: z.number().optional(), + sum: z.number().optional(), + p50: z.number().optional(), + p95: z.number().optional(), + })), + })), + partial: z.boolean().optional(), + }, + }, + async (args) => runDeviceHistoryAggregate(args) + ); + // ---- send_command --------------------------------------------------------- if (!skip('send_command')) server.registerTool( diff --git a/tests/commands/mcp.test.ts b/tests/commands/mcp.test.ts index f1ed0a01..38c3170e 100644 --- a/tests/commands/mcp.test.ts +++ b/tests/commands/mcp.test.ts @@ -163,7 +163,7 @@ describe('mcp server', () => { delete process.env.SWITCHBOT_ALLOW_DIRECT_DESTRUCTIVE; }); - it('exposes the twenty-five tools with titles and input schemas', async () => { + it('exposes the twenty-eight tools with titles and input schemas', async () => { const { client } = await pair(); const { tools } = await client.listTools(); @@ -171,10 +171,12 @@ describe('mcp server', () => { expect(names).toEqual( [ 'account_overview', + 'aggregate_device_history', 'audit_query', 'audit_stats', 'describe_device', 'device_history', + 'get_device_history', 'get_device_status', 'list_devices', 'list_scenes', @@ -188,6 +190,7 @@ describe('mcp server', () => { 'policy_migrate', 'policy_new', 'policy_validate', + 'query_device_history', 'rule_notifications', 'rules_explain', 'rules_simulate', diff --git a/tests/mcp/legacy-aliases.test.ts b/tests/mcp/legacy-aliases.test.ts index edfd97de..3e0e58e9 100644 --- a/tests/mcp/legacy-aliases.test.ts +++ b/tests/mcp/legacy-aliases.test.ts @@ -134,6 +134,7 @@ describe('legacy device_history aliases (3.8.0 backward-compat contract)', () => | { name: string; _meta?: { deprecated?: boolean; replacement?: string } } | undefined; expect(t, `alias ${old} not found`).toBeDefined(); + expect(t!._meta, `${old}: _meta not transmitted by SDK — check Tool type definition`).toBeDefined(); expect(t!._meta?.deprecated).toBe(true); expect(t!._meta?.replacement).toBe('device_history'); }, @@ -145,6 +146,33 @@ describe('legacy device_history aliases (3.8.0 backward-compat contract)', () => const consolidatedResp = await client.callTool({ name: 'device_history', arguments: { mode: 'raw' } }); expect(aliasResp.structuredContent).toEqual(consolidatedResp.structuredContent); }); + + it('query_device_history forwards equivalently to device_history({mode:"query"})', async () => { + const { client } = await pair('all'); + // Query without setting up real device history; both calls should produce + // the same shape (likely an error envelope or empty-records envelope). + const args = { deviceId: 'NO-SUCH-DEVICE', since: '1h' }; + const aliasResp = await client.callTool({ name: 'query_device_history', arguments: args }); + const consolidatedResp = await client.callTool({ name: 'device_history', arguments: { mode: 'query', ...args } }); + expect(aliasResp.structuredContent).toEqual(consolidatedResp.structuredContent); + expect(aliasResp.isError).toBe(consolidatedResp.isError); + }); + + it('aggregate_device_history forwards equivalently to device_history({mode:"aggregate"})', async () => { + const { client } = await pair('all'); + const args = { deviceId: 'NO-SUCH-DEVICE', since: '1h', metrics: ['temperature'] }; + const aliasResp = await client.callTool({ name: 'aggregate_device_history', arguments: args }); + const consolidatedResp = await client.callTool({ name: 'device_history', arguments: { mode: 'aggregate', ...args } }); + expect(aliasResp.isError).toBe(consolidatedResp.isError); + // Both responses should have the same structure. Timestamps (from/to) will + // differ by a few ms across two separate calls, so compare the stable fields. + const stable = (sc: unknown) => { + if (!sc || typeof sc !== 'object') return sc; + const { from: _f, to: _t, ...rest } = sc as Record; + return rest; + }; + expect(stable(aliasResp.structuredContent)).toEqual(stable(consolidatedResp.structuredContent)); + }); }); describe('mindclip retired names (never shipped — must NOT be re-registered)', () => { @@ -156,4 +184,21 @@ describe('mindclip retired names (never shipped — must NOT be re-registered)', `${name} was never published — it must not be registered (would bloat schemas without compat benefit)`, ).not.toContain(name); }); + + it('mindclip_list_recordings callTool returns method-not-found / -32601 (representative)', async () => { + const { client } = await pair('all'); + let caught: unknown; + try { + const res = await client.callTool({ name: 'mindclip_list_recordings', arguments: {} }); + // SDK may return error envelope rather than throwing — either is acceptable, both must say "not found". + expect(res.isError, 'mindclip_list_recordings should not be invokable').toBe(true); + const text = (res.content as Array<{ text: string }>)[0]?.text ?? ''; + expect(text).toMatch(/-32601|not found|unknown tool|Method not found/i); + return; + } catch (err) { + caught = err; + } + const msg = caught instanceof Error ? caught.message : String(caught); + expect(msg).toMatch(/-32601|not found|unknown tool|Method not found/i); + }); }); diff --git a/tests/mcp/retired-tools.test.ts b/tests/mcp/retired-tools.test.ts deleted file mode 100644 index 4836c267..00000000 --- a/tests/mcp/retired-tools.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -/** - * Retired tools contract: 9 MCP tool names retired by the v3.8.0 consolidation - * must NOT be registered, regardless of profile. They were replaced by: - * - mindclip_list_recordings, mindclip_get_recording, mindclip_get_summary - * -> mindclip_recordings (action: list | get | summary) - * - mindclip_daily_recall, mindclip_weekly_summary, mindclip_urgent_todos - * -> mindclip_recall (period: daily | weekly | urgent_todos) - * - get_device_history, query_device_history, aggregate_device_history - * -> device_history (mode: raw | query | aggregate) - * - * If any of these names ever returns to the registered set, this test guards - * the breaking-change contract documented in CHANGELOG [Unreleased]. - */ -import { describe, it, expect, vi } from 'vitest'; - -const apiMock = vi.hoisted(() => { - const instance = { get: vi.fn(), post: vi.fn() }; - return { - createClient: vi.fn(() => instance), - __instance: instance, - }; -}); - -vi.mock('../../src/api/client.js', () => ({ - createClient: apiMock.createClient, - ApiError: class ApiError extends Error { - constructor(message: string, public readonly code: number) { - super(message); - this.name = 'ApiError'; - } - }, - DryRunSignal: class DryRunSignal extends Error { - constructor(public readonly method: string, public readonly url: string) { - super('dry-run'); - this.name = 'DryRunSignal'; - } - }, -})); - -vi.mock('../../src/devices/cache.js', () => ({ - getCachedDevice: vi.fn(() => null), - updateCacheFromDeviceList: vi.fn(), - loadCache: vi.fn(() => null), - clearCache: vi.fn(), - isListCacheFresh: vi.fn(() => false), - listCacheAgeMs: vi.fn(() => null), - getCachedStatus: vi.fn(() => null), - setCachedStatus: vi.fn(), - clearStatusCache: vi.fn(), - loadStatusCache: vi.fn(() => ({ entries: {} })), - describeCache: vi.fn(() => ({ - list: { path: '', exists: false }, - status: { path: '', exists: false, entryCount: 0 }, - })), -})); - -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; -import { createSwitchBotMcpServer, listRegisteredTools } from '../../src/commands/mcp.js'; - -const RETIRED = [ - 'mindclip_list_recordings', - 'mindclip_get_recording', - 'mindclip_get_summary', - 'mindclip_daily_recall', - 'mindclip_weekly_summary', - 'mindclip_urgent_todos', - 'get_device_history', - 'query_device_history', - 'aggregate_device_history', -] as const; - -async function pair(toolProfile: 'default' | 'readonly' | 'all' = 'all') { - const server = createSwitchBotMcpServer({ toolProfile }); - const client = new Client({ name: 'retired-test', version: '0.0.1' }); - const [clientT, serverT] = InMemoryTransport.createLinkedPair(); - await Promise.all([server.connect(serverT), client.connect(clientT)]); - return { server, client }; -} - -describe('retired MCP tool names (v3.8.0 consolidation contract)', () => { - it.each(RETIRED)('%s is not in createSwitchBotMcpServer({ toolProfile: "all" })', (name) => { - const server = createSwitchBotMcpServer({ toolProfile: 'all' }); - const tools = listRegisteredTools(server); - expect(tools, `retired tool ${name} must not be registered under "all" profile`).not.toContain(name); - }); - - it.each(RETIRED)('%s is absent from listTools() over the wire', async (name) => { - const { client } = await pair('all'); - const { tools } = await client.listTools(); - const names = tools.map((t) => t.name); - expect(names, `retired tool ${name} must not appear in listTools()`).not.toContain(name); - }); - - it.each(RETIRED)('%s callTool returns method-not-found / -32601', async (name) => { - const { client } = await pair('all'); - let caught: unknown; - try { - // The SDK throws on tools/call against unregistered names rather than - // returning an isError content envelope (the latter is for tool-handler - // errors). Either shape is acceptable; both must surface "not found". - const res = await client.callTool({ name, arguments: {} }); - // If the SDK ever stops throwing here, the result must still report error. - expect(res.isError, `${name} should not be invokable`).toBe(true); - const text = (res.content as Array<{ text: string }>)[0]?.text ?? ''; - expect(text).toMatch(/-32601|not found|unknown tool|Method not found/i); - return; - } catch (err) { - caught = err; - } - const msg = caught instanceof Error ? caught.message : String(caught); - expect(msg, `${name} expected method-not-found in error`).toMatch( - /-32601|not found|unknown tool|Method not found/i, - ); - }); -}); From 75ccb5613aef6ee79c3ea39edf9857fa74b7d715 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 13 Jun 2026 20:49:21 +0800 Subject: [PATCH 28/57] fix(mcp): add missing notes field to aggregate_device_history outputSchema --- src/commands/mcp.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index 1dbd724e..f117e46b 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -551,17 +551,19 @@ Tool profile: ${profileName} (${allowedTools.size} tools loaded).${profileName ! } // ---- query mode ---------------------------------------------------- - if (args.mode === 'query') return runDeviceHistoryQuery(args as Parameters[0]); + if (args.mode === 'query') return runDeviceHistoryQuery(args as Parameters[0]); // cast strips mode // ---- aggregate mode ------------------------------------------------ + // Zod enum guarantees mode === 'aggregate' here return runDeviceHistoryAggregate(args as Parameters[0]); } ); // ---- deprecated aliases for device_history ------------------------------ - // 3.x backward-compat — removed in 4.0.0. Each alias forwards to the - // consolidated handler with `mode` hardcoded. Schemas mirror the relevant - // subset of the device_history schema so old clients keep their shape. + // 3.x backward-compat — removed in 4.0.0. query/aggregate aliases delegate + // to module-level helpers; raw (get_device_history) inlines the logic since + // it's short. Schemas mirror the relevant subset of the device_history schema + // so old clients keep their shape. if (!skip('get_device_history')) server.registerTool( 'get_device_history', @@ -674,6 +676,7 @@ Tool profile: ${profileName} (${allowedTools.size} tools loaded).${profileName ! })), })), partial: z.boolean().optional(), + notes: z.array(z.string()).optional(), }, }, async (args) => runDeviceHistoryAggregate(args) From 99893a80f6d70f7e56794bcea9ff2fdd9f3e454a Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 13 Jun 2026 20:55:26 +0800 Subject: [PATCH 29/57] docs(tests): note 3.8.0 device_history-aliased + mindclip-fresh decision in header comments --- packages/claude-code-plugin/tests/manifest.test.js | 10 +++++++--- tests/install/claude-code-checks.test.ts | 10 +++++++--- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/claude-code-plugin/tests/manifest.test.js b/packages/claude-code-plugin/tests/manifest.test.js index a85b0842..12caffd7 100644 --- a/packages/claude-code-plugin/tests/manifest.test.js +++ b/packages/claude-code-plugin/tests/manifest.test.js @@ -1,8 +1,12 @@ /** * Asserts the `.mcp.json` shipped by the Claude Code plugin registers the - * SwitchBot MCP server using the default profile (no `--tools all`). The - * v3.8.0 consolidation switched defaults so admin tools are opt-in; this - * test guards against an accidental revert. + * SwitchBot MCP server using the default profile (no `--tools all`). + * v3.8.0 consolidation switched defaults so admin tools are opt-in. The + * device_history trio (get_/query_/aggregate_) collapses into a single + * device_history tool with a mode discriminator; the 3 old names remain + * registered as deprecated aliases for 3.x backward compat (removal in 4.0.0). + * The mindclip MCP tools ship for the first time in 3.8.0 — no aliases needed. + * This test guards against an accidental revert. */ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; diff --git a/tests/install/claude-code-checks.test.ts b/tests/install/claude-code-checks.test.ts index 98e76dcb..d33c2fe7 100644 --- a/tests/install/claude-code-checks.test.ts +++ b/tests/install/claude-code-checks.test.ts @@ -1,8 +1,12 @@ /** * Asserts `claude-code-checks.registerMcp` invokes `claude mcp add` with the - * default-profile args (`mcp serve`, no `--tools all`). The v3.8.0 - * consolidation moved admin tools (policy / audit / rules) behind opt-in; - * this test guards against accidentally re-adding `--tools all` to the + * default-profile args (`mcp serve`, no `--tools all`). + * v3.8.0 consolidation switched defaults so admin tools are opt-in. The + * device_history trio (get_/query_/aggregate_) collapses into a single + * device_history tool with a mode discriminator; the 3 old names remain + * registered as deprecated aliases for 3.x backward compat (removal in 4.0.0). + * The mindclip MCP tools ship for the first time in 3.8.0 — no aliases needed. + * This test guards against accidentally re-adding `--tools all` to the * `claude mcp add ...` command line. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; From 82d126598d407158b573b3e274de82b4c48f7dd7 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 13 Jun 2026 20:56:08 +0800 Subject: [PATCH 30/57] docs: align tool-count copy at 28 (25 canonical + 3 deprecated device_history aliases) From 678ad6b4c81c010b910dbadfe7db582591801633 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 13 Jun 2026 20:57:22 +0800 Subject: [PATCH 31/57] fix(mcp): add .describe() to alias tool schema properties (get/query/aggregate_device_history) --- src/commands/mcp.ts | 41 +++++++++++++++++++++++++---------------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index f117e46b..466ca989 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -575,8 +575,12 @@ Tool profile: ${profileName} (${allowedTools.size} tools loaded).${profileName ! 'No API call — zero quota cost.', _meta: { agentSafetyTier: 'read', deprecated: true, replacement: 'device_history' }, inputSchema: z.object({ - deviceId: z.string().optional(), - limit: z.number().int().min(1).max(100).optional(), + deviceId: z.string().optional().describe( + 'Device MAC address. Omit to list all devices with stored history.', + ), + limit: z.number().int().min(1).max(100).optional().describe( + 'Max history entries to return (default 20, max 100).', + ), }).strict(), outputSchema: { deviceId: z.string().optional(), @@ -615,12 +619,12 @@ Tool profile: ${profileName} (${allowedTools.size} tools loaded).${profileName ! 'Return time-ranged records (since OR from/to) with optional field projection and limit. No API call.', _meta: { agentSafetyTier: 'read', deprecated: true, replacement: 'device_history' }, inputSchema: z.object({ - deviceId: z.string(), - since: z.string().optional(), - from: z.string().optional(), - to: z.string().optional(), - fields: z.array(z.string()).optional(), - limit: z.number().int().min(1).max(10000).optional(), + deviceId: z.string().describe('Device MAC address (required).'), + since: z.string().optional().describe('Relative window ending now, e.g. "30s","15m","1h","7d". Mutually exclusive with from/to.'), + from: z.string().optional().describe('Range start (ISO-8601). Mutually exclusive with since.'), + to: z.string().optional().describe('Range end (ISO-8601). Used together with from.'), + fields: z.array(z.string()).optional().describe('Project these payload fields; omit for full payload.'), + limit: z.number().int().min(1).max(10000).optional().describe('Max records to return (default 1000, max 10000).'), }).strict(), outputSchema: { deviceId: z.string(), @@ -647,14 +651,19 @@ Tool profile: ${profileName} (${allowedTools.size} tools loaded).${profileName ! 'Return bucketed statistics (count/min/max/avg/sum/p50/p95) over numeric metrics. No API call.', _meta: { agentSafetyTier: 'read', deprecated: true, replacement: 'device_history' }, inputSchema: z.object({ - deviceId: z.string(), - since: z.string().optional(), - from: z.string().optional(), - to: z.string().optional(), - metrics: z.array(z.string().min(1)), - aggs: z.array(z.enum(ALL_AGG_FNS as unknown as [AggFn, ...AggFn[]])).optional(), - bucket: z.string().optional(), - maxBucketSamples: z.number().int().positive().max(MAX_SAMPLE_CAP).optional(), + deviceId: z.string().describe('Device MAC address (required).'), + since: z.string().optional().describe('Relative window ending now, e.g. "30s","15m","1h","7d". Mutually exclusive with from/to.'), + from: z.string().optional().describe('Range start (ISO-8601). Mutually exclusive with since.'), + to: z.string().optional().describe('Range end (ISO-8601). Used together with from.'), + metrics: z.array(z.string().min(1)).describe( + 'One or more numeric payload field names to aggregate (e.g. ["temperature","humidity"]).', + ), + aggs: z.array(z.enum(ALL_AGG_FNS as unknown as [AggFn, ...AggFn[]])).optional().describe( + 'Aggregation functions per metric (default ["count","avg"]).', + ), + bucket: z.string().optional().describe('Bucket width like "5m","1h","1d". Omit for a single bucket spanning the full range.'), + maxBucketSamples: z.number().int().positive().max(MAX_SAMPLE_CAP).optional() + .describe(`Per-bucket sample cap (default 10000, max ${MAX_SAMPLE_CAP}). partial=true when any bucket was capped.`), }).strict(), outputSchema: { deviceId: z.string(), From 840e23339d360c21daa4e8e9f04514eada7178af Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 13 Jun 2026 20:57:58 +0800 Subject: [PATCH 32/57] docs: align tool-count copy at 28 (25 canonical + 3 deprecated device_history aliases) --- README.md | 4 ++-- docs/agent-guide.md | 10 +++++++++- .../plugins/switchbot/skills/switchbot/SKILL.md | 2 +- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 1b5049ca..c5f242ac 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,7 @@ The optional skill package [`@switchbot/claude-code-plugin`](https://www.npmjs.c ## Gemini CLI integration -The Gemini extension is in [`packages/gemini-extension/`](./packages/gemini-extension/) — it provides up to 25 MCP tools (14 in the default profile, 25 with `--tools all`), a GEMINI.md context file, and 23 slash commands. +The Gemini extension is in [`packages/gemini-extension/`](./packages/gemini-extension/) — it provides up to 28 MCP tools (14 readonly, 17 default, 28 with `--tools all`; includes 25 canonical tools + 3 deprecated `device_history` aliases retained for 3.x backward compat; see [docs/agent-guide.md](./docs/agent-guide.md)), a GEMINI.md context file, and 23 slash commands. **Recommended — paste into Gemini CLI chat:** @@ -294,7 +294,7 @@ switchbot config list-profiles ### `mcp` ```bash -switchbot mcp serve # stdio MCP server — default 14 tools (use --tools all for 25) +switchbot mcp serve # stdio MCP server — default 17 tools (use --tools all for 28) ``` ### `webhook` diff --git a/docs/agent-guide.md b/docs/agent-guide.md index eb9e81c2..4b4a1ae1 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 (24) +### Available tools (28) | Tool | Purpose | Safety tier | | --- | --- | --- | @@ -105,6 +105,14 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) 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. +### Deprecated aliases (slated for removal in 4.0.0) + +These names continue to work in 3.x but are thin wrappers over `device_history`. Migrate to the consolidated tool — it emits a single schema per session instead of three. + +- `get_device_history` → `device_history(mode="raw")` +- `query_device_history` → `device_history(mode="query")` +- `aggregate_device_history` → `device_history(mode="aggregate")` + ### `device_history` — zero-cost state lookup Reads `~/.switchbot/device-history/.json` (mode="raw") or `.jsonl` (mode="query"/"aggregate") written by `events mqtt-tail`. Requires no API call and costs zero quota. diff --git a/packages/claude-code-plugin/plugins/switchbot/skills/switchbot/SKILL.md b/packages/claude-code-plugin/plugins/switchbot/skills/switchbot/SKILL.md index 5cd02f43..b6681b39 100644 --- a/packages/claude-code-plugin/plugins/switchbot/skills/switchbot/SKILL.md +++ b/packages/claude-code-plugin/plugins/switchbot/skills/switchbot/SKILL.md @@ -35,7 +35,7 @@ Drive the user's SwitchBot smart home through the `switchbot` CLI. Always query ## Network requirements -Claude Code registers the SwitchBot MCP server via `claude mcp add switchbot -- switchbot mcp serve` — or via `.mcp.json` in managed environments. The default profile exposes 14 tools (read + action); add `--tools all` to also expose admin tools (policy, audit, rules — 25 total). No manual setup is required once the MCP server is registered. The MCP server needs outbound HTTPS to `api.switch-bot.com`. If connection errors appear, see `references/claude-code-network.md`. +Claude Code registers the SwitchBot MCP server via `claude mcp add switchbot -- switchbot mcp serve` — or via `.mcp.json` in managed environments. The default profile exposes 17 tools (read + action); add `--tools all` to also expose admin tools (policy, audit, rules — 28 total, including 25 canonical tools + 3 deprecated `device_history` aliases). No manual setup is required once the MCP server is registered. The MCP server needs outbound HTTPS to `api.switch-bot.com`. If connection errors appear, see `references/claude-code-network.md`. --- From 710a3df6cf3f7d5024f871b2b61230c3a188e269 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 13 Jun 2026 20:59:25 +0800 Subject: [PATCH 33/57] =?UTF-8?q?docs(changelog):=20drop=20BREAKING=20sect?= =?UTF-8?q?ion=20=E2=80=94=20only=20device=5Fhistory=20needs=20aliases=20(?= =?UTF-8?q?mindclip=20never=20shipped=20under=20old=20names)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4ab9be0..92ff597c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,15 +9,20 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] -### BREAKING +### Added -- **MCP tool consolidation: 31 → 25 tools** — the seven MindClip read tools collapse into three (`mindclip_recordings` with `action: "list" | "get" | "summary"`, `mindclip_list_todos` (unchanged), and `mindclip_recall` with `period: "daily" | "weekly" | "urgent_todos"`); the three device-history tools collapse into one (`device_history` with `mode: "raw" | "query" | "aggregate"`). MCP clients that called the old names (`mindclip_list_recordings` / `mindclip_get_recording` / `mindclip_get_summary` / `mindclip_daily_recall` / `mindclip_weekly_summary` / `mindclip_urgent_todos` / `get_device_history` / `query_device_history` / `aggregate_device_history`) must move to the new tool name and pass the discriminator. The CLI commands (`switchbot mindclip recordings/recording/summary/...`, `switchbot history show/range/aggregate`) are unchanged. Rationale: the old layout sent ~10 highly-redundant tool descriptions and JSON schemas in every model session; consolidating cuts the per-session token cost and the agent's tool-picking overhead by ~20%. -- **Plugins switch to the `default` tool profile** — `@switchbot/claude-code-plugin`, `@switchbot/codex-plugin`, and `@switchbot/gemini-extension` now register the MCP server as `switchbot mcp serve` (without `--tools all`). The default profile exposes 14 tools (read + action). To get the 11 admin tools (policy / audit / automation rules), users opt in by adding `--tools all` to their MCP config — the same flag the CLI has always supported. Existing installations keep working: `registerMcp` / `claude mcp add` / `codex plugin` re-registration writes the new args; manual configs need a one-line edit. Rationale: most agents never invoke admin tools, but every session paid for their schemas. +- **AI MindClip MCP tools** — three new read tools for AI MindClip recordings: `mindclip_recordings` (action: `"list" | "get" | "summary"`), `mindclip_list_todos`, and `mindclip_recall` (period: `"daily" | "weekly" | "urgent_todos"`). Plus the underlying CLI command group (`switchbot mindclip recordings/recording/summary/todos/daily/weekly/urgent-todos`). Read-only; counts toward the same SwitchBot daily quota as other API reads. ### Changed -- **Profile counts**: `readonly` 17 → 11, `default` 20 → 14, `all` 31 → 25. -- **`mcp tools --tools ` help text**, `mcp serve` help bullet list, README/SKILL.md/GEMINI.md tables, and all package descriptions updated to reflect the new counts. +- **`device_history` MCP consolidation** — the previous `get_device_history` / `query_device_history` / `aggregate_device_history` trio collapses into a single `device_history` tool that takes a `mode: "raw" | "query" | "aggregate"` discriminator. The consolidated tool is recommended — it cuts per-session token cost (one schema instead of three). The 3 old names continue to work as deprecated aliases that delegate to the consolidated handler; no client action is required. CLI commands (`switchbot history show/range/aggregate`) are unchanged. +- **Plugins switch to the `default` tool profile** — `@switchbot/claude-code-plugin`, `@switchbot/codex-plugin`, and `@switchbot/gemini-extension` now register the MCP server as `switchbot mcp serve` (without `--tools all`). The default profile exposes 17 tools (read + action; includes the 3 deprecated device_history aliases for 3.x compat). To get the 11 admin tools (policy / audit / automation rules), users opt in by adding `--tools all` to their MCP config — the same flag the CLI has always supported. Existing installations keep working: `registerCodexPluginAuto` / `claude mcp add` / `codex plugin` re-registration writes the new args; manual configs need a one-line edit. Rationale: most agents never invoke admin tools, but every session paid for their schemas. +- **Profile counts**: `readonly` 11 → 14, `default` 14 → 17, `all` 25 → 28 (each total = 25 canonical + 3 deprecated device_history aliases). +- **`mcp tools --tools ` help text**, `mcp serve` help bullet list, README/SKILL.md/GEMINI.md tables, and all package descriptions updated to reflect the new counts and the deprecation note. + +### Deprecated + +- **3 device_history MCP tool names** are retained as aliases in 3.x and **scheduled for removal in 4.0.0**: `get_device_history`, `query_device_history`, `aggregate_device_history`. Each alias's description is prefixed with `[DEPRECATED — use device_history(mode="…")]` and its `_meta` carries `deprecated: true, replacement: 'device_history'`. Migrate before the 4.0.0 release. ### Fixed From 0466279103d9d8382bf8297cd38b6ac5618b365e Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 13 Jun 2026 21:03:04 +0800 Subject: [PATCH 34/57] test: bump tool-profiles count expectations to 14/17/28 (missed by T4) --- tests/mcp/tool-profiles.test.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/mcp/tool-profiles.test.ts b/tests/mcp/tool-profiles.test.ts index 1df8086b..bd41fe14 100644 --- a/tests/mcp/tool-profiles.test.ts +++ b/tests/mcp/tool-profiles.test.ts @@ -5,16 +5,16 @@ import { MCP_TOOLS } from '../../src/commands/capabilities.js'; describe('tool-profiles', () => { describe('TOOL_PROFILES sets', () => { - it('readonly has 11 tools (core read only)', () => { - expect(TOOL_PROFILES.readonly.size).toBe(11); + it('readonly has 14 tools (core read only)', () => { + expect(TOOL_PROFILES.readonly.size).toBe(14); }); - it('default has 14 tools (core read + action)', () => { - expect(TOOL_PROFILES.default.size).toBe(14); + it('default has 17 tools (core read + action)', () => { + expect(TOOL_PROFILES.default.size).toBe(17); }); - it('all has 25 tools', () => { - expect(TOOL_PROFILES.all.size).toBe(25); + it('all has 28 tools', () => { + expect(TOOL_PROFILES.all.size).toBe(28); }); it('readonly is a subset of default', () => { @@ -66,9 +66,9 @@ describe('tool-profiles', () => { describe('createSwitchBotMcpServer respects toolProfile', () => { it.each<[ToolProfile, number]>([ - ['readonly', 11], - ['default', 14], - ['all', 25], + ['readonly', 14], + ['default', 17], + ['all', 28], // 25 canonical + 3 deprecated device_history aliases ])('profile "%s" registers %d tools', (profile, expected) => { const server = createSwitchBotMcpServer({ toolProfile: profile }); expect(listRegisteredTools(server)).toHaveLength(expected); From 0fa4f676240ef6fe34e4276bb0b81ded1b0261ca Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 13 Jun 2026 21:16:35 +0800 Subject: [PATCH 35/57] docs(quality): tighten README/CHANGELOG copy, align deprecation wording, comment limit cap --- CHANGELOG.md | 2 +- README.md | 2 +- docs/agent-guide.md | 2 +- src/commands/mcp.ts | 1 + 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92ff597c..53e2b208 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added -- **AI MindClip MCP tools** — three new read tools for AI MindClip recordings: `mindclip_recordings` (action: `"list" | "get" | "summary"`), `mindclip_list_todos`, and `mindclip_recall` (period: `"daily" | "weekly" | "urgent_todos"`). Plus the underlying CLI command group (`switchbot mindclip recordings/recording/summary/todos/daily/weekly/urgent-todos`). Read-only; counts toward the same SwitchBot daily quota as other API reads. +- **AI MindClip MCP tools** — three new read tools for AI MindClip recordings: `mindclip_recordings` (action: `"list"` paginated browse / `"get"` single recording by id / `"summary"` AI-generated summary), `mindclip_list_todos`, and `mindclip_recall` (period: `"daily"` daily recall / `"weekly"` weekly summary / `"urgent_todos"` urgent to-dos). Plus the underlying CLI command group (`switchbot mindclip recordings/recording/summary/todos/daily/weekly/urgent-todos`). Read-only; counts toward the same SwitchBot daily quota as other API reads. ### Changed diff --git a/README.md b/README.md index c5f242ac..bb5ac6bd 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,7 @@ The optional skill package [`@switchbot/claude-code-plugin`](https://www.npmjs.c ## Gemini CLI integration -The Gemini extension is in [`packages/gemini-extension/`](./packages/gemini-extension/) — it provides up to 28 MCP tools (14 readonly, 17 default, 28 with `--tools all`; includes 25 canonical tools + 3 deprecated `device_history` aliases retained for 3.x backward compat; see [docs/agent-guide.md](./docs/agent-guide.md)), a GEMINI.md context file, and 23 slash commands. +The Gemini extension is in [`packages/gemini-extension/`](./packages/gemini-extension/) — it provides up to 28 MCP tools (14 readonly, 17 default, 28 with `--tools all`; see [docs/agent-guide.md](./docs/agent-guide.md) for the deprecated-aliases breakdown), a GEMINI.md context file, and 23 slash commands. **Recommended — paste into Gemini CLI chat:** diff --git a/docs/agent-guide.md b/docs/agent-guide.md index 4b4a1ae1..d11d00e4 100644 --- a/docs/agent-guide.md +++ b/docs/agent-guide.md @@ -105,7 +105,7 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) 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. -### Deprecated aliases (slated for removal in 4.0.0) +### Deprecated aliases (scheduled for removal in 4.0.0) These names continue to work in 3.x but are thin wrappers over `device_history`. Migrate to the consolidated tool — it emits a single schema per session instead of three. diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index 466ca989..68736dc6 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -578,6 +578,7 @@ Tool profile: ${profileName} (${allowedTools.size} tools loaded).${profileName ! deviceId: z.string().optional().describe( 'Device MAC address. Omit to list all devices with stored history.', ), + // raw-mode only: hard-capped at 100 here; the consolidated `device_history` schema uses max 10000 across all modes (raw enforces 100 at runtime). limit: z.number().int().min(1).max(100).optional().describe( 'Max history entries to return (default 20, max 100).', ), From 0d6549a8dad0b4913d3743cde26bf3a861beca87 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 13 Jun 2026 21:26:50 +0800 Subject: [PATCH 36/57] test(mcp): make get_device_history equivalence test deterministic via NO-SUCH-DEVICE arg --- tests/mcp/legacy-aliases.test.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/mcp/legacy-aliases.test.ts b/tests/mcp/legacy-aliases.test.ts index 3e0e58e9..fbdf727a 100644 --- a/tests/mcp/legacy-aliases.test.ts +++ b/tests/mcp/legacy-aliases.test.ts @@ -140,11 +140,16 @@ describe('legacy device_history aliases (3.8.0 backward-compat contract)', () => }, ); - it('get_device_history with no args produces the same shape as device_history({mode:"raw"})', async () => { + it('get_device_history forwards equivalently to device_history({mode:"raw"})', async () => { const { client } = await pair('all'); - const aliasResp = await client.callTool({ name: 'get_device_history', arguments: {} }); - const consolidatedResp = await client.callTool({ name: 'device_history', arguments: { mode: 'raw' } }); + // Use a no-such-device arg so neither call snapshots live MQTT real-time + // data — both should produce the same empty-history envelope. Calling with + // empty `arguments` would race on `latest.t` timestamps. + const args = { deviceId: 'NO-SUCH-DEVICE' }; + const aliasResp = await client.callTool({ name: 'get_device_history', arguments: args }); + const consolidatedResp = await client.callTool({ name: 'device_history', arguments: { mode: 'raw', ...args } }); expect(aliasResp.structuredContent).toEqual(consolidatedResp.structuredContent); + expect(aliasResp.isError).toBe(consolidatedResp.isError); }); it('query_device_history forwards equivalently to device_history({mode:"query"})', async () => { From 9c48061ac274326d38c3850df2cf2ec07e3b5a50 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 13 Jun 2026 21:45:15 +0800 Subject: [PATCH 37/57] docs(agent-guide): add 3 missing mindclip tool rows to main table --- docs/agent-guide.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/agent-guide.md b/docs/agent-guide.md index d11d00e4..09a62646 100644 --- a/docs/agent-guide.md +++ b/docs/agent-guide.md @@ -89,6 +89,9 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) | `describe_device` | Catalog-derived capabilities + optional live status | read | | `account_overview` | Cold-start snapshot (devices/scenes/quota/cache/MQTT) | read | | `device_history` | Read locally-persisted history. mode: "raw" (latest + ring) / "query" (time-range JSONL) / "aggregate" (bucketed stats) | read | +| `mindclip_recordings` | Browse/fetch AI MindClip recordings. action: `"list"` paginated browse / `"get"` single recording by id / `"summary"` AI-generated summary | read | +| `mindclip_list_todos` | List AI-extracted to-dos pulled from voice recordings | read | +| `mindclip_recall` | AI-curated recall views. period: `"daily"` daily recall / `"weekly"` weekly summary / `"urgent_todos"` urgent to-dos | read | | `policy_validate` | Validate policy.yaml | read | | `policy_new` | Scaffold a starter policy file | action | | `policy_migrate` | Upgrade policy schema in-place | action | From ad9d17203a61a03f30d611deb891beb422746a01 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 13 Jun 2026 22:09:49 +0800 Subject: [PATCH 38/57] test(mindclip): add missing handleError mock to output module stub --- tests/commands/mindclip.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/commands/mindclip.test.ts b/tests/commands/mindclip.test.ts index a72c083f..032df61c 100644 --- a/tests/commands/mindclip.test.ts +++ b/tests/commands/mindclip.test.ts @@ -19,6 +19,9 @@ vi.mock('../../src/utils/output.js', () => ({ exitWithError: vi.fn((opts) => { throw new Error(typeof opts === 'string' ? opts : opts.message); }), + handleError: vi.fn((error: unknown) => { + throw error instanceof Error ? error : new Error(String(error)); + }), })); function buildProgram(): Command { From 612f22ee91232691d0c888aa0fa5d99530092a6e Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 13 Jun 2026 22:12:41 +0800 Subject: [PATCH 39/57] fix(mcp): wrap runDeviceHistoryAggregate in try/catch and refresh test comments --- src/commands/mcp.ts | 37 ++++++++++++++++++-------------- tests/mcp/legacy-aliases.test.ts | 6 +++++- 2 files changed, 26 insertions(+), 17 deletions(-) diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index 68736dc6..9935302e 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -319,22 +319,27 @@ async function runDeviceHistoryAggregate(args: { bucket: args.bucket, maxBucketSamples: args.maxBucketSamples, }; - const res = await aggregateDeviceHistory(args.deviceId, opts); - const structured: Record = { - deviceId: res.deviceId, - from: res.from, - to: res.to, - metrics: res.metrics, - aggs: res.aggs, - buckets: res.buckets, - partial: res.partial, - notes: res.notes, - }; - if (res.bucket !== undefined) structured.bucket = res.bucket; - return { - content: [{ type: 'text' as const, text: JSON.stringify(res, null, 2) }], - structuredContent: structured, - }; + try { + const res = await aggregateDeviceHistory(args.deviceId, opts); + const structured: Record = { + deviceId: res.deviceId, + from: res.from, + to: res.to, + metrics: res.metrics, + aggs: res.aggs, + buckets: res.buckets, + partial: res.partial, + notes: res.notes, + }; + if (res.bucket !== undefined) structured.bucket = res.bucket; + return { + content: [{ type: 'text' as const, text: JSON.stringify(res, null, 2) }], + structuredContent: structured, + }; + } catch (err) { + const msg = err instanceof Error ? err.message : 'history aggregation failed'; + return mcpError('usage', 2, msg); + } } export function createSwitchBotMcpServer(options?: { eventManager?: EventSubscriptionManager; toolProfile?: ToolProfile }): McpServer { diff --git a/tests/mcp/legacy-aliases.test.ts b/tests/mcp/legacy-aliases.test.ts index fbdf727a..4ad6b40f 100644 --- a/tests/mcp/legacy-aliases.test.ts +++ b/tests/mcp/legacy-aliases.test.ts @@ -134,7 +134,9 @@ describe('legacy device_history aliases (3.8.0 backward-compat contract)', () => | { name: string; _meta?: { deprecated?: boolean; replacement?: string } } | undefined; expect(t, `alias ${old} not found`).toBeDefined(); - expect(t!._meta, `${old}: _meta not transmitted by SDK — check Tool type definition`).toBeDefined(); + // MCP SDK 1.29+ serializes _meta in tools/list; this assertion guards + // against a future SDK regression that strips it. + expect(t!._meta, `${old}: _meta missing — SDK regression dropped it from tools/list?`).toBeDefined(); expect(t!._meta?.deprecated).toBe(true); expect(t!._meta?.replacement).toBe('device_history'); }, @@ -156,6 +158,8 @@ describe('legacy device_history aliases (3.8.0 backward-compat contract)', () => const { client } = await pair('all'); // Query without setting up real device history; both calls should produce // the same shape (likely an error envelope or empty-records envelope). + // Unlike aggregate, query results carry no Date.now()-derived `from`/`to` + // boundaries, so a direct deep-equal is safe — no stable() helper needed. const args = { deviceId: 'NO-SUCH-DEVICE', since: '1h' }; const aliasResp = await client.callTool({ name: 'query_device_history', arguments: args }); const consolidatedResp = await client.callTool({ name: 'device_history', arguments: { mode: 'query', ...args } }); From e3f40a3811e65dcbee2a0a433ccb14ffce63006d Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 13 Jun 2026 22:44:27 +0800 Subject: [PATCH 40/57] fix(mindclip): URL-encode recording id in lib path interpolation Both getRecording and getSummary previously interpolated the id directly into the URL path, allowing characters like /, ?, #, .. to escape the recordings prefix or smuggle query params into the request. Wrap the id in encodeURIComponent and add tests covering traversal and query-merge inputs. --- src/lib/mindclip.ts | 18 ++++++++++++------ tests/lib/mindclip.test.ts | 21 +++++++++++++++++++++ 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/src/lib/mindclip.ts b/src/lib/mindclip.ts index b52691db..43f71a46 100644 --- a/src/lib/mindclip.ts +++ b/src/lib/mindclip.ts @@ -34,17 +34,23 @@ export async function listRecordings(params: ListRecordingsParams): Promise { const c = createClient(); - const res = await c.get<{ body: unknown }>(`/v1.1/mindclip/recordings/${id}`, { - params: compact({ language }), - }); + const res = await c.get<{ body: unknown }>( + `/v1.1/mindclip/recordings/${encodeURIComponent(id)}`, + { + params: compact({ language }), + }, + ); return res.data.body; } export async function getSummary(id: string): Promise { const c = createClient(); - const res = await c.get<{ body: unknown }>(`/v1.1/mindclip/summaries/${id}`, { - params: {}, - }); + const res = await c.get<{ body: unknown }>( + `/v1.1/mindclip/summaries/${encodeURIComponent(id)}`, + { + params: {}, + }, + ); return res.data.body; } diff --git a/tests/lib/mindclip.test.ts b/tests/lib/mindclip.test.ts index 82af671c..3e3a9796 100644 --- a/tests/lib/mindclip.test.ts +++ b/tests/lib/mindclip.test.ts @@ -84,6 +84,20 @@ describe('getRecording', () => { const params = apiMock.__instance.get.mock.calls[0][1].params; expect(params).not.toHaveProperty('language'); }); + + it('URL-encodes id with special characters so query/path separators do not leak', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ data: { body: {} } }); + await getRecording('a/b?secret=x#frag'); + const url = apiMock.__instance.get.mock.calls[0][0]; + expect(url).toBe('/v1.1/mindclip/recordings/a%2Fb%3Fsecret%3Dx%23frag'); + }); + + it('URL-encodes id traversal segments so they cannot escape the recordings prefix', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ data: { body: {} } }); + await getRecording('../summaries/abc'); + const url = apiMock.__instance.get.mock.calls[0][0]; + expect(url).toBe('/v1.1/mindclip/recordings/..%2Fsummaries%2Fabc'); + }); }); // --------------------------------------------------------------------------- @@ -96,6 +110,13 @@ describe('getSummary', () => { expect(apiMock.__instance.get).toHaveBeenCalledWith('/v1.1/mindclip/summaries/s1', { params: {} }); expect(result).toEqual({ summary: 'ok' }); }); + + it('URL-encodes id with special characters', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ data: { body: {} } }); + await getSummary('a/b?x=1'); + const url = apiMock.__instance.get.mock.calls[0][0]; + expect(url).toBe('/v1.1/mindclip/summaries/a%2Fb%3Fx%3D1'); + }); }); // --------------------------------------------------------------------------- From 3e2c5eac5374938928ab502f7c1628f08b47cf40 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 13 Jun 2026 22:47:31 +0800 Subject: [PATCH 41/57] fix(mcp): tighten mindclip Zod schemas to reject empty strings and impossible dates - mindclip_recordings/list_todos: optional string filters (deviceID, fileID, language) now require .min(1) so an explicit empty string no longer flows through to '?deviceID=' on the wire. - mindclip_recall: date now refines through new Date() round-trip, rejecting calendar-impossible inputs like 2026-02-30 or 2026-13-01 that the pure regex previously accepted. - Export DATE_REGEX/WEEK_REGEX/isCalendarValidDate from arg-parsers so CLI dateArg/weekArg and the MCP zod schemas share one source of truth. --- src/commands/mcp.ts | 16 ++++++++------- src/utils/arg-parsers.ts | 23 ++++++++++++++++++---- tests/commands/mcp.test.ts | 40 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 11 deletions(-) diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index 9935302e..4a0d1513 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -3,7 +3,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { z } from 'zod'; -import { intArg, stringArg } from '../utils/arg-parsers.js'; +import { intArg, stringArg, DATE_REGEX, WEEK_REGEX, isCalendarValidDate } from '../utils/arg-parsers.js'; import { handleError, isJsonMode, printJson, buildErrorPayload, exitWithError, type ErrorPayload, type ErrorSubKind } from '../utils/output.js'; import { VERSION } from '../version.js'; import { @@ -1299,9 +1299,9 @@ Tool profile: ${profileName} (${allowedTools.size} tools loaded).${profileName ! ), // get / summary id: z.string().min(1).optional().describe('Recording ID — required when action="get" or "summary".'), - language: z.string().optional().describe('Language code (e.g. "en", "zh") — only honored when action="get".'), + language: z.string().min(1).optional().describe('Language code (e.g. "en", "zh") — only honored when action="get".'), // list filters - deviceID: z.string().optional().describe('Filter by SwitchBot device ID — list only.'), + deviceID: z.string().min(1).optional().describe('Filter by SwitchBot device ID — list only.'), pageNum: z.number().int().min(1).optional().describe('Page number (>= 1) — list only.'), pageSize: z.number().int().min(1).max(100).optional().describe('Results per page (1-100) — list only.'), startTime: z.number().int().min(0).optional().describe('Start timestamp in ms since epoch — list only.'), @@ -1357,8 +1357,8 @@ Tool profile: ${profileName} (${allowedTools.size} tools loaded).${profileName ! .describe('0=any, 1=work, 2=life, 3=hobby, 4=holiday, 5=other'), pageNum: z.number().int().min(1).optional().describe('Page number (>= 1)'), pageSize: z.number().int().min(1).max(100).optional().describe('Results per page (1-100)'), - deviceID: z.string().optional().describe('Filter by SwitchBot device ID'), - fileID: z.string().optional().describe('Filter by source recording file ID'), + deviceID: z.string().min(1).optional().describe('Filter by SwitchBot device ID'), + fileID: z.string().min(1).optional().describe('Filter by source recording file ID'), startTime: z.number().int().min(0).optional().describe('Start timestamp in ms since epoch'), endTime: z.number().int().min(0).optional().describe('End timestamp in ms since epoch'), }).strict(), @@ -1400,9 +1400,11 @@ Tool profile: ${profileName} (${allowedTools.size} tools loaded).${profileName ! period: z.enum(['daily', 'weekly', 'urgent_todos']).describe( '"daily": daily recall by date; "weekly": weekly summary by ISO week; "urgent_todos": urgent to-dos by date.', ), - date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional() + date: z.string().regex(DATE_REGEX).refine(isCalendarValidDate, { + message: 'Invalid calendar date — month/day are out of range or otherwise impossible.', + }).optional() .describe('YYYY-MM-DD — used by period="daily" or "urgent_todos"; omit for server default.'), - week: z.string().regex(/^\d{4}-W(0[1-9]|[1-4]\d|5[0-3])$/).optional() + week: z.string().regex(WEEK_REGEX).optional() .describe('YYYY-Www (weeks 01-53) — used by period="weekly"; omit for server default.'), }).strict(), outputSchema: { diff --git a/src/utils/arg-parsers.ts b/src/utils/arg-parsers.ts index 5fc5bd00..bd560cc2 100644 --- a/src/utils/arg-parsers.ts +++ b/src/utils/arg-parsers.ts @@ -93,13 +93,12 @@ export function enumArg( export function dateArg(flagName: string): (value: string) => string { return (value: string) => { - if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) { + if (!DATE_REGEX.test(value)) { throw new InvalidArgumentError( `${flagName} must be in YYYY-MM-DD format (got "${value}")`, ); } - const d = new Date(value); - if (isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== value) { + if (!isCalendarValidDate(value)) { throw new InvalidArgumentError( `${flagName} must be in YYYY-MM-DD format (got "${value}")`, ); @@ -110,7 +109,7 @@ export function dateArg(flagName: string): (value: string) => string { export function weekArg(flagName: string): (value: string) => string { return (value: string) => { - if (!/^\d{4}-W(0[1-9]|[1-4]\d|5[0-3])$/.test(value)) { + if (!WEEK_REGEX.test(value)) { throw new InvalidArgumentError( `${flagName} must be in YYYY-Www format, weeks 01–53 (e.g. 2026-W23 — got "${value}")`, ); @@ -118,3 +117,19 @@ export function weekArg(flagName: string): (value: string) => string { return value; }; } + +/** + * Shared ISO date and ISO week regexes — also imported by the mindclip MCP + * tool zod schemas so CLI and MCP validation accept the exact same surface. + */ +export const DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/; +export const WEEK_REGEX = /^\d{4}-W(0[1-9]|[1-4]\d|5[0-3])$/; + +/** + * Reject impossible calendar dates that match DATE_REGEX (e.g. 2026-13-50, + * 2026-02-30). Round-trips through Date so leap years stay correct. + */ +export function isCalendarValidDate(value: string): boolean { + const d = new Date(value); + return !isNaN(d.getTime()) && d.toISOString().slice(0, 10) === value; +} diff --git a/tests/commands/mcp.test.ts b/tests/commands/mcp.test.ts index 38c3170e..f9b4a8c0 100644 --- a/tests/commands/mcp.test.ts +++ b/tests/commands/mcp.test.ts @@ -849,6 +849,46 @@ describe('mcp server', () => { expect(calls).not.toContain('/v1.1/mindclip/assistant/daily'); }); + it('mindclip_recordings rejects empty deviceID/language strings rather than forwarding `?deviceID=`', async () => { + const { client } = await pair(); + const res = await client.callTool({ + name: 'mindclip_recordings', + arguments: { action: 'list', deviceID: '' }, + }); + expect(res.isError).toBeTruthy(); + expect(apiMock.__instance.get).not.toHaveBeenCalled(); + }); + + it('mindclip_list_todos rejects empty deviceID/fileID strings', async () => { + const { client } = await pair(); + const res = await client.callTool({ + name: 'mindclip_list_todos', + arguments: { fileID: '' }, + }); + expect(res.isError).toBeTruthy(); + expect(apiMock.__instance.get).not.toHaveBeenCalled(); + }); + + it('mindclip_recall rejects calendar-impossible dates (2026-02-30)', async () => { + const { client } = await pair(); + const res = await client.callTool({ + name: 'mindclip_recall', + arguments: { period: 'daily', date: '2026-02-30' }, + }); + expect(res.isError).toBeTruthy(); + expect(apiMock.__instance.get).not.toHaveBeenCalled(); + }); + + it('mindclip_recall rejects month-out-of-range dates (2026-13-01)', async () => { + const { client } = await pair(); + const res = await client.callTool({ + name: 'mindclip_recall', + arguments: { period: 'daily', date: '2026-13-01' }, + }); + expect(res.isError).toBeTruthy(); + expect(apiMock.__instance.get).not.toHaveBeenCalled(); + }); + it('run_scene POSTs the scene execute endpoint', async () => { apiMock.__instance.post.mockResolvedValueOnce({ data: { statusCode: 100, body: {} } }); const { client } = await pair(); From 86608ea83a5dc4989d9ce0e5b7472c62be457788 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 13 Jun 2026 22:55:01 +0800 Subject: [PATCH 42/57] fix(validation): reject W53 for short ISO years in weekArg and MCP week schema Add isLongISOYear() to arg-parsers (Jan 1 on Thursday, or leap year starting Wednesday). weekArg now throws for W53 on any year that only has 52 ISO weeks. mindclip_recall week Zod field gains a .refine() using the same logic. Tests cover accept/reject for 2026 (long) and 2027/2024 (short). --- src/commands/mcp.ts | 7 +++++-- src/utils/arg-parsers.ts | 16 ++++++++++++++ tests/commands/mcp.test.ts | 20 ++++++++++++++++++ tests/utils/arg-parsers.test.ts | 37 ++++++++++++++++++++++++++++++--- 4 files changed, 75 insertions(+), 5 deletions(-) diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index 4a0d1513..5618c8eb 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -3,7 +3,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { z } from 'zod'; -import { intArg, stringArg, DATE_REGEX, WEEK_REGEX, isCalendarValidDate } from '../utils/arg-parsers.js'; +import { intArg, stringArg, DATE_REGEX, WEEK_REGEX, isCalendarValidDate, isLongISOYear } from '../utils/arg-parsers.js'; import { handleError, isJsonMode, printJson, buildErrorPayload, exitWithError, type ErrorPayload, type ErrorSubKind } from '../utils/output.js'; import { VERSION } from '../version.js'; import { @@ -1404,7 +1404,10 @@ Tool profile: ${profileName} (${allowedTools.size} tools loaded).${profileName ! message: 'Invalid calendar date — month/day are out of range or otherwise impossible.', }).optional() .describe('YYYY-MM-DD — used by period="daily" or "urgent_todos"; omit for server default.'), - week: z.string().regex(WEEK_REGEX).optional() + week: z.string().regex(WEEK_REGEX).refine( + (v) => Number(v.slice(6)) < 53 || isLongISOYear(Number(v.slice(0, 4))), + { message: 'W53 does not exist for this year — only long ISO years (Jan 1 on Thursday, or leap year starting Wednesday) have 53 ISO weeks.' }, + ).optional() .describe('YYYY-Www (weeks 01-53) — used by period="weekly"; omit for server default.'), }).strict(), outputSchema: { diff --git a/src/utils/arg-parsers.ts b/src/utils/arg-parsers.ts index bd560cc2..0772777b 100644 --- a/src/utils/arg-parsers.ts +++ b/src/utils/arg-parsers.ts @@ -114,6 +114,11 @@ export function weekArg(flagName: string): (value: string) => string { `${flagName} must be in YYYY-Www format, weeks 01–53 (e.g. 2026-W23 — got "${value}")`, ); } + if (Number(value.slice(6)) === 53 && !isLongISOYear(Number(value.slice(0, 4)))) { + throw new InvalidArgumentError( + `${flagName}: ${value.slice(0, 4)} only has 52 ISO weeks; W53 does not exist for this year`, + ); + } return value; }; } @@ -133,3 +138,14 @@ export function isCalendarValidDate(value: string): boolean { const d = new Date(value); return !isNaN(d.getTime()) && d.toISOString().slice(0, 10) === value; } + +/** + * Returns true for ISO "long years" — years that have 53 ISO weeks. + * A year is long when Jan 1 falls on Thursday, or it is a leap year + * whose Jan 1 falls on Wednesday. + */ +export function isLongISOYear(year: number): boolean { + const jan1Day = new Date(year, 0, 1).getDay(); // 0=Sun … 6=Sat + const isLeap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0; + return jan1Day === 4 || (isLeap && jan1Day === 3); +} diff --git a/tests/commands/mcp.test.ts b/tests/commands/mcp.test.ts index f9b4a8c0..d58a9d8c 100644 --- a/tests/commands/mcp.test.ts +++ b/tests/commands/mcp.test.ts @@ -889,6 +889,26 @@ describe('mcp server', () => { expect(apiMock.__instance.get).not.toHaveBeenCalled(); }); + it('mindclip_recall rejects W53 for a short ISO year (2027)', async () => { + const { client } = await pair(); + const res = await client.callTool({ + name: 'mindclip_recall', + arguments: { period: 'weekly', week: '2027-W53' }, + }); + expect(res.isError).toBeTruthy(); + expect(apiMock.__instance.get).not.toHaveBeenCalled(); + }); + + it('mindclip_recall accepts W53 for a long ISO year (2026)', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ data: { body: {} } }); + const { client } = await pair(); + const res = await client.callTool({ + name: 'mindclip_recall', + arguments: { period: 'weekly', week: '2026-W53' }, + }); + expect(res.isError).toBeFalsy(); + }); + it('run_scene POSTs the scene execute endpoint', async () => { apiMock.__instance.post.mockResolvedValueOnce({ data: { statusCode: 100, body: {} } }); const { client } = await pair(); diff --git a/tests/utils/arg-parsers.test.ts b/tests/utils/arg-parsers.test.ts index f6d8857f..1f012258 100644 --- a/tests/utils/arg-parsers.test.ts +++ b/tests/utils/arg-parsers.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { InvalidArgumentError } from 'commander'; -import { intArg, durationArg, stringArg, enumArg, dateArg, weekArg } from '../../src/utils/arg-parsers.js'; +import { intArg, durationArg, stringArg, enumArg, dateArg, weekArg, isLongISOYear } from '../../src/utils/arg-parsers.js'; describe('intArg', () => { const parse = intArg('--max'); @@ -149,13 +149,27 @@ describe('dateArg', () => { describe('weekArg', () => { const parse = weekArg('--week'); - it('accepts valid ISO week strings W01-W53', () => { + it('accepts valid ISO week strings W01-W52', () => { expect(parse('2026-W23')).toBe('2026-W23'); expect(parse('2026-W01')).toBe('2026-W01'); - expect(parse('2026-W53')).toBe('2026-W53'); expect(parse('2026-W09')).toBe('2026-W09'); }); + it('accepts W53 for long ISO years (Jan 1 on Thursday)', () => { + // 2026: Jan 1 is Thursday → long year + expect(parse('2026-W53')).toBe('2026-W53'); + // 2015: Jan 1 is Thursday → long year + expect(parse('2015-W53')).toBe('2015-W53'); + }); + + it('rejects W53 for short ISO years', () => { + // 2027: Jan 1 is Friday → short year + expect(() => parse('2027-W53')).toThrow(InvalidArgumentError); + expect(() => parse('2027-W53')).toThrow(/only has 52 ISO weeks/); + // 2024: Jan 1 is Monday → short year + expect(() => parse('2024-W53')).toThrow(/only has 52 ISO weeks/); + }); + it('rejects W00 (week 0 does not exist)', () => { expect(() => parse('2026-W00')).toThrow(InvalidArgumentError); expect(() => parse('2026-W00')).toThrow(/YYYY-Www/); @@ -178,3 +192,20 @@ describe('weekArg', () => { expect(() => parse('2026-W5')).toThrow(/YYYY-Www/); }); }); + +describe('isLongISOYear', () => { + it('returns true for years where Jan 1 is Thursday', () => { + expect(isLongISOYear(2026)).toBe(true); // Jan 1 Thu + expect(isLongISOYear(2015)).toBe(true); // Jan 1 Thu + }); + + it('returns true for leap years where Jan 1 is Wednesday', () => { + expect(isLongISOYear(2020)).toBe(true); // Jan 1 Wed, leap year + }); + + it('returns false for short years', () => { + expect(isLongISOYear(2027)).toBe(false); // Jan 1 Fri + expect(isLongISOYear(2024)).toBe(false); // Jan 1 Mon + expect(isLongISOYear(2023)).toBe(false); // Jan 1 Sun + }); +}); From ec9b277aab9c04989d889df26c15b33602c0ee92 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 13 Jun 2026 22:56:13 +0800 Subject: [PATCH 43/57] fix(mcp): replace bare mcpError with apiErrorToMcpError in mindclip handlers mindclip_recordings, mindclip_list_todos, and mindclip_recall catch blocks were discarding subKind/retryable/retryAfterMs/hint from API errors. Switch to the shared apiErrorToMcpError helper so clients get the full structured error envelope. --- src/commands/mcp.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index 5618c8eb..a15f89fe 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -1336,7 +1336,7 @@ Tool profile: ${profileName} (${allowedTools.size} tools loaded).${profileName ! structuredContent: { data: data as Record }, }; } catch (err) { - return mcpError('api', 1, err instanceof Error ? err.message : String(err)); + return apiErrorToMcpError(err); } } ); @@ -1374,7 +1374,7 @@ Tool profile: ${profileName} (${allowedTools.size} tools loaded).${profileName ! structuredContent: { data: data as Record }, }; } catch (err) { - return mcpError('api', 1, err instanceof Error ? err.message : String(err)); + return apiErrorToMcpError(err); } } ); @@ -1429,7 +1429,7 @@ Tool profile: ${profileName} (${allowedTools.size} tools loaded).${profileName ! structuredContent: { data: data as Record }, }; } catch (err) { - return mcpError('api', 1, err instanceof Error ? err.message : String(err)); + return apiErrorToMcpError(err); } } ); From 3d9a44cc1e788e4d117f0551c9b4f02beb9c6454 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 13 Jun 2026 22:57:36 +0800 Subject: [PATCH 44/57] fix(mcp): reclassify history-store catch errors as kind=runtime runDeviceHistoryQuery and runDeviceHistoryAggregate already validate all user args before the try block; any error reaching the catch is a storage-layer fault (file I/O, corrupt data), not a usage mistake. Change kind from 'usage' to 'runtime' so callers see the correct error classification. --- src/commands/mcp.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index a15f89fe..2f5a4436 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -290,7 +290,7 @@ async function runDeviceHistoryQuery(args: { }; } catch (err) { const msg = err instanceof Error ? err.message : 'history query failed'; - return mcpError('usage', 2, msg); + return mcpError('runtime', 1, msg); } } @@ -338,7 +338,7 @@ async function runDeviceHistoryAggregate(args: { }; } catch (err) { const msg = err instanceof Error ? err.message : 'history aggregation failed'; - return mcpError('usage', 2, msg); + return mcpError('runtime', 1, msg); } } From 5408166ab406519453006b151237bff52fab3149 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 13 Jun 2026 22:59:20 +0800 Subject: [PATCH 45/57] fix(auth): guard clearCache/clearStatusCache against EBUSY on Windows Both auth login and config set-token call fs.unlinkSync via clearCache() which can throw EBUSY on Windows when another process holds the file. The in-memory portion still clears; wrap only the disk calls in a try/catch so the success output always reaches the user. --- src/commands/auth.ts | 8 ++++++-- src/commands/config.ts | 8 ++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 5cd728b3..312151f5 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -454,8 +454,12 @@ export function registerAuthCommand(program: Command): void { // Credentials changed — clear device/status cache so the next list // fetches fresh data for the new account instead of returning stale // results from the previous account's cache. - clearCache(); - clearStatusCache(); + try { + clearCache(); + clearStatusCache(); + } catch { + // Non-fatal on Windows EBUSY; in-memory is already invalidated. + } clearPrimedCredentials(); idempotencyCache.clear(); diff --git a/src/commands/config.ts b/src/commands/config.ts index 2794c823..1082d722 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -270,8 +270,12 @@ Files are written with mode 0600. Profiles live under ~/.switchbot/profiles/ Date: Sat, 13 Jun 2026 23:02:39 +0800 Subject: [PATCH 46/57] fix(auth): invalidate caches after keychain set/delete/migrate Extract onCredentialChange() helper (try/catch disk clear + in-memory clear) and call it from auth login, keychain set, keychain delete, and keychain migrate. Previously the three keychain subcommands wrote credentials but left the stale device list and status caches in place, causing subsequent requests to use data from the previous account. --- src/commands/auth.ts | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 312151f5..98bbe45d 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -82,6 +82,17 @@ async function promptSecret(question: string): Promise { }); } +function onCredentialChange(): void { + try { + clearCache(); + clearStatusCache(); + } catch { + // Non-fatal on Windows EBUSY; in-memory is already invalidated. + } + clearPrimedCredentials(); + idempotencyCache.clear(); +} + function readStdinFile(filePath: string): CredentialBundle { if (!fs.existsSync(filePath)) { exitWithError({ @@ -244,6 +255,7 @@ export function registerAuthCommand(program: Command): void { }); } + onCredentialChange(); if (isJsonMode()) { printJson({ profile, backend: store.name, written: true }); return; @@ -281,6 +293,7 @@ export function registerAuthCommand(program: Command): void { }); } + onCredentialChange(); if (isJsonMode()) { printJson({ profile, backend: store.name, deleted: true }); return; @@ -361,6 +374,7 @@ export function registerAuthCommand(program: Command): void { } } + onCredentialChange(); if (isJsonMode()) { printJson({ profile, @@ -454,14 +468,7 @@ export function registerAuthCommand(program: Command): void { // Credentials changed — clear device/status cache so the next list // fetches fresh data for the new account instead of returning stale // results from the previous account's cache. - try { - clearCache(); - clearStatusCache(); - } catch { - // Non-fatal on Windows EBUSY; in-memory is already invalidated. - } - clearPrimedCredentials(); - idempotencyCache.clear(); + onCredentialChange(); if (isJsonMode()) { printJson({ From d87a15053da86cc3ffa87eed36da57b6dfb24adf Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 13 Jun 2026 23:04:23 +0800 Subject: [PATCH 47/57] fix(mcp): enforce raw-mode limit cap at 100 in device_history The device_history raw-mode description advertised max 100 entries but the Zod schema allowed 10000 with no runtime enforcement. Add Math.min(limit ?? 20, 100) for raw mode and update the description to say "max 100 enforced at runtime" so the contract is unambiguous. --- src/commands/mcp.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index 2f5a4436..7f4a3167 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -479,7 +479,7 @@ Tool profile: ${profileName} (${allowedTools.size} tools loaded).${profileName ! ), // raw mode limit: z.number().int().min(1).max(10000).optional().describe( - 'raw: max history entries (default 20, max 100). query: max records (default 1000, max 10000).', + 'raw: max history entries (default 20, max 100 enforced at runtime). query: max records (default 1000, max 10000).', ), // query / aggregate mode (time range) since: z.string().optional().describe('Relative window ending now, e.g. "30s","15m","1h","7d". Mutually exclusive with from/to.'), @@ -539,7 +539,8 @@ Tool profile: ${profileName} (${allowedTools.size} tools loaded).${profileName ! if (args.mode === 'raw') { if (args.deviceId) { const latest = deviceHistoryStore.getLatest(args.deviceId); - const history = deviceHistoryStore.getHistory(args.deviceId, args.limit ?? 20); + const rawLimit = Math.min(args.limit ?? 20, 100); + const history = deviceHistoryStore.getHistory(args.deviceId, rawLimit); const result = { deviceId: args.deviceId, latest, history }; return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], From 60b76d2db1efddac5c34878a0dec8250729515f0 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 13 Jun 2026 23:07:02 +0800 Subject: [PATCH 48/57] fix(mcp): derive tool-count labels from TOOL_PROFILES instead of hardcoded literals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit readonly=14, default=17, all=28 (was 11/14/25 — all three were wrong). Replace literals in mcp.ts --tools option descriptions, gemini-checks.ts registration description, and all plugin package.json / README / manifest / .mcp.json files. --- packages/claude-code-plugin/.claude-plugin/marketplace.json | 2 +- packages/claude-code-plugin/package.json | 2 +- packages/codex-plugin/.mcp.json | 2 +- packages/codex-plugin/README.md | 2 +- packages/codex-plugin/package.json | 2 +- packages/codex-plugin/plugins/switchbot/.mcp.json | 2 +- packages/gemini-extension/README.md | 2 +- packages/gemini-extension/gemini-extension.json | 2 +- packages/gemini-extension/package.json | 2 +- src/commands/mcp.ts | 6 +++--- src/install/gemini-checks.ts | 3 ++- 11 files changed, 14 insertions(+), 13 deletions(-) diff --git a/packages/claude-code-plugin/.claude-plugin/marketplace.json b/packages/claude-code-plugin/.claude-plugin/marketplace.json index bbef9354..cd58136c 100644 --- a/packages/claude-code-plugin/.claude-plugin/marketplace.json +++ b/packages/claude-code-plugin/.claude-plugin/marketplace.json @@ -1,7 +1,7 @@ { "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", "name": "switchbot", - "description": "SwitchBot smart-home plugin for Claude Code — MCP server with 25 tools for controlling devices and scenes (default profile shows 14)", + "description": "SwitchBot smart-home plugin for Claude Code — MCP server with 28 tools for controlling devices and scenes (default profile shows 17)", "owner": { "name": "OpenWonderLabs", "email": "developer@wondertechlabs.com" diff --git a/packages/claude-code-plugin/package.json b/packages/claude-code-plugin/package.json index 494f4dce..66ea6981 100644 --- a/packages/claude-code-plugin/package.json +++ b/packages/claude-code-plugin/package.json @@ -2,7 +2,7 @@ "name": "@switchbot/claude-code-plugin", "version": "0.1.4", "type": "module", - "description": "SwitchBot Claude Code plugin — wires Claude Code to the SwitchBot CLI MCP server (default 14 tools; `--tools all` for 25)", + "description": "SwitchBot Claude Code plugin — wires Claude Code to the SwitchBot CLI MCP server (default 17 tools; `--tools all` for 28)", "homepage": "https://github.com/OpenWonderLabs/switchbot-openapi-cli/tree/main/packages/claude-code-plugin", "repository": { "type": "git", diff --git a/packages/codex-plugin/.mcp.json b/packages/codex-plugin/.mcp.json index 71c9380b..909b1efb 100644 --- a/packages/codex-plugin/.mcp.json +++ b/packages/codex-plugin/.mcp.json @@ -3,7 +3,7 @@ "switchbot": { "command": "switchbot", "args": ["mcp", "serve"], - "description": "SwitchBot smart-home MCP server (default 14 tools; `--tools all` for 25)" + "description": "SwitchBot smart-home MCP server (default 17 tools; `--tools all` for 28)" } } } diff --git a/packages/codex-plugin/README.md b/packages/codex-plugin/README.md index 44457f96..7a206b37 100644 --- a/packages/codex-plugin/README.md +++ b/packages/codex-plugin/README.md @@ -6,7 +6,7 @@ Codex plugin for SwitchBot smart-home control through the authoritative ## What it installs - A Codex skill at `skills/switchbot/SKILL.md` -- An MCP server definition that runs `switchbot mcp serve` (default profile, 14 tools; pass `--tools all` to add the policy/audit/rules tools for 25 total) +- An MCP server definition that runs `switchbot mcp serve` (default profile, 17 tools; pass `--tools all` to add the policy/audit/rules tools for 25 total) - A best-effort `onInstall` hook that runs non-interactive setup when the CLI is present - Legacy helper binaries: `switchbot-codex-auth` and `switchbot-codex-install` diff --git a/packages/codex-plugin/package.json b/packages/codex-plugin/package.json index 9fa419ca..62678fc4 100644 --- a/packages/codex-plugin/package.json +++ b/packages/codex-plugin/package.json @@ -2,7 +2,7 @@ "name": "@switchbot/codex-plugin", "version": "0.1.6", "type": "module", - "description": "SwitchBot Codex plugin — wires Codex to the SwitchBot CLI MCP server (default 14 tools; `--tools all` for 25)", + "description": "SwitchBot Codex plugin — wires Codex to the SwitchBot CLI MCP server (default 17 tools; `--tools all` for 28)", "homepage": "https://github.com/OpenWonderLabs/switchbot-openapi-cli/tree/main/packages/codex-plugin", "repository": { "type": "git", diff --git a/packages/codex-plugin/plugins/switchbot/.mcp.json b/packages/codex-plugin/plugins/switchbot/.mcp.json index 71c9380b..909b1efb 100644 --- a/packages/codex-plugin/plugins/switchbot/.mcp.json +++ b/packages/codex-plugin/plugins/switchbot/.mcp.json @@ -3,7 +3,7 @@ "switchbot": { "command": "switchbot", "args": ["mcp", "serve"], - "description": "SwitchBot smart-home MCP server (default 14 tools; `--tools all` for 25)" + "description": "SwitchBot smart-home MCP server (default 17 tools; `--tools all` for 28)" } } } diff --git a/packages/gemini-extension/README.md b/packages/gemini-extension/README.md index b7d272c0..4d166b39 100644 --- a/packages/gemini-extension/README.md +++ b/packages/gemini-extension/README.md @@ -1,7 +1,7 @@ # SwitchBot Gemini CLI Extension Gemini CLI native extension for SwitchBot smart-home control through the authoritative -`switchbot` CLI MCP server (default 14 tools, `--tools all` for 25, policy-based safety gates). +`switchbot` CLI MCP server (default 17 tools, `--tools all` for 28, policy-based safety gates). ## Install diff --git a/packages/gemini-extension/gemini-extension.json b/packages/gemini-extension/gemini-extension.json index 27adbacb..a13dd260 100644 --- a/packages/gemini-extension/gemini-extension.json +++ b/packages/gemini-extension/gemini-extension.json @@ -1,7 +1,7 @@ { "name": "switchbot", "version": "0.1.1", - "description": "Control SwitchBot smart-home devices from Gemini CLI via MCP (default 14 tools; `--tools all` for 25, policy-based safety gates)", + "description": "Control SwitchBot smart-home devices from Gemini CLI via MCP (default 17 tools; `--tools all` for 28, policy-based safety gates)", "contextFileName": "GEMINI.md", "mcpServers": { "switchbot": { diff --git a/packages/gemini-extension/package.json b/packages/gemini-extension/package.json index 34d230d5..6bdfd3aa 100644 --- a/packages/gemini-extension/package.json +++ b/packages/gemini-extension/package.json @@ -2,7 +2,7 @@ "name": "@switchbot/gemini-extension", "version": "0.1.1", "type": "module", - "description": "SwitchBot Gemini CLI extension — wires Gemini CLI to the SwitchBot MCP server (default 14 tools; `--tools all` for 25) via the native Extension system", + "description": "SwitchBot Gemini CLI extension — wires Gemini CLI to the SwitchBot MCP server (default 17 tools; `--tools all` for 28) via the native Extension system", "homepage": "https://github.com/OpenWonderLabs/switchbot-openapi-cli/tree/main/packages/gemini-extension", "repository": { "type": "git", diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index 7f4a3167..28fe157b 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -2655,7 +2655,7 @@ Inspect locally: mcp .command('tools') .description('Print the registered MCP tools in human or JSON form') - .option('--tools ', 'Tool profile: "default" (14 tools), "readonly" (11), or "all" (25). Lists all when omitted', stringArg('--tools'), 'all') + .option('--tools ', `Tool profile: "default" (${TOOL_PROFILES.default.size} tools), "readonly" (${TOOL_PROFILES.readonly.size}), or "all" (${TOOL_PROFILES.all.size}). Lists all when omitted`, stringArg('--tools'), 'all') .action((opts: { tools?: string }) => { try { printMcpToolDirectory(resolveToolProfile(opts.tools)); } catch (e) { handleError(e); } @@ -2664,7 +2664,7 @@ Inspect locally: mcp .command('list-tools') .description('Alias of `mcp tools`') - .option('--tools ', 'Tool profile: "default" (14 tools), "readonly" (11), or "all" (25). Lists all when omitted', stringArg('--tools'), 'all') + .option('--tools ', `Tool profile: "default" (${TOOL_PROFILES.default.size} tools), "readonly" (${TOOL_PROFILES.readonly.size}), or "all" (${TOOL_PROFILES.all.size}). Lists all when omitted`, stringArg('--tools'), 'all') .action((opts: { tools?: string }) => { try { printMcpToolDirectory(resolveToolProfile(opts.tools)); } catch (e) { handleError(e); } @@ -2678,7 +2678,7 @@ Inspect locally: .option('--auth-token ', 'Bearer token for HTTP requests (required for --bind 0.0.0.0; falls back to SWITCHBOT_MCP_TOKEN env var)', stringArg('--auth-token')) .option('--cors-origin ', 'Allowed CORS origin(s) for HTTP (repeatable)', stringArg('--cors-origin')) .option('--rate-limit ', 'Max requests per minute per profile (default 60)', intArg('--rate-limit', { min: 1 }), '60') - .option('--tools ', 'Tool profile: "default" (14 tools), "readonly" (11), or "all" (25)', stringArg('--tools'), 'default') + .option('--tools ', `Tool profile: "default" (${TOOL_PROFILES.default.size} tools), "readonly" (${TOOL_PROFILES.readonly.size}), or "all" (${TOOL_PROFILES.all.size})`, stringArg('--tools'), 'default') .addHelpText('after', ` Examples: $ switchbot mcp serve diff --git a/src/install/gemini-checks.ts b/src/install/gemini-checks.ts index b92e7f12..23c538c5 100644 --- a/src/install/gemini-checks.ts +++ b/src/install/gemini-checks.ts @@ -2,6 +2,7 @@ import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; +import { TOOL_PROFILES } from '../mcp/tool-profiles.js'; export interface Check { name: string; @@ -95,7 +96,7 @@ export function registerMcp(): RegisterMcpResult { [MCP_SERVER_NAME]: { command: 'switchbot', args: ['mcp', 'serve'], - description: 'SwitchBot smart-home MCP server (default: 14 tools; `--tools all` for 25)', + description: `SwitchBot smart-home MCP server (default: ${TOOL_PROFILES.default.size} tools; \`--tools all\` for ${TOOL_PROFILES.all.size})`, }, }; fs.mkdirSync(path.dirname(GEMINI_SETTINGS_PATH), { recursive: true }); From 444a46fe607c64aa85d7c58523ab9cf0f0e2f55e Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 13 Jun 2026 23:08:49 +0800 Subject: [PATCH 49/57] fix(capabilities): exclude deprecated MCP aliases from capabilities output Add DEPRECATED_MCP_TOOLS export to tool-profiles.ts (the three 3.x backward-compat aliases: get/query/aggregate_device_history). capabilities --surface mcp now filters them out so only the 25 current tools are advertised, while the MCP server still registers all 28 for backward compatibility. --- src/commands/capabilities.ts | 10 +++++++--- src/mcp/tool-profiles.ts | 7 +++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/commands/capabilities.ts b/src/commands/capabilities.ts index 17692534..8cdf0c49 100644 --- a/src/commands/capabilities.ts +++ b/src/commands/capabilities.ts @@ -11,7 +11,7 @@ import { loadCache } from '../devices/cache.js'; import { printJson } from '../utils/output.js'; import { enumArg, stringArg } from '../utils/arg-parsers.js'; import { IDENTITY } from './identity.js'; -import { TOOL_PROFILES } from '../mcp/tool-profiles.js'; +import { TOOL_PROFILES, DEPRECATED_MCP_TOOLS } from '../mcp/tool-profiles.js'; /** Collect the distinct catalog safety tiers actually used across the given entries. Sorted. */ function collectSafetyTiersInUse(entries: DeviceCatalogEntry[]): SafetyTier[] { @@ -243,8 +243,12 @@ function metaFor(command: string): CommandMeta | null { // Derived from the single source of truth in src/mcp/tool-profiles.ts so that // `capabilities --surface mcp` never drifts behind the actual MCP server tool -// registration. Sorted for stable output. -export const MCP_TOOLS = [...TOOL_PROFILES.all].sort(); +// registration. Sorted for stable output. Deprecated aliases are excluded — +// they remain registered in the MCP server for backward compat but are not +// advertised as part of the current API surface. +export const MCP_TOOLS = [...TOOL_PROFILES.all] + .filter((t) => !DEPRECATED_MCP_TOOLS.has(t)) + .sort(); const IDEMPOTENCY_CONTRACT = { flag: '--idempotency-key ', diff --git a/src/mcp/tool-profiles.ts b/src/mcp/tool-profiles.ts index 614e6ee3..8cdd736d 100644 --- a/src/mcp/tool-profiles.ts +++ b/src/mcp/tool-profiles.ts @@ -40,6 +40,13 @@ export const TOOL_PROFILES: Record> = { all: new Set([...CORE_READ, ...CORE_ACTION, ...ADMIN]), }; +/** 3.x backward-compat aliases registered in the MCP server but removed in 4.0. */ +export const DEPRECATED_MCP_TOOLS: ReadonlySet = new Set([ + 'get_device_history', + 'query_device_history', + 'aggregate_device_history', +]); + export const VALID_PROFILES = Object.keys(TOOL_PROFILES) as readonly ToolProfile[]; export function resolveToolProfile(name?: string): ToolProfile { From 038a7491ac3eb8acb6a2ca063af0e8794e2b16ee Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 13 Jun 2026 23:09:54 +0800 Subject: [PATCH 50/57] fix(catalog): add aliases to AI MindClip device entry search_catalog and describe_device use aliases for fuzzy matching. Without aliases, queries like "MindClip" or "Mind Clip" returned no results even though the device is present. --- src/devices/catalog.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/devices/catalog.ts b/src/devices/catalog.ts index e4c40eb3..ec598a3c 100644 --- a/src/devices/catalog.ts +++ b/src/devices/catalog.ts @@ -214,6 +214,7 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ // ---------- Physical devices ---------- { type: 'AI MindClip', + aliases: ['AIMindClip', 'MindClip', 'Mind Clip'], category: 'physical', description: 'AI-powered voice recorder with transcription and meeting summaries.', role: 'other', From 99c9f14522aa1d51646561cc1302c527d42fcbdc Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 13 Jun 2026 23:14:32 +0800 Subject: [PATCH 51/57] fix(idempotency): profile-scope the idempotency cache Add optional profile parameter to IdempotencyCache.run() and a new clearForProfile(profile) method that evicts only entries tagged with that profile. sendDeviceCommand now passes getActiveProfile() so that credential-change clears only the affected profile's dedup window rather than the entire cache. auth and config call clearForProfile() on the active profile instead of clear(). --- src/commands/auth.ts | 2 +- src/commands/config.ts | 3 ++- src/lib/devices.ts | 2 ++ src/lib/idempotency.ts | 15 +++++++++++++-- tests/lib/devices.test.ts | 4 ++++ tests/lib/idempotency.test.ts | 25 +++++++++++++++++++++++++ 6 files changed, 47 insertions(+), 4 deletions(-) diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 98bbe45d..0c1a0728 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -90,7 +90,7 @@ function onCredentialChange(): void { // Non-fatal on Windows EBUSY; in-memory is already invalidated. } clearPrimedCredentials(); - idempotencyCache.clear(); + idempotencyCache.clearForProfile(activeProfile()); } function readStdinFile(filePath: string): CredentialBundle { diff --git a/src/commands/config.ts b/src/commands/config.ts index 1082d722..279550e4 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -11,6 +11,7 @@ import { isJsonMode, printJson, exitWithError } from '../utils/output.js'; import { clearCache, clearStatusCache } from '../devices/cache.js'; import { clearPrimedCredentials } from '../credentials/prime.js'; import { idempotencyCache } from '../lib/idempotency.js'; +import { getActiveProfile } from '../lib/request-context.js'; import chalk from 'chalk'; function parseEnvFile(file: string): { token?: string; secret?: string } { @@ -277,7 +278,7 @@ Files are written with mode 0600. Profiles live under ~/.switchbot/profiles/(); + private cache = new Map(); private readonly ttlMs: number; private readonly maxEntries: number; @@ -67,6 +67,9 @@ export class IdempotencyCache { * (command, parameter) fingerprint; mismatched shape raises * {@link IdempotencyConflictError}. * + * `profile` tags the entry so {@link clearForProfile} can evict only the + * entries belonging to a specific credential profile. + * * Returns a tuple-esque object with `replayed: true` when the cached * result is served. The `result` field is the original cached value. */ @@ -74,6 +77,7 @@ export class IdempotencyCache { key: string | undefined, fn: () => Promise, shape?: { command: string; parameter: unknown }, + profile?: string, ): Promise<{ result: T; replayed: boolean }> { if (!key) { const result = await fn(); @@ -115,10 +119,17 @@ export class IdempotencyCache { } } - this.cache.set(hashed, { result, expiresAt: now + this.ttlMs, shape: currentShape }); + this.cache.set(hashed, { result, expiresAt: now + this.ttlMs, shape: currentShape, profile }); return { result, replayed: false }; } + /** Remove all cached entries that were stored under the given profile. */ + clearForProfile(profile: string): void { + for (const [k, v] of this.cache.entries()) { + if (v.profile === profile) this.cache.delete(k); + } + } + clear(): void { this.cache.clear(); } diff --git a/tests/lib/devices.test.ts b/tests/lib/devices.test.ts index 877e455a..7c2f47b9 100644 --- a/tests/lib/devices.test.ts +++ b/tests/lib/devices.test.ts @@ -56,6 +56,10 @@ vi.mock('../../src/devices/catalog.js', () => ({ getCommandSafetyReason: vi.fn(() => null), })); +vi.mock('../../src/lib/request-context.js', () => ({ + getActiveProfile: vi.fn(() => 'default'), +})); + describe('executeCommand audit semantics', () => { let tmp: string; let auditFile: string; diff --git a/tests/lib/idempotency.test.ts b/tests/lib/idempotency.test.ts index 825dde58..2902f332 100644 --- a/tests/lib/idempotency.test.ts +++ b/tests/lib/idempotency.test.ts @@ -75,6 +75,31 @@ describe('IdempotencyCache', () => { expect(fn).toHaveBeenCalledTimes(2); }); + it('clearForProfile() removes only entries tagged with the given profile', async () => { + const cache = new IdempotencyCache(60000); + const fn = vi.fn().mockResolvedValue('v'); + await cache.run('k-a', fn, undefined, 'profileA'); + await cache.run('k-b', fn, undefined, 'profileB'); + expect(cache.size()).toBe(2); + cache.clearForProfile('profileA'); + expect(cache.size()).toBe(1); + // profileA entry gone — next run re-executes + const r = await cache.run('k-a', fn, undefined, 'profileA'); + expect(r.replayed).toBe(false); + // profileB entry still cached + const r2 = await cache.run('k-b', fn, undefined, 'profileB'); + expect(r2.replayed).toBe(true); + }); + + it('clearForProfile() leaves entries with no profile untouched', async () => { + const cache = new IdempotencyCache(60000); + const fn = vi.fn().mockResolvedValue(1); + await cache.run('k', fn); // no profile + cache.clearForProfile('work'); + const r = await cache.run('k', fn); // still cached + expect(r.replayed).toBe(true); + }); + it('C4: raises IdempotencyConflictError when same key is used with different shape within TTL', async () => { const cache = new IdempotencyCache(60000); await cache.run('k', async () => 'ok', { command: 'turnOn', parameter: undefined }); From 5e182e0f5d4cd35d7b485c1ce6a61d8d344634ad Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 13 Jun 2026 23:15:50 +0800 Subject: [PATCH 52/57] fix(credentials): guard primeCredentials against stale write after clear clearPrimedCredentials() could be called while store.get() was still in flight, causing the resolved value to overwrite the freshly-cleared cache. Introduce a generation counter: primeCredentials() captures the counter before the await and only writes the result when the counter is unchanged. clearPrimedCredentials() and __resetPrimedCredentials() both increment the counter on clear. --- src/credentials/prime.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/credentials/prime.ts b/src/credentials/prime.ts index b678871d..46877826 100644 --- a/src/credentials/prime.ts +++ b/src/credentials/prime.ts @@ -29,6 +29,7 @@ interface CacheEntry { } let cache: CacheEntry | null = null; +let generation = 0; function isCacheValid(profile: string): boolean { if (!cache) return false; @@ -41,14 +42,21 @@ function isCacheValid(profile: string): boolean { * the result. Subsequent calls within CACHE_TTL_MS short-circuit. * After TTL expires, credentials are re-read from the keychain. * Swallows all errors. + * + * A generation counter guards against the race where clearPrimedCredentials() + * fires while store.get() is still in flight — if the generation changed, we + * discard the stale result instead of overwriting the now-empty cache. */ export async function primeCredentials(profile: string): Promise { if (isCacheValid(profile)) return; + const gen = generation; try { const store = await selectCredentialStore(); const creds = await store.get(profile); + if (generation !== gen) return; cache = { profile, creds, timestamp: Date.now() }; } catch { + if (generation !== gen) return; cache = { profile, creds: null, timestamp: Date.now() }; } } @@ -68,6 +76,7 @@ export function getPrimedCredentials(profile: string): CredentialBundle | null { * Test helper. Not used by production code. */ export function __resetPrimedCredentials(): void { + generation++; cache = null; } @@ -77,5 +86,6 @@ export function __resetPrimedCredentials(): void { * token/secret from the previous account. */ export function clearPrimedCredentials(): void { + generation++; cache = null; } From 739fc5c2db318ffcc9dce370477cb9e76b464d89 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 13 Jun 2026 23:18:07 +0800 Subject: [PATCH 53/57] test(tool-profiles): update MCP_TOOLS sync assertion to account for deprecated exclusion MCP_TOOLS now excludes the 3 deprecated aliases (Commit 10); update the sync test to verify that MCP_TOOLS contains all non-deprecated registered tools, that deprecated aliases are registered but not advertised, and that total counts add up correctly. --- tests/mcp/tool-profiles.test.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/tests/mcp/tool-profiles.test.ts b/tests/mcp/tool-profiles.test.ts index bd41fe14..a832b8f5 100644 --- a/tests/mcp/tool-profiles.test.ts +++ b/tests/mcp/tool-profiles.test.ts @@ -91,11 +91,21 @@ describe('tool-profiles', () => { }); describe('capabilities MCP_TOOLS stays in sync with registered tools', () => { - it('MCP_TOOLS matches the names registered under toolProfile=all', () => { + it('MCP_TOOLS matches registered tools under toolProfile=all, minus deprecated aliases', () => { const server = createSwitchBotMcpServer({ toolProfile: 'all' }); - const registered = [...listRegisteredTools(server)].sort(); - const advertised = [...MCP_TOOLS].sort(); - expect(advertised).toEqual(registered); + const registered = new Set(listRegisteredTools(server)); + // MCP_TOOLS excludes deprecated aliases; all other registered tools must be present. + for (const tool of MCP_TOOLS) { + expect(registered).toContain(tool); + } + // Deprecated aliases are registered for backward compat but not advertised. + const deprecated = ['get_device_history', 'query_device_history', 'aggregate_device_history']; + for (const alias of deprecated) { + expect(registered).toContain(alias); + expect(MCP_TOOLS).not.toContain(alias); + } + // Total: MCP_TOOLS.length + 3 deprecated = registered.size + expect(MCP_TOOLS.length + deprecated.length).toBe(registered.size); }); }); }); From e0dd7d4abb62c099ba340a770f27eb8485d39573 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 13 Jun 2026 23:20:25 +0800 Subject: [PATCH 54/57] chore: release 3.8.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 13 bug fixes from code-review pass on the AI MindClip branch — URL injection, schema validation, ISO week calendar check, error envelope, cache invalidation, tool-count drift, and race conditions. Full change list in CHANGELOG.md. --- CHANGELOG.md | 18 ++++++++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53e2b208..1c5e1636 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,24 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - **`reset` no longer aborts before printing the result summary** — the in-memory cache cleanup (`clearCache` / `clearStatusCache`) was rerunning `unlinkSync` on a file the data-file loop had already attempted to delete. On a permission-denied path that re-throw skipped both the in-memory clear and the result table. The reset command now uses the pure in-memory `resetListCache` / `resetStatusCache` helpers; disk deletion stays the sole responsibility of the data-file loop, where errors are reported into `results`. - **`capabilities --surface mcp` lists every registered MCP tool** — `MCP_TOOLS` was a hand-maintained array that had drifted. The list is now derived from the `TOOL_PROFILES.all` single source of truth, and a new test in `tool-profiles.test.ts` asserts the advertised set matches what `createSwitchBotMcpServer({ toolProfile: 'all' })` actually registers, so any future drift fails CI. +## [3.8.1] + +### Fixed + +- **MindClip URL path injection** — `getRecording` and `getSummary` now call `encodeURIComponent()` on the id before interpolating into the URL path; slashes, `?`, `#`, and `..` traversal segments can no longer escape the path prefix or smuggle query parameters. +- **MindClip MCP Zod schema tightening** — `mindclip_recordings` and `mindclip_list_todos` optional string inputs (`language`, `deviceID`, `fileID`) now use `.min(1)` to reject empty strings that previously bypassed validation. `mindclip_recall` date field gains a `.refine()` check against impossible calendar dates (e.g. `2026-02-30`, `2026-13-01`). +- **ISO W53 validation** — `weekArg` (CLI) and the `mindclip_recall` MCP `week` field now reject W53 for short ISO years; only years whose January 1 falls on a Thursday (or leap years starting on Wednesday) have a 53rd ISO week. +- **MindClip MCP error envelope** — three mindclip handlers (`mindclip_recordings`, `mindclip_list_todos`, `mindclip_recall`) were using a bare `mcpError('api', 1, err.message)` that discarded `subKind`, `retryable`, `retryAfterMs`, and `hint`. Switched to `apiErrorToMcpError()` so all structured error fields are preserved. +- **History-store catch kind** — `runDeviceHistoryQuery` and `runDeviceHistoryAggregate` catch blocks changed from `kind='usage'` to `kind='runtime'`; all user-input validation happens before the try block, so any thrown error is a storage-layer fault, not a caller mistake. +- **`clearCache` / `clearStatusCache` EBUSY on Windows** — `auth login` and `config set-token` called `fs.unlinkSync` directly; on Windows a concurrent reader can cause `EBUSY` which skipped the success output. Disk-only calls are now wrapped in try/catch; the in-memory portion always clears. +- **`auth keychain set/delete/migrate` missing cache invalidation** — the three keychain subcommands wrote new credentials but left the device-list cache, status cache, primed-credentials cache, and idempotency cache untouched. All three now call `onCredentialChange()`, the same helper used by `auth login`. +- **`device_history` raw-mode limit cap** — Zod schema allowed `max(10000)` for the shared `limit` field, but the description and deprecated `get_device_history` both said max 100 for raw mode. `device_history(mode="raw")` now applies `Math.min(limit ?? 20, 100)` at runtime and the description is updated to say "max 100 enforced at runtime". +- **Stale tool-count labels** — `readonly`/`default`/`all` profile sizes were hardcoded as 11/14/25 in `mcp tools` help text, `gemini-checks.ts`, and all plugin manifests; the true values are 14/17/28. All labels now derive from `TOOL_PROFILES.*.size`. +- **`capabilities --surface mcp` advertising deprecated aliases** — `MCP_TOOLS` now filters out the 3 deprecated `*_device_history` aliases via the new `DEPRECATED_MCP_TOOLS` export from `tool-profiles.ts`. The aliases remain registered in the MCP server for backward compat. +- **AI MindClip catalog aliases missing** — the `AI MindClip` entry in `DEVICE_CATALOG` had no `aliases` array; `search_catalog` and `describe_device` queries for `"MindClip"` or `"Mind Clip"` returned no results. Aliases `['AIMindClip', 'MindClip', 'Mind Clip']` added. +- **Idempotency cache not scoped per profile** — `idempotencyCache.clear()` on credential change wiped all profiles' dedup windows. A new optional `profile` parameter on `IdempotencyCache.run()` tags each entry; the new `clearForProfile(profile)` method evicts only that profile's entries. `sendDeviceCommand` now passes `getActiveProfile()` and `auth`/`config` call `clearForProfile` instead of `clear`. +- **`primeCredentials` stale-write race** — when `clearPrimedCredentials()` fired while `store.get()` was still in flight, the resolved value could overwrite the freshly-cleared cache. A generation counter prevents this: `primeCredentials` captures the counter before awaiting and discards the result if it has changed. + ## [3.7.9] ### Added diff --git a/package-lock.json b/package-lock.json index 7e802087..c49fca97 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@switchbot/openapi-cli", - "version": "3.8.0", + "version": "3.8.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@switchbot/openapi-cli", - "version": "3.8.0", + "version": "3.8.1", "license": "MIT", "workspaces": [ "packages/*" diff --git a/package.json b/package.json index a6f209a9..c6afed63 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@switchbot/openapi-cli", - "version": "3.8.0", + "version": "3.8.1", "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", From dfb8ae5185e499dc84ef453481a95237ce74cdbc Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sun, 14 Jun 2026 00:06:03 +0800 Subject: [PATCH 55/57] fix(review): address 15 code-review findings from PR #67 - idempotency: incorporate profile into hash key so cross-profile entries never collide (lookup was keyed on raw key, ignoring the stored profile) - cache: move in-memory Map clear before fs.unlinkSync in clearCache() and clearStatusCache() so Windows EBUSY no longer leaves stale data in memory - auth: export onCredentialChange(); remove stale 'in-memory already invalidated' comment (it wasn't, until the ordering fix above) - config: replace open-coded credential-invalidation block with onCredentialChange() to eliminate divergence risk - mcp: add superRefine to mindclip_recall schema rejecting cross-period fields (date with period=weekly, week with period=daily/urgent_todos) - mcp: add superRefine to device_history schema enforcing limit<=100 for raw mode; fix falsy deviceId check to !== undefined; add .min(1) to deviceId schema - arg-parsers: fix isLongISOYear to use Date.UTC/getUTCDay() instead of local-time Date constructor (TZ-sensitive on historical clock-shift dates) - arg-parsers: give calendar-invalid dates a distinct error message from format-invalid ones in dateArg() - credentials/prime: replace single global CacheEntry slot with Map and per-profile generation counters so clearPrimedCredentials(profile) only evicts one profile's entry - mindclip: extend compact() to filter null/NaN/empty string - mcp: extract deprecatedAlias() helper to emit description prefix and _meta.deprecated atomically, preventing future drift between the two - githooks: extract COMSPEC fix-up block into common.sh, sourced from both pre-commit and pre-push --- .githooks/common.sh | 7 ++++ .githooks/pre-commit | 8 ++--- .githooks/pre-push | 8 ++--- src/commands/auth.ts | 12 +++---- src/commands/config.ts | 14 ++------ src/commands/mcp.ts | 46 +++++++++++++++++--------- src/credentials/prime.ts | 57 +++++++++++++++++++++------------ src/devices/cache.ts | 12 ++++--- src/lib/idempotency.ts | 7 ++-- src/lib/mindclip.ts | 4 ++- src/utils/arg-parsers.ts | 4 +-- tests/commands/mcp.test.ts | 25 +++++---------- tests/credentials/prime.test.ts | 4 +-- tests/utils/arg-parsers.test.ts | 4 +-- 14 files changed, 113 insertions(+), 99 deletions(-) create mode 100644 .githooks/common.sh diff --git a/.githooks/common.sh b/.githooks/common.sh new file mode 100644 index 00000000..8b82ac8e --- /dev/null +++ b/.githooks/common.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env sh +# Windows: Git Bash clears COMSPEC, which causes npm's child_process.spawn to +# receive an undefined shell path and fail with ERR_INVALID_ARG_TYPE. +if [ -z "${COMSPEC:-}" ] && [ -f "/c/Windows/System32/cmd.exe" ]; then + COMSPEC="C:\\Windows\\System32\\cmd.exe" + export COMSPEC +fi diff --git a/.githooks/pre-commit b/.githooks/pre-commit index fee616ef..d3f18d64 100644 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -4,12 +4,8 @@ set -eu REPO_ROOT="$(git rev-parse --show-toplevel)" cd "$REPO_ROOT" -# Windows: Git Bash clears COMSPEC, which causes npm's child_process.spawn to -# receive an undefined shell path and fail with ERR_INVALID_ARG_TYPE. -if [ -z "${COMSPEC:-}" ] && [ -f "/c/Windows/System32/cmd.exe" ]; then - COMSPEC="C:\\Windows\\System32\\cmd.exe" - export COMSPEC -fi +# shellcheck source=common.sh +. "$REPO_ROOT/.githooks/common.sh" echo "[pre-commit] packaging sanity checks" npm run verify:pre-commit diff --git a/.githooks/pre-push b/.githooks/pre-push index 796dc7a5..5f9758e4 100644 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -4,12 +4,8 @@ set -eu REPO_ROOT="$(git rev-parse --show-toplevel)" cd "$REPO_ROOT" -# Windows: Git Bash clears COMSPEC, which causes npm's child_process.spawn to -# receive an undefined shell path and fail with ERR_INVALID_ARG_TYPE. -if [ -z "${COMSPEC:-}" ] && [ -f "/c/Windows/System32/cmd.exe" ]; then - COMSPEC="C:\\Windows\\System32\\cmd.exe" - export COMSPEC -fi +# shellcheck source=common.sh +. "$REPO_ROOT/.githooks/common.sh" echo "[pre-push] release gate" npm run verify:release-gate diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 0c1a0728..6b9db1b8 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -82,14 +82,10 @@ async function promptSecret(question: string): Promise { }); } -function onCredentialChange(): void { - try { - clearCache(); - clearStatusCache(); - } catch { - // Non-fatal on Windows EBUSY; in-memory is already invalidated. - } - clearPrimedCredentials(); +export function onCredentialChange(): void { + clearCache(); + clearStatusCache(); + clearPrimedCredentials(activeProfile()); idempotencyCache.clearForProfile(activeProfile()); } diff --git a/src/commands/config.ts b/src/commands/config.ts index 279550e4..f2d85b23 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -8,10 +8,7 @@ import { stringArg } from '../utils/arg-parsers.js'; import { intArg } from '../utils/arg-parsers.js'; import { saveConfig, showConfig, getConfigSummary, listProfiles, readProfileMeta } from '../config.js'; import { isJsonMode, printJson, exitWithError } from '../utils/output.js'; -import { clearCache, clearStatusCache } from '../devices/cache.js'; -import { clearPrimedCredentials } from '../credentials/prime.js'; -import { idempotencyCache } from '../lib/idempotency.js'; -import { getActiveProfile } from '../lib/request-context.js'; +import { onCredentialChange } from './auth.js'; import chalk from 'chalk'; function parseEnvFile(file: string): { token?: string; secret?: string } { @@ -271,14 +268,7 @@ Files are written with mode 0600. Profiles live under ~/.switchbot/profiles/ { + if (val.mode === 'raw' && val.limit !== undefined && val.limit > 100) { + ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['limit'], message: 'limit for mode="raw" cannot exceed 100' }); + } + }), outputSchema: { // raw mode (deviceId set) deviceId: z.string().optional(), @@ -537,7 +548,7 @@ Tool profile: ${profileName} (${allowedTools.size} tools loaded).${profileName ! async (args) => { // ---- raw mode ------------------------------------------------------ if (args.mode === 'raw') { - if (args.deviceId) { + if (args.deviceId !== undefined) { const latest = deviceHistoryStore.getLatest(args.deviceId); const rawLimit = Math.min(args.limit ?? 20, 100); const history = deviceHistoryStore.getHistory(args.deviceId, rawLimit); @@ -575,11 +586,9 @@ Tool profile: ${profileName} (${allowedTools.size} tools loaded).${profileName ! 'get_device_history', { title: '[Deprecated] Latest + recent device history', - description: - '[DEPRECATED — use device_history(mode="raw")]. ' + - 'Read the latest entry plus the most recent N records for one device, or list devices with stored history when deviceId is omitted. ' + - 'No API call — zero quota cost.', - _meta: { agentSafetyTier: 'read', deprecated: true, replacement: 'device_history' }, + ...deprecatedAlias('device_history', 'raw', + 'Read the latest entry plus the most recent N records for one device, or list devices with stored history when deviceId is omitted. No API call — zero quota cost.', + ), inputSchema: z.object({ deviceId: z.string().optional().describe( 'Device MAC address. Omit to list all devices with stored history.', @@ -621,10 +630,9 @@ Tool profile: ${profileName} (${allowedTools.size} tools loaded).${profileName ! 'query_device_history', { title: '[Deprecated] Time-ranged device history query', - description: - '[DEPRECATED — use device_history(mode="query")]. ' + + ...deprecatedAlias('device_history', 'query', 'Return time-ranged records (since OR from/to) with optional field projection and limit. No API call.', - _meta: { agentSafetyTier: 'read', deprecated: true, replacement: 'device_history' }, + ), inputSchema: z.object({ deviceId: z.string().describe('Device MAC address (required).'), since: z.string().optional().describe('Relative window ending now, e.g. "30s","15m","1h","7d". Mutually exclusive with from/to.'), @@ -653,10 +661,9 @@ Tool profile: ${profileName} (${allowedTools.size} tools loaded).${profileName ! 'aggregate_device_history', { title: '[Deprecated] Bucketed device-history aggregation', - description: - '[DEPRECATED — use device_history(mode="aggregate")]. ' + + ...deprecatedAlias('device_history', 'aggregate', 'Return bucketed statistics (count/min/max/avg/sum/p50/p95) over numeric metrics. No API call.', - _meta: { agentSafetyTier: 'read', deprecated: true, replacement: 'device_history' }, + ), inputSchema: z.object({ deviceId: z.string().describe('Device MAC address (required).'), since: z.string().optional().describe('Relative window ending now, e.g. "30s","15m","1h","7d". Mutually exclusive with from/to.'), @@ -1410,7 +1417,14 @@ Tool profile: ${profileName} (${allowedTools.size} tools loaded).${profileName ! { message: 'W53 does not exist for this year — only long ISO years (Jan 1 on Thursday, or leap year starting Wednesday) have 53 ISO weeks.' }, ).optional() .describe('YYYY-Www (weeks 01-53) — used by period="weekly"; omit for server default.'), - }).strict(), + }).strict().superRefine((val, ctx) => { + if (val.period !== 'weekly' && val.week !== undefined) { + ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['week'], message: '`week` is only valid when period="weekly"' }); + } + if (val.period === 'weekly' && val.date !== undefined) { + ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['date'], message: '`date` is only valid when period="daily" or "urgent_todos"' }); + } + }), outputSchema: { data: z.unknown().describe('Period-shaped envelope: daily -> daily recall, weekly -> weekly summary, urgent_todos -> urgent todos list.'), }, diff --git a/src/credentials/prime.ts b/src/credentials/prime.ts index 46877826..958d0b5c 100644 --- a/src/credentials/prime.ts +++ b/src/credentials/prime.ts @@ -28,13 +28,17 @@ interface CacheEntry { timestamp: number; } -let cache: CacheEntry | null = null; -let generation = 0; +const caches = new Map(); +const generations = new Map(); function isCacheValid(profile: string): boolean { - if (!cache) return false; - if (cache.profile !== profile) return false; - return (Date.now() - cache.timestamp) < CACHE_TTL_MS; + const entry = caches.get(profile); + if (!entry) return false; + return (Date.now() - entry.timestamp) < CACHE_TTL_MS; +} + +function genFor(profile: string): number { + return generations.get(profile) ?? 0; } /** @@ -43,21 +47,22 @@ function isCacheValid(profile: string): boolean { * After TTL expires, credentials are re-read from the keychain. * Swallows all errors. * - * A generation counter guards against the race where clearPrimedCredentials() - * fires while store.get() is still in flight — if the generation changed, we - * discard the stale result instead of overwriting the now-empty cache. + * A per-profile generation counter guards against the race where + * clearPrimedCredentials(profile) fires while store.get() is still in + * flight — if the generation changed, we discard the stale result instead + * of overwriting the now-empty cache. */ export async function primeCredentials(profile: string): Promise { if (isCacheValid(profile)) return; - const gen = generation; + const gen = genFor(profile); try { const store = await selectCredentialStore(); const creds = await store.get(profile); - if (generation !== gen) return; - cache = { profile, creds, timestamp: Date.now() }; + if (genFor(profile) !== gen) return; + caches.set(profile, { profile, creds, timestamp: Date.now() }); } catch { - if (generation !== gen) return; - cache = { profile, creds: null, timestamp: Date.now() }; + if (genFor(profile) !== gen) return; + caches.set(profile, { profile, creds: null, timestamp: Date.now() }); } } @@ -67,25 +72,35 @@ export async function primeCredentials(profile: string): Promise { * so existing file-based fallback stays the authoritative source. */ export function getPrimedCredentials(profile: string): CredentialBundle | null { - if (!cache) return null; - if (cache.profile !== profile) return null; - return cache.creds; + return caches.get(profile)?.creds ?? null; } /** * Test helper. Not used by production code. */ export function __resetPrimedCredentials(): void { - generation++; - cache = null; + for (const p of caches.keys()) { + generations.set(p, (generations.get(p) ?? 0) + 1); + } + caches.clear(); } /** * Production helper — called by auth and config commands after saving new * credentials to ensure the 5-second priming cache does not serve stale * token/secret from the previous account. + * + * Pass a specific `profile` to evict only that profile's entry (preferred + * for auth operations on a single profile). Omit to clear all profiles. */ -export function clearPrimedCredentials(): void { - generation++; - cache = null; +export function clearPrimedCredentials(profile?: string): void { + if (profile !== undefined) { + generations.set(profile, (generations.get(profile) ?? 0) + 1); + caches.delete(profile); + } else { + for (const p of caches.keys()) { + generations.set(p, (generations.get(p) ?? 0) + 1); + } + caches.clear(); + } } diff --git a/src/devices/cache.ts b/src/devices/cache.ts index 75615304..b785da3c 100644 --- a/src/devices/cache.ts +++ b/src/devices/cache.ts @@ -197,9 +197,11 @@ export function updateCacheFromDeviceList(body: DeviceListBodyShape): void { } export function clearCache(): void { - const file = cacheFilePath(); - if (fs.existsSync(file)) fs.unlinkSync(file); _listCacheByProfile.set(cacheKey(), null); + const file = cacheFilePath(); + if (fs.existsSync(file)) { + try { fs.unlinkSync(file); } catch { /* EBUSY on Windows — in-memory already cleared above */ } + } } // ---- Device list freshness ------------------------------------------------- @@ -343,9 +345,11 @@ export function setCachedStatus( } export function clearStatusCache(): void { - const file = statusCacheFilePath(); - if (fs.existsSync(file)) fs.unlinkSync(file); _statusCacheByProfile.set(cacheKey(), { entries: {} }); + const file = statusCacheFilePath(); + if (fs.existsSync(file)) { + try { fs.unlinkSync(file); } catch { /* EBUSY on Windows — in-memory already cleared above */ } + } } /** Summary for `switchbot cache show`. */ diff --git a/src/lib/idempotency.ts b/src/lib/idempotency.ts index fcf86786..c7ef53fb 100644 --- a/src/lib/idempotency.ts +++ b/src/lib/idempotency.ts @@ -68,7 +68,10 @@ export class IdempotencyCache { * {@link IdempotencyConflictError}. * * `profile` tags the entry so {@link clearForProfile} can evict only the - * entries belonging to a specific credential profile. + * entries belonging to a specific credential profile. Callers that omit + * `profile` store in an unscoped bucket — those entries survive + * {@link clearForProfile} by design; always pass the active profile for + * any call that should be evicted on credential rotation. * * Returns a tuple-esque object with `replayed: true` when the cached * result is served. The `result` field is the original cached value. @@ -84,7 +87,7 @@ export class IdempotencyCache { return { result, replayed: false }; } - const hashed = hashKey(key); + const hashed = hashKey(profile ? `${profile}:${key}` : key); const now = Date.now(); const cached = this.cache.get(hashed); const currentShape = shape ? shapeSignature(shape.command, shape.parameter) : '*'; diff --git a/src/lib/mindclip.ts b/src/lib/mindclip.ts index 43f71a46..1f83df02 100644 --- a/src/lib/mindclip.ts +++ b/src/lib/mindclip.ts @@ -21,7 +21,9 @@ export interface ListTodosParams { } function compact(obj: Record): Record { - return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined)); + return Object.fromEntries( + Object.entries(obj).filter(([, v]) => v !== undefined && v !== null && v !== '' && !Number.isNaN(v)), + ); } export async function listRecordings(params: ListRecordingsParams): Promise { diff --git a/src/utils/arg-parsers.ts b/src/utils/arg-parsers.ts index 0772777b..6c7bb852 100644 --- a/src/utils/arg-parsers.ts +++ b/src/utils/arg-parsers.ts @@ -100,7 +100,7 @@ export function dateArg(flagName: string): (value: string) => string { } if (!isCalendarValidDate(value)) { throw new InvalidArgumentError( - `${flagName} must be in YYYY-MM-DD format (got "${value}")`, + `${flagName} is not a valid calendar date — month/day out of range (got "${value}")`, ); } return value; @@ -145,7 +145,7 @@ export function isCalendarValidDate(value: string): boolean { * whose Jan 1 falls on Wednesday. */ export function isLongISOYear(year: number): boolean { - const jan1Day = new Date(year, 0, 1).getDay(); // 0=Sun … 6=Sat + const jan1Day = new Date(Date.UTC(year, 0, 1)).getUTCDay(); // 0=Sun … 6=Sat const isLeap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0; return jan1Day === 4 || (isLeap && jan1Day === 3); } diff --git a/tests/commands/mcp.test.ts b/tests/commands/mcp.test.ts index d58a9d8c..97b192c4 100644 --- a/tests/commands/mcp.test.ts +++ b/tests/commands/mcp.test.ts @@ -820,33 +820,24 @@ describe('mcp server', () => { expect(apiMock.__instance.get).toHaveBeenCalledWith('/v1.1/mindclip/recordings/r1', { params: {} }); }); - it('mindclip_recall period=daily ignores stray `week` field', async () => { - apiMock.__instance.get.mockResolvedValueOnce({ data: { body: {} } }); + it('mindclip_recall period=daily rejects stray `week` field', async () => { const { client } = await pair(); - await client.callTool({ + const res = await client.callTool({ name: 'mindclip_recall', arguments: { period: 'daily', date: '2026-06-01', week: '2026-W23' }, }); - expect(apiMock.__instance.get).toHaveBeenCalledWith('/v1.1/mindclip/assistant/daily', { - params: { date: '2026-06-01' }, - }); - // Must NOT have called the weekly endpoint - const calls = apiMock.__instance.get.mock.calls.map((c) => c[0]); - expect(calls).not.toContain('/v1.1/mindclip/assistant/weekly'); + expect(res.isError).toBeTruthy(); + expect(apiMock.__instance.get).not.toHaveBeenCalled(); }); - it('mindclip_recall period=weekly ignores stray `date` field', async () => { - apiMock.__instance.get.mockResolvedValueOnce({ data: { body: {} } }); + it('mindclip_recall period=weekly rejects stray `date` field', async () => { const { client } = await pair(); - await client.callTool({ + const res = await client.callTool({ name: 'mindclip_recall', arguments: { period: 'weekly', week: '2026-W23', date: '2026-06-01' }, }); - expect(apiMock.__instance.get).toHaveBeenCalledWith('/v1.1/mindclip/assistant/weekly', { - params: { week: '2026-W23' }, - }); - const calls = apiMock.__instance.get.mock.calls.map((c) => c[0]); - expect(calls).not.toContain('/v1.1/mindclip/assistant/daily'); + expect(res.isError).toBeTruthy(); + expect(apiMock.__instance.get).not.toHaveBeenCalled(); }); it('mindclip_recordings rejects empty deviceID/language strings rather than forwarding `?deviceID=`', async () => { diff --git a/tests/credentials/prime.test.ts b/tests/credentials/prime.test.ts index f6d5ccb8..9efa6959 100644 --- a/tests/credentials/prime.test.ts +++ b/tests/credentials/prime.test.ts @@ -64,7 +64,7 @@ describe('primeCredentials', () => { expect(selectMock).toHaveBeenCalledTimes(1); }); - it('repriming a different profile invalidates the previous entry', async () => { + it('priming a different profile keeps both entries independently cached', async () => { const getA = vi.fn().mockResolvedValue({ token: 'TA', secret: 'SA' }); const getB = vi.fn().mockResolvedValue({ token: 'TB', secret: 'SB' }); selectMock @@ -76,7 +76,7 @@ describe('primeCredentials', () => { await primeCredentials('b'); expect(getPrimedCredentials('b')).toEqual({ token: 'TB', secret: 'SB' }); - expect(getPrimedCredentials('a')).toBeNull(); + expect(getPrimedCredentials('a')).toEqual({ token: 'TA', secret: 'SA' }); }); it('swallows errors from selectCredentialStore', async () => { diff --git a/tests/utils/arg-parsers.test.ts b/tests/utils/arg-parsers.test.ts index 1f012258..30547e65 100644 --- a/tests/utils/arg-parsers.test.ts +++ b/tests/utils/arg-parsers.test.ts @@ -137,8 +137,8 @@ describe('dateArg', () => { }); it('rejects impossible calendar dates', () => { - expect(() => parse('2026-02-30')).toThrow(/YYYY-MM-DD/); - expect(() => parse('2026-13-01')).toThrow(/YYYY-MM-DD/); + expect(() => parse('2026-02-30')).toThrow(/calendar date/); + expect(() => parse('2026-13-01')).toThrow(/calendar date/); }); it('rejects flag-like tokens', () => { From 4d05b17a423dec9844daf284d168d9fbfe91d789 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sun, 14 Jun 2026 01:23:11 +0800 Subject: [PATCH 56/57] fix(review): address 15 code-review findings from PR #67 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correctness fixes: - credentials/prime: clearPrimedCredentials() no-arg now iterates generations.keys() instead of caches.keys() so in-flight primes (profile not yet in caches) have their generation bumped; add pre-registration of profile in generations before any await - idempotency: treat empty-string key as a valid dedupe key (was falsy-bypassed); distinguish undefined parameter from the literal string 'default' via NUL-byte sentinel; fix profile-colon collision using NUL separator; sort object keys for canonical JSON; implement true LRU (delete+re-set on hit) instead of FIFO - mcp: get_device_history alias adds .min(1) to deviceId schema and changes if(args.deviceId) to !== undefined check; device_history aggregate and aggregate_device_history alias both add array-level .min(1) on metrics; HTTP serve path calls primeCredentials(profile) before withRequestContext so keychain-only non-default profiles work - cache: clearCache/clearStatusCache now re-throw non-EBUSY errors instead of silently swallowing EACCES/EROFS Documentation: - Correct tool counts (14→17 default, 25→28 all) across claude-code-plugin, codex-plugin, gemini-extension READMEs and mcp.ts help text Tests (+58 cases): - prime.test.ts: in-flight race, multi-profile clear coverage - idempotency.test.ts: null/empty key, LRU eviction, shapeSignature undefined/'default' distinction, canonical JSON key order, colon collision in profile scoping - strict-schemas.test.ts: mindclip_recordings/list_todos/recall strict schema coverage; device_history aggregate metrics:[] rejection; get_device_history empty deviceId rejection --- packages/claude-code-plugin/README.md | 4 +- packages/codex-plugin/README.md | 2 +- packages/gemini-extension/README.md | 2 +- src/commands/mcp.ts | 14 ++-- src/credentials/prime.ts | 12 +++- src/devices/cache.ts | 4 +- src/lib/idempotency.ts | 47 ++++++++++--- tests/commands/strict-schemas.test.ts | 75 +++++++++++++++++++++ tests/credentials/prime.test.ts | 52 +++++++++++++++ tests/lib/idempotency.test.ts | 95 ++++++++++++++++++++++++++- 10 files changed, 282 insertions(+), 25 deletions(-) diff --git a/packages/claude-code-plugin/README.md b/packages/claude-code-plugin/README.md index 680091d8..49e07854 100644 --- a/packages/claude-code-plugin/README.md +++ b/packages/claude-code-plugin/README.md @@ -1,6 +1,6 @@ # @switchbot/claude-code-plugin -SwitchBot plugin for [Claude Code](https://claude.ai/claude-code) — wires Claude Code to the SwitchBot OpenAPI CLI MCP server, exposing up to 25 smart-home tools (14 in the default profile, 25 with `--tools all`) with policy-based safety gates. +SwitchBot plugin for [Claude Code](https://claude.ai/claude-code) — wires Claude Code to the SwitchBot OpenAPI CLI MCP server, exposing up to 28 smart-home tools (17 in the default profile, 28 with `--tools all`) with policy-based safety gates. ## Installation @@ -40,7 +40,7 @@ switchbot-claude-auth ## What it does -Registers the `switchbot` MCP server (`switchbot mcp serve` — default profile) with Claude Code. Add `--tools all` to expose the policy/audit/rules tools alongside the core 14. The skill document (`plugins/switchbot/skills/switchbot/SKILL.md`) guides Claude Code in safely controlling devices, reading sensors, running scenes, and respecting policy-based safety tiers. +Registers the `switchbot` MCP server (`switchbot mcp serve` — default profile) with Claude Code. Add `--tools all` to expose the policy/audit/rules tools alongside the core 17. The skill document (`plugins/switchbot/skills/switchbot/SKILL.md`) guides Claude Code in safely controlling devices, reading sensors, running scenes, and respecting policy-based safety tiers. ## Related packages diff --git a/packages/codex-plugin/README.md b/packages/codex-plugin/README.md index 7a206b37..93604d88 100644 --- a/packages/codex-plugin/README.md +++ b/packages/codex-plugin/README.md @@ -6,7 +6,7 @@ Codex plugin for SwitchBot smart-home control through the authoritative ## What it installs - A Codex skill at `skills/switchbot/SKILL.md` -- An MCP server definition that runs `switchbot mcp serve` (default profile, 17 tools; pass `--tools all` to add the policy/audit/rules tools for 25 total) +- An MCP server definition that runs `switchbot mcp serve` (default profile, 17 tools; pass `--tools all` to add the policy/audit/rules tools for 28 total) - A best-effort `onInstall` hook that runs non-interactive setup when the CLI is present - Legacy helper binaries: `switchbot-codex-auth` and `switchbot-codex-install` diff --git a/packages/gemini-extension/README.md b/packages/gemini-extension/README.md index 4d166b39..e513b60a 100644 --- a/packages/gemini-extension/README.md +++ b/packages/gemini-extension/README.md @@ -36,7 +36,7 @@ This writes the MCP server entry directly to `~/.gemini/settings.json`. ## What the extension provides -- Up to 25 MCP tools for device control, scene execution, automation rules, and diagnostics (14 in the default profile; the rest are admin tools — policy/audit/rules — gated behind `--tools all`) +- Up to 28 MCP tools for device control, scene execution, automation rules, and diagnostics (17 in the default profile; the rest are admin tools — policy/audit/rules — gated behind `--tools all`) - `GEMINI.md` context file (auto-loaded) with safety tiers, name resolution, authority chain - 23 slash commands: diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index fc716566..0cdc6c8b 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -51,6 +51,7 @@ import { import { todayUsage } from '../utils/quota.js'; import { describeCache } from '../devices/cache.js'; import { withRequestContext } from '../lib/request-context.js'; +import { primeCredentials } from '../credentials/prime.js'; import { profileFilePath, tryLoadConfig } from '../config.js'; import { loadPolicyFile, @@ -495,7 +496,7 @@ Tool profile: ${profileName} (${allowedTools.size} tools loaded).${profileName ! // query mode fields: z.array(z.string()).optional().describe('query: project these payload fields; omit for full payload.'), // aggregate mode - metrics: z.array(z.string().min(1)).optional().describe( + metrics: z.array(z.string().min(1)).min(1).optional().describe( 'aggregate (required): one or more numeric payload field names (e.g. ["temperature","humidity"]).', ), aggs: z.array(z.enum(ALL_AGG_FNS as unknown as [AggFn, ...AggFn[]])).optional() @@ -590,7 +591,7 @@ Tool profile: ${profileName} (${allowedTools.size} tools loaded).${profileName ! 'Read the latest entry plus the most recent N records for one device, or list devices with stored history when deviceId is omitted. No API call — zero quota cost.', ), inputSchema: z.object({ - deviceId: z.string().optional().describe( + deviceId: z.string().min(1).optional().describe( 'Device MAC address. Omit to list all devices with stored history.', ), // raw-mode only: hard-capped at 100 here; the consolidated `device_history` schema uses max 10000 across all modes (raw enforces 100 at runtime). @@ -606,7 +607,7 @@ Tool profile: ${profileName} (${allowedTools.size} tools loaded).${profileName ! }, }, async (args) => { - if (args.deviceId) { + if (args.deviceId !== undefined) { const latest = deviceHistoryStore.getLatest(args.deviceId); const history = deviceHistoryStore.getHistory(args.deviceId, args.limit ?? 20); const result = { deviceId: args.deviceId, latest, history }; @@ -669,7 +670,7 @@ Tool profile: ${profileName} (${allowedTools.size} tools loaded).${profileName ! since: z.string().optional().describe('Relative window ending now, e.g. "30s","15m","1h","7d". Mutually exclusive with from/to.'), from: z.string().optional().describe('Range start (ISO-8601). Mutually exclusive with since.'), to: z.string().optional().describe('Range end (ISO-8601). Used together with from.'), - metrics: z.array(z.string().min(1)).describe( + metrics: z.array(z.string().min(1)).min(1).describe( 'One or more numeric payload field names to aggregate (e.g. ["temperature","humidity"]).', ), aggs: z.array(z.enum(ALL_AGG_FNS as unknown as [AggFn, ...AggFn[]])).optional().describe( @@ -2614,7 +2615,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-five tools: + The MCP server exposes twenty-eight 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) @@ -2891,6 +2892,9 @@ process_uptime_seconds ${Math.floor(process.uptime())} }); // Route per-request credentials via AsyncLocalStorage so loadConfig() // picks up this request's profile instead of the process-global flag. + // Prime keychain credentials for this profile before entering context + // so loadConfig()'s synchronous getPrimedCredentials() can find them. + if (profile) await primeCredentials(profile); await withRequestContext({ profile: profile ?? undefined }, async () => { try { await reqServer.connect(reqTransport); diff --git a/src/credentials/prime.ts b/src/credentials/prime.ts index 958d0b5c..457e3f79 100644 --- a/src/credentials/prime.ts +++ b/src/credentials/prime.ts @@ -54,6 +54,10 @@ function genFor(profile: string): number { */ export async function primeCredentials(profile: string): Promise { if (isCacheValid(profile)) return; + // Pre-register in generations before any await so the no-arg + // clearPrimedCredentials() iterating generations.keys() can always find this + // profile even while store.get() is still in-flight. + if (!generations.has(profile)) generations.set(profile, 0); const gen = genFor(profile); try { const store = await selectCredentialStore(); @@ -79,10 +83,11 @@ export function getPrimedCredentials(profile: string): CredentialBundle | null { * Test helper. Not used by production code. */ export function __resetPrimedCredentials(): void { - for (const p of caches.keys()) { + for (const p of generations.keys()) { generations.set(p, (generations.get(p) ?? 0) + 1); } caches.clear(); + generations.clear(); } /** @@ -98,7 +103,10 @@ export function clearPrimedCredentials(profile?: string): void { generations.set(profile, (generations.get(profile) ?? 0) + 1); caches.delete(profile); } else { - for (const p of caches.keys()) { + // Iterate generations.keys() rather than caches.keys() so profiles that + // are currently in-flight (primeCredentials awaiting store.get()) also + // get their generation bumped, preventing the stale-write race. + for (const p of generations.keys()) { generations.set(p, (generations.get(p) ?? 0) + 1); } caches.clear(); diff --git a/src/devices/cache.ts b/src/devices/cache.ts index b785da3c..09f5fc5d 100644 --- a/src/devices/cache.ts +++ b/src/devices/cache.ts @@ -200,7 +200,7 @@ export function clearCache(): void { _listCacheByProfile.set(cacheKey(), null); const file = cacheFilePath(); if (fs.existsSync(file)) { - try { fs.unlinkSync(file); } catch { /* EBUSY on Windows — in-memory already cleared above */ } + try { fs.unlinkSync(file); } catch (e) { if ((e as NodeJS.ErrnoException).code !== 'EBUSY') throw e; } } } @@ -348,7 +348,7 @@ export function clearStatusCache(): void { _statusCacheByProfile.set(cacheKey(), { entries: {} }); const file = statusCacheFilePath(); if (fs.existsSync(file)) { - try { fs.unlinkSync(file); } catch { /* EBUSY on Windows — in-memory already cleared above */ } + try { fs.unlinkSync(file); } catch (e) { if ((e as NodeJS.ErrnoException).code !== 'EBUSY') throw e; } } } diff --git a/src/lib/idempotency.ts b/src/lib/idempotency.ts index c7ef53fb..743f9442 100644 --- a/src/lib/idempotency.ts +++ b/src/lib/idempotency.ts @@ -9,6 +9,9 @@ * key — the original string never touches the Map keys, so a later heap dump * or inadvertent log capture does not leak the raw token. * + * Eviction is true LRU: each cache hit moves the entry to the back of the + * Map's insertion-order so the oldest-unused entry is always at the front. + * * Process-local only — not shared across replicas. */ @@ -37,14 +40,32 @@ export function fingerprintIdempotencyKey(key: string): string { return hashKey(key).slice(0, 12); } +// Sentinel for undefined — JSON.stringify never emits a raw NUL byte, so this +// string cannot be confused with any serialised value. +const UNDEFINED_SENTINEL = '\x00undefined\x00'; + +function sortedJsonStringify(value: unknown): string { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return JSON.stringify(value); + } + // Sort top-level keys for canonical representation. Parameters passed to + // SwitchBot commands are shallow objects, so one level is sufficient. + const sorted = Object.fromEntries( + Object.entries(value as Record).sort(([a], [b]) => a.localeCompare(b)) + ); + return JSON.stringify(sorted); +} + function shapeSignature(command: string, parameter: unknown): string { - // Canonical-ish JSON — stable enough for object equality with no nested sort - // (callers can pass primitives or small objects). let parm: string; - try { - parm = JSON.stringify(parameter ?? 'default'); - } catch { - parm = String(parameter); + if (parameter === undefined) { + parm = UNDEFINED_SENTINEL; + } else { + try { + parm = sortedJsonStringify(parameter); + } catch { + parm = String(parameter); + } } return `${command}::${parm}`; } @@ -82,12 +103,15 @@ export class IdempotencyCache { shape?: { command: string; parameter: unknown }, profile?: string, ): Promise<{ result: T; replayed: boolean }> { - if (!key) { + if (key === undefined || key === null) { const result = await fn(); return { result, replayed: false }; } - const hashed = hashKey(profile ? `${profile}:${key}` : key); + // Use NUL-separated encoding to prevent collisions when a profile name + // contains ':' (e.g. profile="abc:123", key="def" must not hash the same + // as profile="abc", key="123:def"). + const hashed = hashKey(profile ? `${profile}\x00${key}` : key); const now = Date.now(); const cached = this.cache.get(hashed); const currentShape = shape ? shapeSignature(shape.command, shape.parameter) : '*'; @@ -101,6 +125,9 @@ export class IdempotencyCache { currentShape, ); } + // Move to back of Map insertion order so the front stays the LRU victim. + this.cache.delete(hashed); + this.cache.set(hashed, cached); return { result: cached.result as T, replayed: true }; } @@ -117,8 +144,8 @@ export class IdempotencyCache { } } if (this.cache.size >= this.maxEntries) { - const firstKey = this.cache.keys().next().value; - if (firstKey) this.cache.delete(firstKey); + const lruKey = this.cache.keys().next().value; + if (lruKey) this.cache.delete(lruKey); } } diff --git a/tests/commands/strict-schemas.test.ts b/tests/commands/strict-schemas.test.ts index ac34526b..093fad51 100644 --- a/tests/commands/strict-schemas.test.ts +++ b/tests/commands/strict-schemas.test.ts @@ -191,6 +191,21 @@ describe('MCP strict schemas — all tools reject unknown keys', () => { const { client } = await pair(); await assertRejectsUnknownKey(client, 'audit_stats', {}); }); + + it('mindclip_recordings rejects unknown keys', async () => { + const { client } = await pair(); + await assertRejectsUnknownKey(client, 'mindclip_recordings', { action: 'list' }); + }); + + it('mindclip_list_todos rejects unknown keys', async () => { + const { client } = await pair(); + await assertRejectsUnknownKey(client, 'mindclip_list_todos', {}); + }); + + it('mindclip_recall rejects unknown keys', async () => { + const { client } = await pair(); + await assertRejectsUnknownKey(client, 'mindclip_recall', { period: 'daily' }); + }); }); // --------------------------------------------------------------------------- @@ -336,3 +351,63 @@ describe('G7: boundary values on consolidated tool filters', () => { } }); }); + +// --------------------------------------------------------------------------- +// G8: aggregate metrics array must have ≥1 element; empty array [] must be +// rejected at the schema layer (-32602), not silently routed to a runtime error. +// get_device_history alias: empty-string deviceId must be rejected (-32602). +// --------------------------------------------------------------------------- + +describe('G8: aggregate metrics min(1) + get_device_history empty deviceId', () => { + beforeEach(() => { + apiMock.__instance.get.mockReset(); + apiMock.__instance.post.mockReset(); + }); + + it('device_history (aggregate) rejects metrics=[] at schema level', async () => { + const { client } = await pair(); + await assertSchemaReject(client, 'device_history', { + mode: 'aggregate', + deviceId: 'D1', + metrics: [], + }); + }); + + it('aggregate_device_history (deprecated alias) rejects metrics=[] at schema level', async () => { + const { client } = await pair(); + await assertSchemaReject(client, 'aggregate_device_history', { + deviceId: 'D1', + metrics: [], + }); + }); + + it('device_history (aggregate) accepts metrics with ≥1 valid string', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ data: { body: {} } }); + const { client } = await pair(); + const res = await client.callTool({ name: 'device_history', arguments: { + mode: 'aggregate', + deviceId: 'D1', + metrics: ['temperature'], + }}); + // Schema passes — runtime result may vary (empty history store); isError + // is still acceptable here as long as it's a runtime error, not a -32602. + const text = (res.content as Array<{ type: string; text: string }>)[0].text; + expect(text).not.toMatch(/-32602|Array must contain at least/); + }); + + it('get_device_history (deprecated alias) rejects deviceId="" at schema level', async () => { + const { client } = await pair(); + await assertSchemaReject(client, 'get_device_history', { + deviceId: '', + }); + }); + + it('get_device_history with valid deviceId is accepted by the schema', async () => { + const { client } = await pair(); + const res = await client.callTool({ name: 'get_device_history', arguments: { + deviceId: 'D1', + }}); + // Schema passes; runtime result from empty store is valid non-error JSON + expect(res.isError).toBeFalsy(); + }); +}); diff --git a/tests/credentials/prime.test.ts b/tests/credentials/prime.test.ts index 9efa6959..6d8799a5 100644 --- a/tests/credentials/prime.test.ts +++ b/tests/credentials/prime.test.ts @@ -104,3 +104,55 @@ describe('primeCredentials', () => { expect(getPrimedCredentials('default')).toBeNull(); }); }); + +describe('clearPrimedCredentials() race — no-arg path vs in-flight prime', () => { + it('discards in-flight result when no-arg clear fires before store.get() resolves', async () => { + // Set up a deferred resolve so we can fire clearPrimedCredentials() + // while primeCredentials() is suspended at `await store.get()`. + let resolveGet!: (v: { token: string; secret: string } | null) => void; + const getDeferred = new Promise<{ token: string; secret: string } | null>( + (res) => { resolveGet = res; } + ); + const get = vi.fn().mockReturnValue(getDeferred); + selectMock.mockResolvedValue({ name: 'keychain', get } as any); + + // Start prime without await — it suspends at store.get() + const primePromise = primeCredentials('p1'); + + // Fire the no-arg clear while p1 is NOT yet in `caches` + clearPrimedCredentials(); + + // Now let store.get() resolve with fresh credentials + resolveGet({ token: 'STALE', secret: 'STALE' }); + await primePromise; + + // The generation was bumped, so the resolve must have been discarded + expect(getPrimedCredentials('p1')).toBeNull(); + }); + + it('no-arg clear after prime completes evicts the cached entry', async () => { + const get = vi.fn().mockResolvedValue({ token: 'T', secret: 'S' }); + selectMock.mockResolvedValue({ name: 'keychain', get } as any); + + await primeCredentials('p2'); + expect(getPrimedCredentials('p2')).not.toBeNull(); + + clearPrimedCredentials(); // no arg — should evict p2 + expect(getPrimedCredentials('p2')).toBeNull(); + }); + + it('profile-specific clear does not evict other profiles', async () => { + const getA = vi.fn().mockResolvedValue({ token: 'TA', secret: 'SA' }); + const getB = vi.fn().mockResolvedValue({ token: 'TB', secret: 'SB' }); + selectMock + .mockResolvedValueOnce({ name: 'keychain', get: getA } as any) + .mockResolvedValueOnce({ name: 'keychain', get: getB } as any); + + await primeCredentials('a'); + await primeCredentials('b'); + + clearPrimedCredentials('a'); + expect(getPrimedCredentials('a')).toBeNull(); + expect(getPrimedCredentials('b')).toEqual({ token: 'TB', secret: 'SB' }); + }); +}); diff --git a/tests/lib/idempotency.test.ts b/tests/lib/idempotency.test.ts index 2902f332..968fd3b2 100644 --- a/tests/lib/idempotency.test.ts +++ b/tests/lib/idempotency.test.ts @@ -42,14 +42,44 @@ describe('IdempotencyCache', () => { expect(fn).toHaveBeenCalledTimes(2); }); - it('evicts oldest entry when capacity is exceeded', async () => { + it('always executes fn when key is null', async () => { + const cache = new IdempotencyCache(); + const fn = vi.fn().mockResolvedValue('x'); + await cache.run(null as unknown as undefined, fn); + await cache.run(null as unknown as undefined, fn); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it('empty-string key IS a valid key and deduplicates within TTL', async () => { + const cache = new IdempotencyCache(60000); + const fn = vi.fn().mockResolvedValue('v'); + const r1 = await cache.run('', fn); + const r2 = await cache.run('', fn); + expect(r1.replayed).toBe(false); + expect(r2.replayed).toBe(true); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('evicts LRU entry (least-recently-used) when capacity is exceeded', async () => { const cache = new IdempotencyCache(60000, 3); await cache.run('a', async () => 1); await cache.run('b', async () => 2); await cache.run('c', async () => 3); expect(cache.size()).toBe(3); + // Touch 'a' to make it recently used; 'b' becomes LRU victim + await cache.run('a', async () => 99); // replayed — moves a to back + // Adding 'd' should evict 'b' (LRU), not 'a' await cache.run('d', async () => 4); - expect(cache.size()).toBeLessThanOrEqual(3); + expect(cache.size()).toBe(3); + // 'a' should still be cached + const ra = await cache.run('a', async () => 999); + expect(ra.replayed).toBe(true); + expect(ra.result).toBe(1); + // 'b' should have been evicted — fn re-runs + const fnB = vi.fn().mockResolvedValue(22); + const rb = await cache.run('b', fnB); + expect(rb.replayed).toBe(false); + expect(fnB).toHaveBeenCalledTimes(1); }); it('concurrent same-key calls do not deduplicate (cache misses run concurrently)', async () => { @@ -125,3 +155,64 @@ describe('IdempotencyCache', () => { expect(cache.size()).toBe(1); }); }); + +describe('IdempotencyCache — shapeSignature distinguishes undefined from "default"', () => { + it('treats parameter=undefined differently from parameter="default"', async () => { + const cache = new IdempotencyCache(60000); + // Seed with undefined parameter + await cache.run('k', async () => 'ok', { command: 'press', parameter: undefined }); + // Same key, different parameter ('default' literal) should conflict + await expect( + cache.run('k', async () => 'ok', { command: 'press', parameter: 'default' }), + ).rejects.toBeInstanceOf(IdempotencyConflictError); + }); + + it('treats parameter=undefined differently from parameter=null', async () => { + const cache = new IdempotencyCache(60000); + await cache.run('k', async () => 'ok', { command: 'cmd', parameter: undefined }); + await expect( + cache.run('k', async () => 'ok', { command: 'cmd', parameter: null }), + ).rejects.toBeInstanceOf(IdempotencyConflictError); + }); + + it('two undefined parameters produce the same shape (no conflict)', async () => { + const cache = new IdempotencyCache(60000); + const fn = vi.fn().mockResolvedValue('v'); + await cache.run('k', fn, { command: 'cmd', parameter: undefined }); + const r = await cache.run('k', fn, { command: 'cmd', parameter: undefined }); + expect(r.replayed).toBe(true); + }); + + it('object parameter with different key order produces same shape (canonical JSON)', async () => { + const cache = new IdempotencyCache(60000); + const fn = vi.fn().mockResolvedValue('v'); + await cache.run('k', fn, { command: 'setColor', parameter: { hue: 120, saturation: 100 } }); + // Same object but different insertion order — should NOT conflict (same canonical form) + const r = await cache.run('k', fn, { command: 'setColor', parameter: { saturation: 100, hue: 120 } }); + expect(r.replayed).toBe(true); + expect(fn).toHaveBeenCalledTimes(1); + }); +}); + +describe('IdempotencyCache — profile scoping prevents cross-profile collision', () => { + it('profile "abc:123" + key "def" does not collide with profile "abc" + key "123:def"', async () => { + const cache = new IdempotencyCache(60000); + const fn1 = vi.fn().mockResolvedValue('first'); + const fn2 = vi.fn().mockResolvedValue('second'); + + await cache.run('def', fn1, undefined, 'abc:123'); + // Different (profile, key) pair must be an independent slot, not a replay + const r2 = await cache.run('123:def', fn2, undefined, 'abc'); + expect(r2.replayed).toBe(false); + expect(fn2).toHaveBeenCalledTimes(1); + }); + + it('same key under different profiles are independent entries', async () => { + const cache = new IdempotencyCache(60000); + const fn = vi.fn().mockResolvedValue('v'); + await cache.run('k', fn, undefined, 'profileA'); + const r = await cache.run('k', fn, undefined, 'profileB'); + expect(r.replayed).toBe(false); + expect(fn).toHaveBeenCalledTimes(2); + }); +}); From c98e607ac1fee595c45b47244e775e4296fb14ce Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sun, 14 Jun 2026 01:34:53 +0800 Subject: [PATCH 57/57] test(auth,config): assert all four caches cleared on every credential mutation Cover the full onCredentialChange() contract in auth.test.ts: - login: clearCache, clearStatusCache, clearPrimedCredentials, idempotencyCache.clearForProfile - keychain set / delete / migrate: same four functions asserted Add cache-clearing coverage to config.test.ts (previously zero): - set-token success: all four caches called - set-token failure (missing token): no caches called --- tests/commands/auth.test.ts | 95 ++++++++++++++++++++++++++++++++++- tests/commands/config.test.ts | 66 ++++++++++++++++++++++++ 2 files changed, 159 insertions(+), 2 deletions(-) diff --git a/tests/commands/auth.test.ts b/tests/commands/auth.test.ts index 9aafb460..93d47499 100644 --- a/tests/commands/auth.test.ts +++ b/tests/commands/auth.test.ts @@ -211,6 +211,25 @@ describe('auth keychain set', () => { const data = expectJsonEnvelopeShape(parsed, ['profile', 'backend', 'written']); expect(data.written).toBe(true); }); + + it('clears all four caches after writing credentials', async () => { + clearCacheMock.mockReset(); + clearStatusCacheMock.mockReset(); + clearPrimedCredsMock.mockReset(); + idempotencyClearForProfileMock.mockReset(); + + const store = makeStore({ writable: true }); + selectMock.mockResolvedValue(store); + const file = path.join(tmpDir, 'creds.json'); + fs.writeFileSync(file, JSON.stringify({ token: 'tk', secret: 'sk' })); + + const res = await runCli(['auth', 'keychain', 'set', '--stdin-file', file]); + expect(res.exitCode).toBe(0); + expect(clearCacheMock).toHaveBeenCalledOnce(); + expect(clearStatusCacheMock).toHaveBeenCalledOnce(); + expect(clearPrimedCredsMock).toHaveBeenCalledOnce(); + expect(idempotencyClearForProfileMock).toHaveBeenCalledOnce(); + }); }); describe('auth keychain delete', () => { @@ -233,6 +252,23 @@ describe('auth keychain delete', () => { const data = expectJsonEnvelopeShape(parsed, ['profile', 'backend', 'deleted']); expect(data.deleted).toBe(true); }); + + it('clears all four caches after deleting credentials', async () => { + clearCacheMock.mockReset(); + clearStatusCacheMock.mockReset(); + clearPrimedCredsMock.mockReset(); + idempotencyClearForProfileMock.mockReset(); + + const store = makeStore({ writable: true }); + selectMock.mockResolvedValue(store); + + const res = await runCli(['auth', 'keychain', 'delete', '--yes']); + expect(res.exitCode).toBe(0); + expect(clearCacheMock).toHaveBeenCalledOnce(); + expect(clearStatusCacheMock).toHaveBeenCalledOnce(); + expect(clearPrimedCredsMock).toHaveBeenCalledOnce(); + expect(idempotencyClearForProfileMock).toHaveBeenCalledOnce(); + }); }); describe('auth keychain migrate', () => { @@ -400,6 +436,26 @@ describe('auth keychain migrate', () => { unlinkSpy.mockRestore(); } }); + + it('clears all four caches after a successful migrate', async () => { + clearCacheMock.mockReset(); + clearStatusCacheMock.mockReset(); + clearPrimedCredsMock.mockReset(); + idempotencyClearForProfileMock.mockReset(); + + const store = makeStore({ writable: true }); + selectMock.mockResolvedValue(store); + const file = path.join(tmpHome, '.switchbot', 'config.json'); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify({ token: 't-mig', secret: 's-mig' })); + + const res = await runCli(['auth', 'keychain', 'migrate']); + expect(res.exitCode).toBe(0); + expect(clearCacheMock).toHaveBeenCalledOnce(); + expect(clearStatusCacheMock).toHaveBeenCalledOnce(); + expect(clearPrimedCredsMock).toHaveBeenCalledOnce(); + expect(idempotencyClearForProfileMock).toHaveBeenCalledOnce(); + }); }); // ── auth login ──────────────────────────────────────────────────────────────── @@ -429,12 +485,43 @@ vi.mock('../../src/devices/cache.js', async () => { }; }); +const clearPrimedCredsMock = vi.fn(); + +vi.mock('../../src/credentials/prime.js', async () => { + const actual = await vi.importActual( + '../../src/credentials/prime.js', + ); + return { + ...actual, + clearPrimedCredentials: (...args: unknown[]) => clearPrimedCredsMock(...args), + }; +}); + +const idempotencyClearForProfileMock = vi.fn(); + +vi.mock('../../src/lib/idempotency.js', async () => { + const actual = await vi.importActual( + '../../src/lib/idempotency.js', + ); + return { + ...actual, + idempotencyCache: { + clearForProfile: (...args: unknown[]) => idempotencyClearForProfileMock(...args), + clear: vi.fn(), + run: vi.fn(), + size: vi.fn(() => 0), + }, + }; +}); + describe('auth login', () => { beforeEach(() => { browserLoginMock.mockReset(); verifyCredsMock.mockReset(); clearCacheMock.mockReset(); clearStatusCacheMock.mockReset(); + clearPrimedCredsMock.mockReset(); + idempotencyClearForProfileMock.mockReset(); }); it('saves credentials and exits 0 on success', async () => { @@ -501,7 +588,7 @@ describe('auth login', () => { } }); - it('clears device and status cache after successful login', async () => { + it('clears all four caches after successful login', async () => { browserLoginMock.mockResolvedValue({ token: 'tok-new', secret: 'sec-new' }); verifyCredsMock.mockResolvedValue({ ok: true }); const store = makeStore({ writable: true }); @@ -511,14 +598,18 @@ describe('auth login', () => { expect(res.exitCode).toBe(0); expect(clearCacheMock).toHaveBeenCalledOnce(); expect(clearStatusCacheMock).toHaveBeenCalledOnce(); + expect(clearPrimedCredsMock).toHaveBeenCalledOnce(); + expect(idempotencyClearForProfileMock).toHaveBeenCalledOnce(); }); - it('does not clear cache when login fails', async () => { + it('does not clear any cache when login fails', async () => { browserLoginMock.mockRejectedValue(new Error('user cancelled')); const res = await runCli(['auth', 'login', '--no-open']); expect(res.exitCode).toBe(1); expect(clearCacheMock).not.toHaveBeenCalled(); expect(clearStatusCacheMock).not.toHaveBeenCalled(); + expect(clearPrimedCredsMock).not.toHaveBeenCalled(); + expect(idempotencyClearForProfileMock).not.toHaveBeenCalled(); }); }); diff --git a/tests/commands/config.test.ts b/tests/commands/config.test.ts index cb476f80..8520ed45 100644 --- a/tests/commands/config.test.ts +++ b/tests/commands/config.test.ts @@ -19,6 +19,51 @@ const configMock = vi.hoisted(() => ({ vi.mock('../../src/config.js', () => configMock); +// Mocks for onCredentialChange side-effects so we can assert all four +// cache-clear functions are called on credential mutations. +const clearCacheMock = vi.hoisted(() => vi.fn()); +const clearStatusCacheMock = vi.hoisted(() => vi.fn()); + +vi.mock('../../src/devices/cache.js', async () => { + const actual = await vi.importActual( + '../../src/devices/cache.js', + ); + return { + ...actual, + clearCache: (...args: unknown[]) => clearCacheMock(...args), + clearStatusCache: (...args: unknown[]) => clearStatusCacheMock(...args), + }; +}); + +const clearPrimedCredsMock = vi.hoisted(() => vi.fn()); + +vi.mock('../../src/credentials/prime.js', async () => { + const actual = await vi.importActual( + '../../src/credentials/prime.js', + ); + return { + ...actual, + clearPrimedCredentials: (...args: unknown[]) => clearPrimedCredsMock(...args), + }; +}); + +const idempotencyClearForProfileMock = vi.hoisted(() => vi.fn()); + +vi.mock('../../src/lib/idempotency.js', async () => { + const actual = await vi.importActual( + '../../src/lib/idempotency.js', + ); + return { + ...actual, + idempotencyCache: { + clearForProfile: (...args: unknown[]) => idempotencyClearForProfileMock(...args), + clear: vi.fn(), + run: vi.fn(), + size: vi.fn(() => 0), + }, + }; +}); + import { registerConfigCommand } from '../../src/commands/config.js'; import { runCli } from '../helpers/cli.js'; import { expectJsonEnvelopeShape } from '../helpers/contracts.js'; @@ -31,6 +76,10 @@ describe('config command', () => { configMock.getConfigSummary.mockReturnValue({ source: 'none' }); configMock.listProfiles.mockReset(); configMock.listProfiles.mockReturnValue([]); + clearCacheMock.mockReset(); + clearStatusCacheMock.mockReset(); + clearPrimedCredsMock.mockReset(); + idempotencyClearForProfileMock.mockReset(); }); describe('set-token', () => { @@ -102,6 +151,23 @@ describe('config command', () => { ); expect(res.exitCode).toBeNull(); }); + + it('clears all four caches after saving credentials', async () => { + const res = await runCli(registerConfigCommand, ['config', 'set-token', 'T', 'S']); + expect(res.exitCode).toBeNull(); + expect(clearCacheMock).toHaveBeenCalledOnce(); + expect(clearStatusCacheMock).toHaveBeenCalledOnce(); + expect(clearPrimedCredsMock).toHaveBeenCalledOnce(); + expect(idempotencyClearForProfileMock).toHaveBeenCalledOnce(); + }); + + it('does not clear caches when set-token fails (missing token)', async () => { + await runCli(registerConfigCommand, ['config', 'set-token']); + expect(clearCacheMock).not.toHaveBeenCalled(); + expect(clearStatusCacheMock).not.toHaveBeenCalled(); + expect(clearPrimedCredsMock).not.toHaveBeenCalled(); + expect(idempotencyClearForProfileMock).not.toHaveBeenCalled(); + }); }); describe('show', () => {