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 9565ea36..d3f18d64 100644 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -4,5 +4,8 @@ set -eu REPO_ROOT="$(git rev-parse --show-toplevel)" cd "$REPO_ROOT" +# 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 ef6cec72..5f9758e4 100644 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -4,5 +4,8 @@ set -eu REPO_ROOT="$(git rev-parse --show-toplevel)" cd "$REPO_ROOT" +# shellcheck source=common.sh +. "$REPO_ROOT/.githooks/common.sh" + echo "[pre-push] release gate" npm run verify:release-gate diff --git a/CHANGELOG.md b/CHANGELOG.md index b650c82a..1c5e1636 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,44 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added + +- **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 + +- **`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 + +- **`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/README.md b/README.md index 3264c063..bb5ac6bd 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 | --- @@ -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 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:** @@ -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 @@ -282,7 +294,7 @@ switchbot config list-profiles ### `mcp` ```bash -switchbot mcp serve # stdio MCP server — 24 tools +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 b8578568..09a62646 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 | | --- | --- | --- | @@ -88,9 +88,10 @@ 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 | +| `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 | @@ -107,21 +108,38 @@ 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 +### Deprecated aliases (scheduled for removal in 4.0.0) -Reads `~/.switchbot/device-history/.json` written by `events mqtt-tail`. Requires no API call and costs zero quota. +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. ```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 +149,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/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..87892afc --- /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) + +- [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: + +```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(); + }); +``` + +- [x] **Step 2: Run the failing test** + +``` +npx vitest run tests/credentials/prime.test.ts +``` + +Expected: FAIL — `clearPrimedCredentials is not exported` + +- [x] **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; +} +``` + +- [x] **Step 4: Run the test to verify it passes** + +``` +npx vitest run tests/credentials/prime.test.ts +``` + +Expected: PASS (7 tests) + +- [x] **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) + +- [x] **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(); +``` + +- [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): + +```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(); +``` + +- [x] **Step 3: Run the full test suite to confirm no regressions** + +``` +npx vitest run tests/credentials/ tests/lib/idempotency.test.ts +``` + +Expected: all pass + +- [x] **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) + +- [x] **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'; +``` + +- [x] **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` + +- [x] **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; + }; +} +``` + +- [x] **Step 4: Run the tests to verify they pass** + +``` +npx vitest run tests/utils/arg-parsers.test.ts +``` + +Expected: PASS (all describe blocks) + +- [x] **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) + +- [x] **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', + ]); + }); +}); +``` + +- [x] **Step 2: Run the failing test** + +``` +npx vitest run tests/devices/mindclip-catalog.test.ts +``` + +Expected: FAIL — `entry` is `undefined` + +- [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): + +```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'], + }, +``` + +- [x] **Step 4: Run the test to verify it passes** + +``` +npx vitest run tests/devices/mindclip-catalog.test.ts +``` + +Expected: PASS (4 tests) + +- [x] **Step 5: Run existing catalog tests to confirm no regressions** + +``` +npx vitest run tests/devices/ +``` + +Expected: all pass + +- [x] **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` + +- [x] **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'); + }); +}); +``` + +- [x] **Step 2: Run the failing tests** + +``` +npx vitest run tests/lib/mindclip.test.ts +``` + +Expected: FAIL — `Cannot find module '../../src/lib/mindclip.js'` + +- [x] **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; +} +``` + +- [x] **Step 4: Run the tests to verify they pass** + +``` +npx vitest run tests/lib/mindclip.test.ts +``` + +Expected: PASS (all describe blocks, ~16 tests) + +- [x] **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` + +- [x] **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); + }); +}); +``` + +- [x] **Step 2: Run the failing tests** + +``` +npx vitest run tests/commands/mindclip.test.ts +``` + +Expected: FAIL — `Cannot find module '../../src/commands/mindclip.js'` + +- [x] **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); + }); +} +``` + +- [x] **Step 4: Run the tests to verify they pass** + +``` +npx vitest run tests/commands/mindclip.test.ts +``` + +Expected: PASS (all describe blocks, ~22 tests) + +- [x] **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) + +- [x] **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); +``` + +- [x] **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) +``` + +- [x] **Step 3: Run the full test suite** + +``` +npx vitest run +``` + +Expected: all tests pass, no regressions + +- [x] **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` 接收 `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/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. 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/package-lock.json b/package-lock.json index 25bf9d9b..c49fca97 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@switchbot/openapi-cli", - "version": "3.7.9", + "version": "3.8.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@switchbot/openapi-cli", - "version": "3.7.9", + "version": "3.8.1", "license": "MIT", "workspaces": [ "packages/*" @@ -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/package.json b/package.json index 91bac94e..c6afed63 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@switchbot/openapi-cli", - "version": "3.7.9", + "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", diff --git a/packages/claude-code-plugin/.claude-plugin/marketplace.json b/packages/claude-code-plugin/.claude-plugin/marketplace.json index ff6d3578..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 24 tools for controlling devices and scenes", + "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/README.md b/packages/claude-code-plugin/README.md index 8c694186..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 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 up to 28 smart-home tools (17 in the default profile, 28 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 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/claude-code-plugin/package.json b/packages/claude-code-plugin/package.json index 80a87d3f..66ea6981 100644 --- a/packages/claude-code-plugin/package.json +++ b/packages/claude-code-plugin/package.json @@ -1,8 +1,8 @@ { "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)", + "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/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 3a1e3ac7..b6681b39 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_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` | @@ -34,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 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`. --- 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..12caffd7 --- /dev/null +++ b/packages/claude-code-plugin/tests/manifest.test.js @@ -0,0 +1,43 @@ +/** + * Asserts the `.mcp.json` shipped by the Claude Code plugin registers the + * 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'; +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/.mcp.json b/packages/codex-plugin/.mcp.json index f27b53b9..909b1efb 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 (24 tools, via CLI)" + "args": ["mcp", "serve"], + "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 00c0c717..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 --tools all` +- 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/codex-plugin/package.json b/packages/codex-plugin/package.json index 36dbac61..62678fc4 100644 --- a/packages/codex-plugin/package.json +++ b/packages/codex-plugin/package.json @@ -1,8 +1,8 @@ { "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)", + "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 f27b53b9..909b1efb 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 (24 tools, via CLI)" + "args": ["mcp", "serve"], + "description": "SwitchBot smart-home MCP server (default 17 tools; `--tools all` for 28)" } } } 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/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/packages/gemini-extension/GEMINI.md b/packages/gemini-extension/GEMINI.md index 7e6ccf3d..d56079cd 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_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 430d1542..e513b60a 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 (default 17 tools, `--tools all` for 28, 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 +- 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/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 213d7194..a13dd260 100644 --- a/packages/gemini-extension/gemini-extension.json +++ b/packages/gemini-extension/gemini-extension.json @@ -1,12 +1,12 @@ { "name": "switchbot", - "version": "0.1.0", - "description": "Control SwitchBot smart-home devices from Gemini CLI via MCP (24 tools, policy-based safety gates)", + "version": "0.1.1", + "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": { "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 eab8d764..6bdfd3aa 100644 --- a/packages/gemini-extension/package.json +++ b/packages/gemini-extension/package.json @@ -1,8 +1,8 @@ { "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", + "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/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/auth.ts b/src/commands/auth.ts index bd0766c2..6b9db1b8 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'; @@ -80,6 +82,13 @@ async function promptSecret(question: string): Promise { }); } +export function onCredentialChange(): void { + clearCache(); + clearStatusCache(); + clearPrimedCredentials(activeProfile()); + idempotencyCache.clearForProfile(activeProfile()); +} + function readStdinFile(filePath: string): CredentialBundle { if (!fs.existsSync(filePath)) { exitWithError({ @@ -242,6 +251,7 @@ export function registerAuthCommand(program: Command): void { }); } + onCredentialChange(); if (isJsonMode()) { printJson({ profile, backend: store.name, written: true }); return; @@ -279,6 +289,7 @@ export function registerAuthCommand(program: Command): void { }); } + onCredentialChange(); if (isJsonMode()) { printJson({ profile, backend: store.name, deleted: true }); return; @@ -359,6 +370,7 @@ export function registerAuthCommand(program: Command): void { } } + onCredentialChange(); if (isJsonMode()) { printJson({ profile, @@ -452,8 +464,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. - clearCache(); - clearStatusCache(); + onCredentialChange(); if (isJsonMode()) { printJson({ diff --git a/src/commands/capabilities.ts b/src/commands/capabilities.ts index b093384d..8cdf0c49 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, 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[] { @@ -221,6 +222,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, @@ -233,19 +241,14 @@ 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. 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/commands/config.ts b/src/commands/config.ts index 08d28840..f2d85b23 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -8,6 +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 { onCredentialChange } from './auth.js'; import chalk from 'chalk'; function parseEnvFile(file: string): { token?: string; secret?: string } { @@ -267,6 +268,7 @@ Files are written with mode 0600. Profiles live under ~/.switchbot/profiles/ = { + 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('runtime', 1, msg); + } +} + export function createSwitchBotMcpServer(options?: { eventManager?: EventSubscriptionManager; toolProfile?: ToolProfile }): McpServer { const eventManager = options?.eventManager; const allowedTools = TOOL_PROFILES[options?.toolProfile ?? 'default']; @@ -359,20 +454,150 @@ Tool profile: ${profileName} (${allowedTools.size} tools loaded).${profileName ! } ); - // ---- get_device_history ---------------------------------------------------- - if (!skip('get_device_history')) + function deprecatedAlias(replacement: string, mode: string, baseDescription: string) { + return { + description: `[DEPRECATED — use ${replacement}(mode="${mode}")]. ${baseDescription}`, + _meta: { agentSafetyTier: 'read' as const, deprecated: true, replacement }, + }; + } + + // ---- 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().min(1).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)).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().superRefine((val, ctx) => { + 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(), + 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(), + // 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 (args) => { + // ---- raw mode ------------------------------------------------------ + if (args.mode === 'raw') { + 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); + 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, + }; + } + + // ---- query mode ---------------------------------------------------- + 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. 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', + { + title: '[Deprecated] Latest + recent 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().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). + 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(), @@ -381,11 +606,11 @@ Tool profile: ${profileName} (${allowedTools.size} tools loaded).${profileName ! 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 }; + async (args) => { + 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 }; return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], structuredContent: result, @@ -401,24 +626,21 @@ Tool profile: ${profileName} (${allowedTools.size} tools loaded).${profileName ! } ); - // ---- 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' }, + title: '[Deprecated] Time-ranged device history query', + ...deprecatedAlias('device_history', 'query', + 'Return time-ranged records (since OR from/to) with optional field projection and limit. No API call.', + ), 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).'), + 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(), @@ -429,24 +651,59 @@ Tool profile: ${profileName} (${allowedTools.size} tools loaded).${profileName ! deviceType: z.string().optional(), payload: z.unknown(), })), + 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 }; - 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); - } - } + async (args) => runDeviceHistoryQuery(args) + ); + + if (!skip('aggregate_device_history')) + server.registerTool( + 'aggregate_device_history', + { + title: '[Deprecated] Bucketed device-history aggregation', + ...deprecatedAlias('device_history', 'aggregate', + 'Return bucketed statistics (count/min/max/avg/sum/p50/p95) over numeric metrics. No API call.', + ), + 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.'), + 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)).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(), + 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(), + notes: z.array(z.string()).optional(), + }, + }, + async (args) => runDeviceHistoryAggregate(args) ); // ---- send_command --------------------------------------------------------- @@ -932,110 +1189,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( @@ -1134,6 +1287,169 @@ Tool profile: ${profileName} (${allowedTools.size} tools loaded).${profileName ! } ); + // ---- 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_recordings', + { + title: 'AI MindClip recordings (list / get / summary)', + description: + '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({ + 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().min(1).optional().describe('Language code (e.g. "en", "zh") — only honored when action="get".'), + // list filters + 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.'), + 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('Operation-shaped envelope: list -> recordings page, get -> single recording, summary -> AI summary body.'), + }, + }, + async (args) => { + try { + 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 }, + }; + } catch (err) { + return apiErrorToMcpError(err); + } + } + ); + + // ---- 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().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(), + outputSchema: { + data: z.unknown().describe('Todos page envelope as returned by /v1.1/mindclip/todos (opaque body)'), + }, + }, + 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 apiErrorToMcpError(err); + } + } + ); + + // ---- 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_recall', + { + title: 'AI MindClip recall (daily / weekly / urgent)', + description: + '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(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(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().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.'), + }, + }, + async (args) => { + try { + 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 }, + }; + } catch (err) { + return apiErrorToMcpError(err); + } + } + ); + // ---- policy_validate ----------------------------------------------------- if (!skip('policy_validate')) server.registerTool( @@ -2299,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-four 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) @@ -2308,9 +2624,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 + - 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) @@ -2353,7 +2670,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" (${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); } @@ -2362,7 +2679,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" (${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); } @@ -2376,7 +2693,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" (${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 @@ -2575,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/commands/mindclip.ts b/src/commands/mindclip.ts new file mode 100644 index 00000000..60acde37 --- /dev/null +++ b/src/commands/mindclip.ts @@ -0,0 +1,217 @@ +import { Command } from 'commander'; +import { intArg, enumArg, stringArg, dateArg, weekArg } from '../utils/arg-parsers.js'; +import { printJson, handleError } 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), + ); + try { + const data = await listRecordings(params); + printJson(data); + } catch (err) { + handleError(err); + } + }); + + 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) => { + try { + const data = await getRecording(id, options.language); + printJson(data); + } catch (err) { + handleError(err); + } + }); + + mindclip + .command('summary ') + .description('Get AI summary and transcription for a recording') + .addHelpText( + 'after', + ` +Examples: + switchbot mindclip summary 5f3a1c2e9b7d`, + ) + .action(async (id: string) => { + try { + const data = await getSummary(id); + printJson(data); + } catch (err) { + handleError(err); + } + }); + + 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), + ); + try { + const data = await listTodos(params); + printJson(data); + } catch (err) { + handleError(err); + } + }); + + 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) => { + try { + const data = await getDailyRecall(options.date); + printJson(data); + } catch (err) { + handleError(err); + } + }); + + 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) => { + try { + const data = await getWeeklySummary(options.week); + printJson(data); + } catch (err) { + handleError(err); + } + }); + + 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) => { + try { + const data = await getUrgentTodos(options.date); + printJson(data); + } catch (err) { + handleError(err); + } + }); +} diff --git a/src/commands/reset.ts b/src/commands/reset.ts index dec567dd..748f96c4 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 { resetListCache, resetStatusCache } 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,16 @@ 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) ── + // 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(); + if (isJsonMode()) { const failed = results.filter(r => r.status === 'failed').length; if (failed > 0) { diff --git a/src/credentials/prime.ts b/src/credentials/prime.ts index 22bed2b0..457e3f79 100644 --- a/src/credentials/prime.ts +++ b/src/credentials/prime.ts @@ -28,12 +28,17 @@ interface CacheEntry { timestamp: number; } -let cache: CacheEntry | null = null; +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; } /** @@ -41,15 +46,27 @@ 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 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; + // 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(); const creds = await store.get(profile); - cache = { profile, creds, timestamp: Date.now() }; + if (genFor(profile) !== gen) return; + caches.set(profile, { profile, creds, timestamp: Date.now() }); } catch { - cache = { profile, creds: null, timestamp: Date.now() }; + if (genFor(profile) !== gen) return; + caches.set(profile, { profile, creds: null, timestamp: Date.now() }); } } @@ -59,14 +76,39 @@ 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 { - cache = null; + for (const p of generations.keys()) { + generations.set(p, (generations.get(p) ?? 0) + 1); + } + caches.clear(); + generations.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(profile?: string): void { + if (profile !== undefined) { + generations.set(profile, (generations.get(profile) ?? 0) + 1); + caches.delete(profile); + } else { + // 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 75615304..09f5fc5d 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 (e) { if ((e as NodeJS.ErrnoException).code !== 'EBUSY') throw e; } + } } // ---- 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 (e) { if ((e as NodeJS.ErrnoException).code !== 'EBUSY') throw e; } + } } /** Summary for `switchbot cache show`. */ diff --git a/src/devices/catalog.ts b/src/devices/catalog.ts index 84450725..ec598a3c 100644 --- a/src/devices/catalog.ts +++ b/src/devices/catalog.ts @@ -212,6 +212,16 @@ const rgbOnlyLightControls0To100: CommandSpec[] = [ 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', + readOnly: true, + commands: [], + statusFields: ['battery', 'chargingStatus', 'recordingStatus', 'uploadStatus', 'hasUntransferredFiles'], + }, { type: 'Bot', category: 'physical', diff --git a/src/install/claude-code-checks.ts b/src/install/claude-code-checks.ts index 3c44ef35..32cf50c4 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, { @@ -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/src/install/gemini-checks.ts b/src/install/gemini-checks.ts index 25b1ba2d..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; @@ -94,8 +95,8 @@ export function registerMcp(): RegisterMcpResult { ...mcpServers, [MCP_SERVER_NAME]: { command: 'switchbot', - args: ['mcp', 'serve', '--tools', 'all'], - description: 'SwitchBot smart-home MCP server (24 tools)', + args: ['mcp', 'serve'], + 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 }); diff --git a/src/lib/devices.ts b/src/lib/devices.ts index 8d852503..cd8941bf 100644 --- a/src/lib/devices.ts +++ b/src/lib/devices.ts @@ -21,6 +21,7 @@ import { import { getCacheMode } from '../utils/flags.js'; import { writeAudit } from '../utils/audit.js'; import { isDryRun } from '../utils/flags.js'; +import { getActiveProfile } from '../lib/request-context.js'; export interface Device { deviceId: string; @@ -246,6 +247,7 @@ export async function executeCommand( options?.idempotencyKey, execute, { command: cmd, parameter }, + getActiveProfile() ?? 'default', ); if (!replayed) return result; writeAudit({ diff --git a/src/lib/idempotency.ts b/src/lib/idempotency.ts index e212745a..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,20 +40,38 @@ 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}`; } export class IdempotencyCache { - private cache = new Map(); + private cache = new Map(); private readonly ttlMs: number; private readonly maxEntries: number; @@ -67,6 +88,12 @@ 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. 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. */ @@ -74,13 +101,17 @@ export class IdempotencyCache { key: string | undefined, fn: () => Promise, 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(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) : '*'; @@ -94,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 }; } @@ -110,15 +144,22 @@ 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); } } - 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/src/lib/mindclip.ts b/src/lib/mindclip.ts new file mode 100644 index 00000000..1f83df02 --- /dev/null +++ b/src/lib/mindclip.ts @@ -0,0 +1,89 @@ +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 && v !== null && v !== '' && !Number.isNaN(v)), + ); +} + +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/${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/${encodeURIComponent(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/src/mcp/tool-profiles.ts b/src/mcp/tool-profiles.ts index 9d5a024e..8cdd736d 100644 --- a/src/mcp/tool-profiles.ts +++ b/src/mcp/tool-profiles.ts @@ -3,14 +3,19 @@ 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_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; @@ -35,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 { 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; } diff --git a/src/utils/arg-parsers.ts b/src/utils/arg-parsers.ts index 13284891..6c7bb852 100644 --- a/src/utils/arg-parsers.ts +++ b/src/utils/arg-parsers.ts @@ -90,3 +90,62 @@ export function enumArg( return value; }; } + +export function dateArg(flagName: string): (value: string) => string { + return (value: string) => { + if (!DATE_REGEX.test(value)) { + throw new InvalidArgumentError( + `${flagName} must be in YYYY-MM-DD format (got "${value}")`, + ); + } + if (!isCalendarValidDate(value)) { + throw new InvalidArgumentError( + `${flagName} is not a valid calendar date — month/day out of range (got "${value}")`, + ); + } + return value; + }; +} + +export function weekArg(flagName: string): (value: string) => string { + return (value: string) => { + if (!WEEK_REGEX.test(value)) { + throw new InvalidArgumentError( + `${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; + }; +} + +/** + * 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; +} + +/** + * 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(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/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/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/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', () => { diff --git a/tests/commands/mcp.test.ts b/tests/commands/mcp.test.ts index 3de7ca0e..97b192c4 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 twenty-eight tools with titles and input schemas', async () => { const { client } = await pair(); const { tools } = await client.listTools(); @@ -175,10 +175,14 @@ describe('mcp server', () => { 'audit_query', 'audit_stats', 'describe_device', + 'device_history', 'get_device_history', 'get_device_status', 'list_devices', 'list_scenes', + 'mindclip_list_todos', + 'mindclip_recall', + 'mindclip_recordings', 'plan_run', 'plan_suggest', 'policy_add_rule', @@ -576,6 +580,326 @@ describe('mcp server', () => { expect(res.isError).toBe(true); }); + 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_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' }] }); + expect(apiMock.__instance.get).toHaveBeenCalledWith('/v1.1/mindclip/recordings', { + params: {}, + }); + }); + + 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_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_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_recordings', + arguments: { action: 'get', id: 'r1', language: 'en' }, + }); + expect(apiMock.__instance.get).toHaveBeenCalledWith('/v1.1/mindclip/recordings/r1', { + params: { language: 'en' }, + }); + }); + + 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_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(); + 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('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_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_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_recall', + arguments: { period: 'weekly', week: '2026-W23' }, + }); + expect(apiMock.__instance.get).toHaveBeenCalledWith('/v1.1/mindclip/assistant/weekly', { + params: { week: '2026-W23' }, + }); + }); + + 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_recall', arguments: { period: 'urgent_todos' } }); + expect(apiMock.__instance.get).toHaveBeenCalledWith('/v1.1/mindclip/assistant/urgent-todos', { + params: {}, + }); + }); + + // --------------------------------------------------------------------------- + // 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 rejects stray `week` field', async () => { + const { client } = await pair(); + const res = await client.callTool({ + name: 'mindclip_recall', + arguments: { period: 'daily', date: '2026-06-01', week: '2026-W23' }, + }); + expect(res.isError).toBeTruthy(); + expect(apiMock.__instance.get).not.toHaveBeenCalled(); + }); + + it('mindclip_recall period=weekly rejects stray `date` field', async () => { + const { client } = await pair(); + const res = await client.callTool({ + name: 'mindclip_recall', + arguments: { period: 'weekly', week: '2026-W23', date: '2026-06-01' }, + }); + expect(res.isError).toBeTruthy(); + expect(apiMock.__instance.get).not.toHaveBeenCalled(); + }); + + 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('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(); @@ -633,28 +957,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); @@ -669,8 +993,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', @@ -696,6 +1021,240 @@ 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`'); + }); + + // --------------------------------------------------------------------------- + // 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/mindclip.test.ts b/tests/commands/mindclip.test.ts new file mode 100644 index 00000000..032df61c --- /dev/null +++ b/tests/commands/mindclip.test.ts @@ -0,0 +1,169 @@ +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); + }), + handleError: vi.fn((error: unknown) => { + throw error instanceof Error ? error : new Error(String(error)); + }), +})); + +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); + }); +}); diff --git a/tests/commands/strict-schemas.test.ts b/tests/commands/strict-schemas.test.ts index 10a12d98..093fad51 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'], }); @@ -190,4 +191,223 @@ 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' }); + }); +}); + +// --------------------------------------------------------------------------- +// 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(); + } + }); +}); + +// --------------------------------------------------------------------------- +// 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 e37dacff..6d8799a5 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'; @@ -63,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 @@ -75,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 () => { @@ -91,4 +92,67 @@ 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(); + }); +}); + +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/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', + ]); + }); +}); diff --git a/tests/install/claude-code-checks.test.ts b/tests/install/claude-code-checks.test.ts new file mode 100644 index 00000000..d33c2fe7 --- /dev/null +++ b/tests/install/claude-code-checks.test.ts @@ -0,0 +1,59 @@ +/** + * Asserts `claude-code-checks.registerMcp` invokes `claude mcp add` with 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'; + +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/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/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..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 () => { @@ -75,6 +105,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 }); @@ -100,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); + }); +}); diff --git a/tests/lib/mindclip.test.ts b/tests/lib/mindclip.test.ts new file mode 100644 index 00000000..3e3a9796 --- /dev/null +++ b/tests/lib/mindclip.test.ts @@ -0,0 +1,217 @@ +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'); + }); + + 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'); + }); +}); + +// --------------------------------------------------------------------------- +// 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' }); + }); + + 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'); + }); +}); + +// --------------------------------------------------------------------------- +// 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'); + }); +}); diff --git a/tests/mcp/legacy-aliases.test.ts b/tests/mcp/legacy-aliases.test.ts new file mode 100644 index 00000000..4ad6b40f --- /dev/null +++ b/tests/mcp/legacy-aliases.test.ts @@ -0,0 +1,213 @@ +/** + * 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(); + // 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'); + }, + ); + + it('get_device_history forwards equivalently to device_history({mode:"raw"})', async () => { + const { client } = await pair('all'); + // 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 () => { + 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 } }); + 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)', () => { + 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); + }); + + 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/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 2002b09c..a832b8f5 100644 --- a/tests/mcp/tool-profiles.test.ts +++ b/tests/mcp/tool-profiles.test.ts @@ -1,19 +1,20 @@ 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', () => { - it('readonly has 10 tools (core read only)', () => { - expect(TOOL_PROFILES.readonly.size).toBe(10); + it('readonly has 14 tools (core read only)', () => { + expect(TOOL_PROFILES.readonly.size).toBe(14); }); - it('default has 13 tools (core read + action)', () => { - expect(TOOL_PROFILES.default.size).toBe(13); + it('default has 17 tools (core read + action)', () => { + expect(TOOL_PROFILES.default.size).toBe(17); }); - it('all has 24 tools', () => { - expect(TOOL_PROFILES.all.size).toBe(24); + it('all has 28 tools', () => { + expect(TOOL_PROFILES.all.size).toBe(28); }); it('readonly is a subset of default', () => { @@ -65,9 +66,9 @@ describe('tool-profiles', () => { describe('createSwitchBotMcpServer respects toolProfile', () => { it.each<[ToolProfile, number]>([ - ['readonly', 10], - ['default', 13], - ['all', 24], + ['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); @@ -88,4 +89,23 @@ describe('tool-profiles', () => { expect(tools).not.toContain('plan_run'); }); }); + + describe('capabilities MCP_TOOLS stays in sync with registered tools', () => { + it('MCP_TOOLS matches registered tools under toolProfile=all, minus deprecated aliases', () => { + const server = createSwitchBotMcpServer({ toolProfile: 'all' }); + 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); + }); + }); }); diff --git a/tests/mcp/tool-schema-completeness.test.ts b/tests/mcp/tool-schema-completeness.test.ts index 99626d19..55a43a6c 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`, @@ -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']); + }); }); diff --git a/tests/utils/arg-parsers.test.ts b/tests/utils/arg-parsers.test.ts index 713bc5bb..30547e65 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, isLongISOYear } from '../../src/utils/arg-parsers.js'; describe('intArg', () => { const parse = intArg('--max'); @@ -117,3 +117,95 @@ 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(/calendar date/); + expect(() => parse('2026-13-01')).toThrow(/calendar date/); + }); + + 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-W52', () => { + expect(parse('2026-W23')).toBe('2026-W23'); + expect(parse('2026-W01')).toBe('2026-W01'); + 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/); + }); + + 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/); + }); +}); + +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 + }); +});