diff --git a/CHANGELOG.md b/CHANGELOG.md index 91a56a50..ee5969ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,104 @@ All notable changes to `@switchbot/openapi-cli` are documented in this file. The format is loosely based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.5.0] - 2026-04-20 + +### Added + +- **`history aggregate `** — on-demand bucketed statistics + (`count / min / max / avg / sum / p50 / p95`) over the append-only JSONL + device history. Flags: `--since` / `--from` / `--to`, repeatable + `--metric`, `--agg `, `--bucket `, + `--max-bucket-samples `. Non-numeric samples are skipped; empty + metrics are omitted from their bucket. +- **MCP `aggregate_device_history`** — same contract as the CLI, exposed + as a read-tier tool (`_meta.agentSafetyTier: "read"`) with a strict + Zod input schema (unknown keys reject with JSON-RPC `-32602`). +- **Capabilities manifest** — new `history aggregate` entry in + `COMMAND_META`; new `aggregate_device_history` entry in + `surfaces.mcp.tools`. +- **`scenes describe `** — returns `{sceneId, sceneName, + stepCount:null, note}`; SwitchBot API v1.1 does not expose scene + steps. Unknown sceneId returns structured `scene_not_found` with a + candidate list. (bug #17) +- **`--no-color` flag + `NO_COLOR` env var** — honors the standard + https://no-color.org/ contract; disables chalk colors globally before + any subcommand runs. (bug #12) +- **`--format markdown`** — accepted as an alias for `--format table` + with `--table-style markdown` forced at render time, independent of + the user's `--table-style` flag. (bug #8) +- **`cache status`** — alias for `cache show`, matching the `quota` + subcommand's status/show parity. (bug #9) + +### Fixed (security & correctness — v2.4.0 report) + +- **MCP strict input schemas on all 11 tools** — unknown keys now + reject with JSON-RPC `-32602`. Fixes the v2.4.0 hole where + `send_command {dryRun:true}` silently fired the command anyway — + particularly dangerous for Smart Lock / Garage. (bug #4) +- **MCP `dryRun` on mutating tools** — `send_command` and `run_scene` + accept `dryRun:true`; when set, no API call is made and the response + is `{ok:true, dryRun:true, wouldSend:{...}}`. (bug #4) +- **MCP `serverInfo.version`** — wired to `package.json#version`; was + hardcoded `"2.0.0"` despite the CLI reporting the real version + everywhere else. (bug #5) +- **MCP `_meta.agentSafetyTier`** — every tool now emits its tier + (`read` / `action` / `destructive`). Release notes already claimed + this but no tool was actually emitting it. (bug #6) +- **`--name` require-unique + exact-match** — exact-name short-circuit + in `name-resolver` was returning the exact hit even when substring + matches existed, defeating the write-path `require-unique` default. + Exact hits now enter the candidate list under `require-unique` and + go through the ambiguity check like any other match. (bug #1) +- **`history verify` on missing audit.log** — exits 0 with `status:"warn"` + and `fileMissing:true` rather than exit 1. Malformed/unversioned + content still exits 1 as before. (bug #11) +- **`events mqtt-tail` control events** — `__connect` / `__reconnect` / + `__disconnect` / `__heartbeat` now append to + `~/.switchbot/device-history/__control.jsonl` alongside per-device + files, honoring the v2.4.0 "every event is persisted" claim. (bug #10) + +### Changed (docs) + +- `--idempotency-key` help text on `devices command`, `devices batch`, + `plan run`, `history replay` now explicitly mentions the process-local + 60s scope — independent CLI invocations do NOT share the cache. (bug #14) +- `mcp --help` now says "eleven tools" and lists all 11 names. (bug #15) +- New `docs/verbose-redaction.md` — documents the nine masked headers + (`authorization`, `token`, `sign`, `nonce`, `x-api-key`, `cookie`, + `set-cookie`, `x-auth-token`, `t`) and the `--trace-unsafe` opt-out. (bug #16) +- `plan schema` now includes `agentNotes.deviceNameStrategy` declaring + that plan steps using `deviceName` resolve with `require-unique`. (bug #18) +- `agent-bootstrap` `hints` field carries JSDoc + `schema export` + declares it in `cliAddedFields` — empty array means "no hints", + never null. (bug #13) + +### Notes + +- Storage format unchanged. Aggregation streams the existing JSONL + rotation files via `readline` — zero memory blow-up for large + windows, with a hard ceiling of `--max-bucket-samples` × 8 bytes per + `(bucket × metric)` for quantile computation. +- Quantiles use nearest-rank on sorted per-bucket samples; if the cap + is reached the result carries `partial: true` and a per-bucket + `notes[]` entry. `count / min / max / avg / sum` remain exact. +- All bug-fix items bundled into 2.5.0 rather than shipping a separate + 2.4.1. Source of bug numbers: the v2.4.0 smoke-test report at + `D:/servicdata/openclaw/workspace/switchbot-cli-v2.4.0-report.md`. + +### Not included (deferred) + +- Cross-device aggregation (agents merge locally). +- Trend / rate-of-change helpers (derivable from bucket series). +- `--fill-empty` for missing buckets. +- Disk-persisted idempotency cache for cross-invocation replay + (report bug #2). Process-local is the documented 2.4.0 contract; + the `--help` text now states this plainly — no code change. Revisit + only if a concrete use case forces it. +- `capabilities --types` / `--fields` / `--used` (report bug #7). + `schema export` already offers these for the agent bootstrap path; + `capabilities --compact --surface ` covers the payload-size story. + ## [2.4.0] - 2026-04-20 Large agent-experience overhaul driven by the OpenClaw + Claude integration diff --git a/docs/superpowers/plans/2026-04-20-device-history-aggregation.md b/docs/superpowers/plans/2026-04-20-device-history-aggregation.md new file mode 100644 index 00000000..cf271e5d --- /dev/null +++ b/docs/superpowers/plans/2026-04-20-device-history-aggregation.md @@ -0,0 +1,1506 @@ +# Device History Aggregation — 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 an on-demand, per-device bucketed aggregation query over existing JSONL history, exposed both as the CLI subcommand `history aggregate` and the MCP tool `aggregate_device_history`. + +**Architecture:** A single pure async function `aggregateDeviceHistory(deviceId, opts)` in `src/devices/history-agg.ts` streams the existing `~/.switchbot/device-history/.jsonl*` files with `readline`, folds each numeric sample into per-bucket accumulators, and returns a structured result. CLI and MCP each build the same `AggOptions` and consume the same `AggResult`. Zero storage changes; reuses `parseDurationToMs`, `jsonlFilesForDevice`, and `resolveRange` from `src/devices/history-query.ts`. + +**Tech Stack:** TypeScript (strict), Node 20+ (`node:fs`, `node:readline`, `node:path`, `node:os`), Commander.js, @modelcontextprotocol/sdk (Zod-shape input schemas), Vitest. + +**Spec:** `docs/superpowers/specs/2026-04-20-device-history-aggregation-design.md` + +--- + +## File Structure + +| File | Action | Responsibility | +|---|---|---| +| `src/devices/history-query.ts` | Modify (1 LoC) | Export `resolveRange` so `history-agg.ts` can reuse time-window logic | +| `src/devices/history-agg.ts` | **Create** | Pure `aggregateDeviceHistory(deviceId, opts)` + types. Zero commander/MCP imports | +| `src/commands/history.ts` | Modify | Register new `aggregate` subcommand; translate flags → `AggOptions`; format text/JSON output | +| `src/commands/mcp.ts` | Modify | Register new `aggregate_device_history` tool; strict Zod schema; delegate to `aggregateDeviceHistory` | +| `src/commands/capabilities.ts` | Modify | Add `'history aggregate'` row to `COMMAND_META`; append `'aggregate_device_history'` to `MCP_TOOLS` | +| `tests/devices/history-agg.test.ts` | **Create** | Unit tests for the pure function (≈ 12 cases) | +| `tests/commands/history.test.ts` | Modify | Integration tests for the CLI subcommand | +| `tests/commands/mcp.test.ts` | Modify | MCP tool surface tests (listing, strictness, output parity) | +| `CHANGELOG.md` | Modify | New `## [2.5.0]` entry | +| `package.json` | Modify | `version` → `2.5.0` | + +--- + +## Task 0: Preflight + +**Files:** none + +- [ ] **Step 1: Confirm clean tree on the spec branch and green baseline** + +Run: +```bash +cd D:/workspace/claudecode/switchbot-cli +git status +git branch --show-current +npm run build +npm test +``` +Expected: branch `docs/history-aggregation-spec`, working tree clean, build succeeds, all tests pass. If anything is red, fix before continuing. + +- [ ] **Step 2: Re-read the spec** + +Open `docs/superpowers/specs/2026-04-20-device-history-aggregation-design.md`. Every decision below traces to a section there. + +--- + +## Task 1: Export `resolveRange` from `history-query.ts` + +`history-agg.ts` must reuse the same time-window validation (`--since` vs `--from/--to` mutex, bad ISO rejection, `--from > --to` check). Today `resolveRange` is private; exporting it is the cheapest correct path. + +**Files:** +- Modify: `src/devices/history-query.ts:57` (one `export` keyword) +- Run: `tests/devices/history-query.test.ts` (verify nothing broke) + +- [ ] **Step 1: Add the `export` keyword** + +Edit `src/devices/history-query.ts` line 57: + +From: +```ts +function resolveRange(opts: QueryOptions): { fromMs: number; toMs: number } { +``` +To: +```ts +export function resolveRange(opts: QueryOptions): { fromMs: number; toMs: number } { +``` + +- [ ] **Step 2: Re-run history-query tests** + +Run: +```bash +npx vitest run tests/devices/history-query.test.ts +``` +Expected: all existing cases pass. + +- [ ] **Step 3: Commit** + +```bash +git add src/devices/history-query.ts +git commit -m "refactor(history-query): export resolveRange for reuse in aggregation" +``` + +--- + +## Task 2: Create `history-agg.ts` types + empty function (TDD red) + +Stand up the module skeleton with types only so the failing test in Task 3 has something to import. + +**Files:** +- Create: `src/devices/history-agg.ts` + +- [ ] **Step 1: Write the skeleton** + +Create `src/devices/history-agg.ts`: +```ts +import type { QueryOptions } from './history-query.js'; + +export type AggFn = 'count' | 'min' | 'max' | 'avg' | 'sum' | 'p50' | 'p95'; + +export const ALL_AGG_FNS: readonly AggFn[] = ['count', 'min', 'max', 'avg', 'sum', 'p50', 'p95']; +export const DEFAULT_AGGS: readonly AggFn[] = ['count', 'avg']; +export const DEFAULT_SAMPLE_CAP = 10_000; +export const MAX_SAMPLE_CAP = 100_000; + +export interface AggOptions extends QueryOptions { + metrics: string[]; + aggs?: AggFn[]; + bucket?: string; + maxBucketSamples?: number; +} + +export interface BucketMetricResult { + count?: number; + min?: number; + max?: number; + avg?: number; + sum?: number; + p50?: number; + p95?: number; +} + +export interface AggBucket { + t: string; + metrics: Record; +} + +export interface AggResult { + deviceId: string; + bucket?: string; + from: string; + to: string; + metrics: string[]; + aggs: AggFn[]; + buckets: AggBucket[]; + partial: boolean; + notes: string[]; +} + +export async function aggregateDeviceHistory( + _deviceId: string, + _opts: AggOptions, +): Promise { + throw new Error('aggregateDeviceHistory: not implemented'); +} +``` + +- [ ] **Step 2: Verify it compiles** + +Run: +```bash +npm run build +``` +Expected: clean tsc output, no errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/devices/history-agg.ts +git commit -m "feat(history-agg): add module skeleton with public types" +``` + +--- + +## Task 3: Single-bucket count/min/max/avg/sum + +First behavioral test: when `--bucket` is omitted, the whole window folds into one bucket. + +**Files:** +- Create: `tests/devices/history-agg.test.ts` +- Modify: `src/devices/history-agg.ts` + +- [ ] **Step 1: Write the failing test** + +Create `tests/devices/history-agg.test.ts`: +```ts +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { aggregateDeviceHistory } from '../../src/devices/history-agg.js'; + +function writeJsonl(file: string, records: Array>): void { + fs.writeFileSync(file, records.map((r) => JSON.stringify(r)).join('\n') + '\n'); +} + +describe('aggregateDeviceHistory — single bucket', () => { + let tmpHome: string; + let historyDir: string; + + beforeEach(() => { + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sb-agg-')); + historyDir = path.join(tmpHome, '.switchbot', 'device-history'); + fs.mkdirSync(historyDir, { recursive: true }); + vi.spyOn(os, 'homedir').mockReturnValue(tmpHome); + }); + + afterEach(() => { + vi.restoreAllMocks(); + try { fs.rmSync(tmpHome, { recursive: true, force: true }); } catch { /* */ } + }); + + it('folds all samples into one bucket when --bucket is omitted', async () => { + const file = path.join(historyDir, 'DEV1.jsonl'); + writeJsonl(file, [ + { t: '2026-04-19T10:00:00.000Z', topic: 'status', payload: { temperature: 20 } }, + { t: '2026-04-19T10:30:00.000Z', topic: 'status', payload: { temperature: 22 } }, + { t: '2026-04-19T11:00:00.000Z', topic: 'status', payload: { temperature: 24 } }, + ]); + + const res = await aggregateDeviceHistory('DEV1', { + from: '2026-04-19T00:00:00.000Z', + to: '2026-04-20T00:00:00.000Z', + metrics: ['temperature'], + aggs: ['count', 'min', 'max', 'avg', 'sum'], + }); + + expect(res.buckets).toHaveLength(1); + const m = res.buckets[0].metrics.temperature; + expect(m.count).toBe(3); + expect(m.min).toBe(20); + expect(m.max).toBe(24); + expect(m.avg).toBe(22); + expect(m.sum).toBe(66); + expect(res.partial).toBe(false); + expect(res.notes).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: Run the test — expect FAIL** + +Run: +```bash +npx vitest run tests/devices/history-agg.test.ts +``` +Expected: FAIL with `aggregateDeviceHistory: not implemented`. + +- [ ] **Step 3: Implement the single-bucket path** + +Replace the stub in `src/devices/history-agg.ts` with: +```ts +import fs from 'node:fs'; +import readline from 'node:readline'; +import type { QueryOptions, HistoryRecord } from './history-query.js'; +import { jsonlFilesForDevice, resolveRange } from './history-query.js'; + +export type AggFn = 'count' | 'min' | 'max' | 'avg' | 'sum' | 'p50' | 'p95'; + +export const ALL_AGG_FNS: readonly AggFn[] = ['count', 'min', 'max', 'avg', 'sum', 'p50', 'p95']; +export const DEFAULT_AGGS: readonly AggFn[] = ['count', 'avg']; +export const DEFAULT_SAMPLE_CAP = 10_000; +export const MAX_SAMPLE_CAP = 100_000; + +export interface AggOptions extends QueryOptions { + metrics: string[]; + aggs?: AggFn[]; + bucket?: string; + maxBucketSamples?: number; +} + +export interface BucketMetricResult { + count?: number; + min?: number; + max?: number; + avg?: number; + sum?: number; + p50?: number; + p95?: number; +} + +export interface AggBucket { + t: string; + metrics: Record; +} + +export interface AggResult { + deviceId: string; + bucket?: string; + from: string; + to: string; + metrics: string[]; + aggs: AggFn[]; + buckets: AggBucket[]; + partial: boolean; + notes: string[]; +} + +interface Acc { + min: number; + max: number; + sum: number; + count: number; + samples: number[] | null; + sampleCapHit: boolean; +} + +export async function aggregateDeviceHistory( + deviceId: string, + opts: AggOptions, +): Promise { + const { fromMs, toMs } = resolveRange(opts); + const aggs: AggFn[] = (opts.aggs && opts.aggs.length > 0) ? opts.aggs : [...DEFAULT_AGGS]; + const needQuantile = aggs.includes('p50') || aggs.includes('p95'); + + // bucketKey (epoch ms; 0 when no --bucket) → metric name → Acc + const buckets = new Map>(); + + for (const file of jsonlFilesForDevice(deviceId)) { + const stream = fs.createReadStream(file, { encoding: 'utf-8' }); + const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); + for await (const line of rl) { + if (!line) continue; + let rec: HistoryRecord; + try { rec = JSON.parse(line) as HistoryRecord; } catch { continue; } + const tMs = Date.parse(rec.t); + if (!Number.isFinite(tMs) || tMs < fromMs || tMs > toMs) continue; + + const key = 0; // single-bucket mode; Task 4 introduces bucketMs + let bkt = buckets.get(key); + if (!bkt) { bkt = new Map(); buckets.set(key, bkt); } + + for (const metric of opts.metrics) { + const v = (rec.payload as Record | null | undefined)?.[metric]; + if (typeof v !== 'number' || !Number.isFinite(v)) continue; + let acc = bkt.get(metric); + if (!acc) { + acc = { + min: v, + max: v, + sum: 0, + count: 0, + samples: needQuantile ? [] : null, + sampleCapHit: false, + }; + bkt.set(metric, acc); + } + acc.min = Math.min(acc.min, v); + acc.max = Math.max(acc.max, v); + acc.sum += v; + acc.count += 1; + } + } + } + + return finalize(deviceId, opts, aggs, buckets, false, []); +} + +function finalize( + deviceId: string, + opts: AggOptions, + aggs: AggFn[], + buckets: Map>, + partial: boolean, + notes: string[], +): AggResult { + const { fromMs, toMs } = resolveRange(opts); + const fromIso = Number.isFinite(fromMs) ? new Date(fromMs).toISOString() : new Date(0).toISOString(); + const toIso = Number.isFinite(toMs) ? new Date(toMs).toISOString() : new Date(Date.now()).toISOString(); + + const keys = [...buckets.keys()].sort((a, b) => a - b); + const outBuckets: AggBucket[] = []; + for (const key of keys) { + const perMetric = buckets.get(key)!; + const metricsOut: Record = {}; + for (const [metric, acc] of perMetric.entries()) { + if (acc.count === 0) continue; + const r: BucketMetricResult = {}; + if (aggs.includes('count')) r.count = acc.count; + if (aggs.includes('min')) r.min = acc.min; + if (aggs.includes('max')) r.max = acc.max; + if (aggs.includes('avg')) r.avg = acc.sum / acc.count; + if (aggs.includes('sum')) r.sum = acc.sum; + if ((aggs.includes('p50') || aggs.includes('p95')) && acc.samples) { + const sorted = [...acc.samples].sort((a, b) => a - b); + if (aggs.includes('p50')) r.p50 = sorted[Math.floor(0.5 * (sorted.length - 1))]; + if (aggs.includes('p95')) r.p95 = sorted[Math.floor(0.95 * (sorted.length - 1))]; + } + metricsOut[metric] = r; + } + if (Object.keys(metricsOut).length === 0) continue; + outBuckets.push({ + t: new Date(key).toISOString(), + metrics: metricsOut, + }); + } + + return { + deviceId, + bucket: opts.bucket, + from: fromIso, + to: toIso, + metrics: [...opts.metrics], + aggs: [...aggs], + buckets: outBuckets, + partial, + notes, + }; +} +``` + +- [ ] **Step 4: Run the test — expect PASS** + +Run: +```bash +npx vitest run tests/devices/history-agg.test.ts +``` +Expected: the one case passes. + +- [ ] **Step 5: Commit** + +```bash +git add src/devices/history-agg.ts tests/devices/history-agg.test.ts +git commit -m "feat(history-agg): fold samples into a single bucket (count/min/max/avg/sum)" +``` + +--- + +## Task 4: Time buckets with boundary alignment + +Introduce `--bucket` support. Samples are filed into buckets keyed by `floor(tMs / bucketMs) * bucketMs` (UTC-aligned). Boundary tests pin the exact-boundary behavior. + +**Files:** +- Modify: `src/devices/history-agg.ts` (replace hard-coded `const key = 0`) +- Modify: `tests/devices/history-agg.test.ts` (append tests) + +- [ ] **Step 1: Append failing tests for multi-bucket + boundary** + +Append to `tests/devices/history-agg.test.ts` inside the same `describe` block (before the closing brace): +```ts + it('buckets by --bucket duration with UTC-aligned boundaries', async () => { + const file = path.join(historyDir, 'DEV1.jsonl'); + writeJsonl(file, [ + { t: '2026-04-19T10:00:00.000Z', topic: 'status', payload: { temperature: 20 } }, + { t: '2026-04-19T10:30:00.000Z', topic: 'status', payload: { temperature: 22 } }, + { t: '2026-04-19T11:00:00.000Z', topic: 'status', payload: { temperature: 24 } }, + { t: '2026-04-19T11:59:59.999Z', topic: 'status', payload: { temperature: 26 } }, + ]); + + const res = await aggregateDeviceHistory('DEV1', { + from: '2026-04-19T00:00:00.000Z', + to: '2026-04-20T00:00:00.000Z', + metrics: ['temperature'], + aggs: ['count', 'avg'], + bucket: '1h', + }); + + expect(res.buckets.map((b) => b.t)).toEqual([ + '2026-04-19T10:00:00.000Z', + '2026-04-19T11:00:00.000Z', + ]); + expect(res.buckets[0].metrics.temperature.count).toBe(2); + expect(res.buckets[0].metrics.temperature.avg).toBe(21); + expect(res.buckets[1].metrics.temperature.count).toBe(2); + expect(res.buckets[1].metrics.temperature.avg).toBe(25); + }); + + it('places a record at HH:59:59.999 in the HH bucket and HH+1:00:00.000 in HH+1', async () => { + const file = path.join(historyDir, 'DEV1.jsonl'); + writeJsonl(file, [ + { t: '2026-04-19T10:59:59.999Z', topic: 'status', payload: { temperature: 20 } }, + { t: '2026-04-19T11:00:00.000Z', topic: 'status', payload: { temperature: 40 } }, + ]); + + const res = await aggregateDeviceHistory('DEV1', { + from: '2026-04-19T00:00:00.000Z', + to: '2026-04-20T00:00:00.000Z', + metrics: ['temperature'], + aggs: ['count'], + bucket: '1h', + }); + + expect(res.buckets).toHaveLength(2); + expect(res.buckets[0].t).toBe('2026-04-19T10:00:00.000Z'); + expect(res.buckets[1].t).toBe('2026-04-19T11:00:00.000Z'); + }); + + it('throws UsageError-like for unparseable --bucket', async () => { + const file = path.join(historyDir, 'DEV1.jsonl'); + writeJsonl(file, [ + { t: '2026-04-19T10:00:00.000Z', topic: 'status', payload: { temperature: 20 } }, + ]); + + await expect( + aggregateDeviceHistory('DEV1', { + from: '2026-04-19T00:00:00.000Z', + to: '2026-04-20T00:00:00.000Z', + metrics: ['temperature'], + bucket: 'banana', + }), + ).rejects.toThrow(/Invalid --bucket/); + }); +``` + +- [ ] **Step 2: Run tests — expect FAIL on the new cases** + +Run: +```bash +npx vitest run tests/devices/history-agg.test.ts +``` +Expected: single-bucket case still passes; new cases fail because `bucketMs` is unused. + +- [ ] **Step 3: Wire up `bucketMs`** + +Edit `src/devices/history-agg.ts` — change the top of `aggregateDeviceHistory` and the `key` computation: + +Replace the line +```ts + const needQuantile = aggs.includes('p50') || aggs.includes('p95'); +``` +with: +```ts + const needQuantile = aggs.includes('p50') || aggs.includes('p95'); + + let bucketMs: number | null = null; + if (opts.bucket !== undefined) { + const { parseDurationToMs } = await import('./history-query.js'); + bucketMs = parseDurationToMs(opts.bucket); + if (bucketMs === null) { + throw new Error(`Invalid --bucket "${opts.bucket}". Expected e.g. "15m", "1h", "1d".`); + } + } +``` + +Also hoist the import to the top of the file (replace the existing import of `jsonlFilesForDevice, resolveRange` with): +```ts +import { jsonlFilesForDevice, parseDurationToMs, resolveRange } from './history-query.js'; +``` +and drop the dynamic `await import`: +```ts + let bucketMs: number | null = null; + if (opts.bucket !== undefined) { + bucketMs = parseDurationToMs(opts.bucket); + if (bucketMs === null) { + throw new Error(`Invalid --bucket "${opts.bucket}". Expected e.g. "15m", "1h", "1d".`); + } + } +``` + +Then replace the line +```ts + const key = 0; // single-bucket mode; Task 4 introduces bucketMs +``` +with: +```ts + const key = bucketMs !== null ? Math.floor(tMs / bucketMs) * bucketMs : 0; +``` + +- [ ] **Step 4: Run tests — expect PASS** + +Run: +```bash +npx vitest run tests/devices/history-agg.test.ts +``` +Expected: all four cases pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/devices/history-agg.ts tests/devices/history-agg.test.ts +git commit -m "feat(history-agg): bucket samples by UTC-aligned --bucket duration" +``` + +--- + +## Task 5: Quantiles (p50/p95) with sample cap + +Record `samples[]` per `(bucket × metric)` when quantiles are requested, cap the array at `maxBucketSamples`, flip `partial` on overflow, and append a per-bucket note. + +**Files:** +- Modify: `src/devices/history-agg.ts` (add sample push + cap + note) +- Modify: `tests/devices/history-agg.test.ts` (append two tests) + +- [ ] **Step 1: Append failing tests** + +Append inside the same `describe`: +```ts + it('computes p50 and p95 via nearest-rank on sorted samples', async () => { + const file = path.join(historyDir, 'DEV1.jsonl'); + // 100 samples uniformly 1..100 + const records = []; + for (let i = 1; i <= 100; i++) { + records.push({ + t: `2026-04-19T10:${String(Math.floor((i - 1) / 2)).padStart(2, '0')}:${String((i - 1) % 2 * 30).padStart(2, '0')}.000Z`, + topic: 'status', + payload: { v: i }, + }); + } + writeJsonl(file, records); + + const res = await aggregateDeviceHistory('DEV1', { + from: '2026-04-19T00:00:00.000Z', + to: '2026-04-20T00:00:00.000Z', + metrics: ['v'], + aggs: ['p50', 'p95'], + }); + + expect(res.buckets).toHaveLength(1); + // Nearest-rank on 1..100: p50 → index floor(0.5*99)=49 → 50; p95 → floor(0.95*99)=94 → 95 + expect(res.buckets[0].metrics.v.p50).toBe(50); + expect(res.buckets[0].metrics.v.p95).toBe(95); + }); + + it('flips partial:true and appends a note when sample cap is hit', async () => { + const file = path.join(historyDir, 'DEV1.jsonl'); + const records = []; + // 5 samples, cap=3 → cap hit on the 4th + for (let i = 0; i < 5; i++) { + records.push({ + t: `2026-04-19T10:00:0${i}.000Z`, + topic: 'status', + payload: { v: i + 1 }, + }); + } + writeJsonl(file, records); + + const res = await aggregateDeviceHistory('DEV1', { + from: '2026-04-19T00:00:00.000Z', + to: '2026-04-20T00:00:00.000Z', + metrics: ['v'], + aggs: ['count', 'p95'], + maxBucketSamples: 3, + }); + + expect(res.partial).toBe(true); + expect(res.notes.length).toBe(1); + expect(res.notes[0]).toMatch(/sample cap 3 reached/); + // count is still exact (all 5 samples folded in) + expect(res.buckets[0].metrics.v.count).toBe(5); + }); +``` + +- [ ] **Step 2: Run tests — expect FAIL on the new cases** + +Run: +```bash +npx vitest run tests/devices/history-agg.test.ts +``` +Expected: p50/p95 case produces `undefined` or wrong values; partial case shows `partial:false`. + +- [ ] **Step 3: Add sample push + cap logic** + +In `src/devices/history-agg.ts`, inside `aggregateDeviceHistory`, replace the inner metric-fold block: + +From: +```ts + acc.min = Math.min(acc.min, v); + acc.max = Math.max(acc.max, v); + acc.sum += v; + acc.count += 1; + } +``` +To: +```ts + acc.min = Math.min(acc.min, v); + acc.max = Math.max(acc.max, v); + acc.sum += v; + acc.count += 1; + if (acc.samples) { + if (acc.samples.length < sampleCap) { + acc.samples.push(v); + } else if (!acc.sampleCapHit) { + acc.sampleCapHit = true; + partial = true; + notes.push( + `bucket ${new Date(key).toISOString()} metric ${metric}: sample cap ${sampleCap} reached, quantiles approximate`, + ); + } + } + } +``` + +Also at the top of the function, add `sampleCap`, `partial`, and `notes` locals — place them right after the `needQuantile` / `bucketMs` block: +```ts + const sampleCap = Math.max(1, Math.min(opts.maxBucketSamples ?? DEFAULT_SAMPLE_CAP, MAX_SAMPLE_CAP)); + let partial = false; + const notes: string[] = []; +``` + +Change the final `return finalize(...)` to pass `partial` and `notes`: +```ts + return finalize(deviceId, opts, aggs, buckets, partial, notes); +``` + +- [ ] **Step 4: Run tests — expect PASS** + +Run: +```bash +npx vitest run tests/devices/history-agg.test.ts +``` +Expected: all cases pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/devices/history-agg.ts tests/devices/history-agg.test.ts +git commit -m "feat(history-agg): compute p50/p95 with nearest-rank and sample cap" +``` + +--- + +## Task 6: Non-numeric skip, empty device, mtime prune + +Three small behaviors finalize the pure function's contract. + +**Files:** +- Modify: `src/devices/history-agg.ts` (mtime prune) +- Modify: `tests/devices/history-agg.test.ts` (append three tests) + +- [ ] **Step 1: Append failing tests** + +```ts + it('skips non-numeric samples for a metric', async () => { + const file = path.join(historyDir, 'DEV1.jsonl'); + writeJsonl(file, [ + { t: '2026-04-19T10:00:00.000Z', topic: 'status', payload: { temperature: 20 } }, + { t: '2026-04-19T10:05:00.000Z', topic: 'status', payload: { temperature: 'hot' } }, + { t: '2026-04-19T10:10:00.000Z', topic: 'status', payload: { temperature: null } }, + { t: '2026-04-19T10:15:00.000Z', topic: 'status', payload: { temperature: 24 } }, + ]); + + const res = await aggregateDeviceHistory('DEV1', { + from: '2026-04-19T00:00:00.000Z', + to: '2026-04-20T00:00:00.000Z', + metrics: ['temperature'], + aggs: ['count', 'avg'], + }); + + expect(res.buckets[0].metrics.temperature.count).toBe(2); + expect(res.buckets[0].metrics.temperature.avg).toBe(22); + }); + + it('omits metric entirely when no numeric samples exist in a bucket', async () => { + const file = path.join(historyDir, 'DEV1.jsonl'); + writeJsonl(file, [ + { t: '2026-04-19T10:00:00.000Z', topic: 'status', payload: { temperature: 20 } }, + ]); + + const res = await aggregateDeviceHistory('DEV1', { + from: '2026-04-19T00:00:00.000Z', + to: '2026-04-20T00:00:00.000Z', + metrics: ['temperature', 'humidity'], + aggs: ['count'], + }); + + expect(res.buckets).toHaveLength(1); + expect(res.buckets[0].metrics.temperature.count).toBe(1); + expect(res.buckets[0].metrics.humidity).toBeUndefined(); + }); + + it('returns empty buckets for an unknown device', async () => { + const res = await aggregateDeviceHistory('UNKNOWN', { + from: '2026-04-19T00:00:00.000Z', + to: '2026-04-20T00:00:00.000Z', + metrics: ['temperature'], + }); + expect(res.buckets).toEqual([]); + expect(res.partial).toBe(false); + }); + + it('skips rotated files whose mtime is older than --since window', async () => { + const base = path.join(historyDir, 'DEV1.jsonl'); + const rotated = `${base}.1`; + writeJsonl(rotated, [ + { t: '2025-01-01T00:00:00.000Z', topic: 'status', payload: { temperature: 99 } }, + ]); + // Force the rotated file's mtime to a year ago. + const oneYearAgo = Date.now() - 365 * 86_400_000; + fs.utimesSync(rotated, new Date(oneYearAgo), new Date(oneYearAgo)); + + writeJsonl(base, [ + { t: new Date(Date.now() - 60_000).toISOString(), topic: 'status', payload: { temperature: 21 } }, + ]); + + const res = await aggregateDeviceHistory('DEV1', { + since: '5m', + metrics: ['temperature'], + aggs: ['count', 'min'], + }); + + expect(res.buckets).toHaveLength(1); + expect(res.buckets[0].metrics.temperature.count).toBe(1); + expect(res.buckets[0].metrics.temperature.min).toBe(21); + }); +``` + +- [ ] **Step 2: Run tests — expect some to FAIL** + +Run: +```bash +npx vitest run tests/devices/history-agg.test.ts +``` +Expected: non-numeric skip and empty-metric cases pass (the existing guard `typeof v !== 'number'` covers them). Empty-device case passes (`jsonlFilesForDevice` returns `[]`). mtime-prune case **fails** because we don't prune yet. + +- [ ] **Step 3: Add mtime prune** + +In `src/devices/history-agg.ts`, inside the `for (const file of jsonlFilesForDevice(deviceId))` loop, before opening the stream, add: + +```ts + for (const file of jsonlFilesForDevice(deviceId)) { + try { + const st = fs.statSync(file); + if (st.mtimeMs < fromMs) continue; + } catch { + continue; + } + const stream = fs.createReadStream(file, { encoding: 'utf-8' }); +``` + +- [ ] **Step 4: Run tests — expect PASS** + +Run: +```bash +npx vitest run tests/devices/history-agg.test.ts +``` +Expected: all cases pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/devices/history-agg.ts tests/devices/history-agg.test.ts +git commit -m "feat(history-agg): mtime-prune rotated files, handle non-numeric + unknown-device" +``` + +--- + +## Task 7: `history aggregate` CLI subcommand — flag parsing + +Wire the subcommand to Commander. Translate flags to `AggOptions`, map thrown errors to `UsageError`, print via `printJson` or text table. + +**Files:** +- Modify: `src/commands/history.ts` (add subcommand registration) +- Modify: `tests/commands/history.test.ts` (append tests) + +- [ ] **Step 1: Append failing test — JSON happy path** + +Append to `tests/commands/history.test.ts` (at the bottom of the file, inside any existing `describe('history', …)` or a new `describe`). Mirror the fixture-setup pattern used by the existing range/stats tests in the same file. If that file doesn't already use `vi.spyOn(os, 'homedir')`, add the same `beforeEach` / `afterEach` pattern from `tests/devices/history-agg.test.ts`: + +```ts +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { Command } from 'commander'; +import { registerHistoryCommand } from '../../src/commands/history.js'; + +describe('history aggregate CLI', () => { + let tmpHome: string; + let historyDir: string; + + beforeEach(() => { + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sb-agg-cli-')); + historyDir = path.join(tmpHome, '.switchbot', 'device-history'); + fs.mkdirSync(historyDir, { recursive: true }); + vi.spyOn(os, 'homedir').mockReturnValue(tmpHome); + }); + + afterEach(() => { + vi.restoreAllMocks(); + try { fs.rmSync(tmpHome, { recursive: true, force: true }); } catch { /* */ } + }); + + function makeProgram(): Command { + const p = new Command(); + p.name('switchbot').version('0.0.0-test'); + p.option('--json'); + registerHistoryCommand(p); + return p; + } + + it('emits the expected --json envelope for a single-bucket aggregation', async () => { + fs.writeFileSync( + path.join(historyDir, 'DEV1.jsonl'), + [ + { t: '2026-04-19T10:00:00.000Z', topic: 'status', payload: { temperature: 20 } }, + { t: '2026-04-19T10:30:00.000Z', topic: 'status', payload: { temperature: 24 } }, + ].map((r) => JSON.stringify(r)).join('\n') + '\n', + ); + + const chunks: string[] = []; + const logSpy = vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + chunks.push(args.map(String).join(' ')); + }); + + const p = makeProgram(); + p.exitOverride(); + try { + await p.parseAsync([ + 'node', 'test', + '--json', + 'history', 'aggregate', 'DEV1', + '--from', '2026-04-19T00:00:00.000Z', + '--to', '2026-04-20T00:00:00.000Z', + '--metric', 'temperature', + '--agg', 'count,avg', + ]); + } finally { + logSpy.mockRestore(); + } + + const parsed = JSON.parse(chunks.join('')) as { data: { buckets: Array<{ metrics: Record }> } }; + expect(parsed.data.buckets).toHaveLength(1); + expect(parsed.data.buckets[0].metrics.temperature.count).toBe(2); + expect(parsed.data.buckets[0].metrics.temperature.avg).toBe(22); + }); + + it('exits 2 with UsageError when --metric is missing', async () => { + const p = makeProgram(); + p.exitOverride(); + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`exit:${code}`); + }) as never); + try { + await expect( + p.parseAsync(['node', 'test', 'history', 'aggregate', 'DEV1', '--since', '1h']), + ).rejects.toThrow(/exit:2/); + } finally { + errSpy.mockRestore(); + exitSpy.mockRestore(); + } + }); +}); +``` + +- [ ] **Step 2: Run — expect FAIL (no `aggregate` subcommand yet)** + +Run: +```bash +npx vitest run tests/commands/history.test.ts +``` +Expected: failures say `unknown command 'aggregate'`. + +- [ ] **Step 3: Register the subcommand** + +In `src/commands/history.ts`, add imports at the top (keep existing ones intact): +```ts +import { + aggregateDeviceHistory, + ALL_AGG_FNS, + type AggFn, + type AggOptions, +} from '../devices/history-agg.js'; +``` + +At the end of `registerHistoryCommand`, before the final `}`, add: +```ts + history + .command('aggregate') + .description('Bucketed aggregation (count/min/max/avg/sum/p50/p95) over device history JSONL') + .argument('', 'Device ID to aggregate') + .option('--since ', 'Relative window ending now (mutually exclusive with --from/--to)', stringArg('--since')) + .option('--from ', 'Range start (ISO-8601)', stringArg('--from')) + .option('--to ', 'Range end (ISO-8601)', stringArg('--to')) + .option( + '--metric ', + 'Payload field to aggregate (repeat for multiple metrics)', + (v: string, acc: string[] = []) => acc.concat(v), + [] as string[], + ) + .option('--agg ', `Comma-separated subset of: ${ALL_AGG_FNS.join(',')} (default: count,avg)`, stringArg('--agg')) + .option('--bucket ', 'Bucket width, e.g. "15m", "1h", "1d" (omit for one bucket over the whole window)', stringArg('--bucket')) + .option('--max-bucket-samples ', 'Safety cap for quantile samples per (bucket × metric) (default 10000)', intArg('--max-bucket-samples', { min: 1, max: 100_000 })) + .addHelpText('after', ` +Reads the append-only JSONL history (populated by 'events mqtt-tail' and MCP +status refreshes). Non-numeric samples are skipped; metrics with zero numeric +samples in a bucket are omitted from that bucket's "metrics" object. + +Examples: + $ switchbot history aggregate --since 7d --metric temperature --agg avg,p95 --bucket 1h + $ switchbot history aggregate --from 2026-04-18T00:00:00Z --to 2026-04-19T00:00:00Z \\ + --metric temperature --metric humidity --agg count,avg,p95 --bucket 15m +`) + .action(async ( + deviceId: string, + options: { + since?: string; + from?: string; + to?: string; + metric?: string[]; + agg?: string; + bucket?: string; + maxBucketSamples?: string; + }, + ) => { + if (!options.metric || options.metric.length === 0) { + handleError(new UsageError('at least one --metric is required.')); + } + + let aggs: AggFn[] | undefined; + if (options.agg !== undefined) { + const names = options.agg.split(',').map((s) => s.trim()).filter(Boolean); + const invalid = names.filter((n) => !(ALL_AGG_FNS as readonly string[]).includes(n)); + if (invalid.length > 0) { + handleError(new UsageError( + `--agg contains unknown function(s): ${invalid.join(', ')}. Legal: ${ALL_AGG_FNS.join(', ')}.`, + )); + } + aggs = names as AggFn[]; + } + + try { + const opts: AggOptions = { + since: options.since, + from: options.from, + to: options.to, + metrics: options.metric!, + aggs, + bucket: options.bucket, + maxBucketSamples: options.maxBucketSamples !== undefined ? Number(options.maxBucketSamples) : undefined, + }; + const res = await aggregateDeviceHistory(deviceId, opts); + + if (isJsonMode()) { + printJson(res); + return; + } + if (res.buckets.length === 0) { + console.log(`(no history records for ${deviceId} in requested range)`); + return; + } + // Text mode: one row per bucket, columns = (t, ., …) + const colMetrics = res.metrics; + const colAggs = res.aggs; + const header = ['t', ...colMetrics.flatMap((m) => colAggs.map((a) => `${m}.${a}`))]; + console.log(header.join(' ')); + for (const b of res.buckets) { + const cells: string[] = [b.t]; + for (const m of colMetrics) { + const mr = b.metrics[m]; + for (const a of colAggs) { + const v = mr?.[a]; + cells.push(v === undefined ? '—' : (Number.isInteger(v) ? String(v) : v.toFixed(3))); + } + } + console.log(cells.join(' ')); + } + if (res.partial) { + for (const n of res.notes) console.error(`note: ${n}`); + } + } catch (err) { + if (err instanceof Error && /^Invalid (--|--bucket)/i.test(err.message)) { + handleError(new UsageError(err.message)); + } + if (err instanceof Error && /--since is mutually exclusive|--from must be <= --to|Invalid --since|Invalid --from|Invalid --to/.test(err.message)) { + handleError(new UsageError(err.message)); + } + handleError(err); + } + }); +``` + +- [ ] **Step 4: Run tests — expect PASS** + +Run: +```bash +npx vitest run tests/commands/history.test.ts +``` +Expected: both new cases pass; existing tests still green. + +- [ ] **Step 5: Commit** + +```bash +git add src/commands/history.ts tests/commands/history.test.ts +git commit -m "feat(history): add 'aggregate' subcommand wired to aggregateDeviceHistory" +``` + +--- + +## Task 8: MCP `aggregate_device_history` tool + +Register a new strict-schema tool that delegates to the same pure function. This keeps CLI/MCP outputs identical by construction. + +**Files:** +- Modify: `src/commands/mcp.ts` (add `server.registerTool('aggregate_device_history', …)`) +- Modify: `tests/commands/mcp.test.ts` (append tool-surface tests) + +- [ ] **Step 1: Append failing tests** + +Append to `tests/commands/mcp.test.ts` (inside the existing `describe('mcp server', …)` or a new one — keep the existing `pair()` helper in scope): + +```ts + it('lists aggregate_device_history with _meta.agentSafetyTier=read', async () => { + const { client } = await pair(); + const res = await client.listTools(); + const tool = res.tools.find((t) => t.name === 'aggregate_device_history'); + expect(tool).toBeDefined(); + expect(tool!._meta).toBeDefined(); + expect((tool!._meta as { agentSafetyTier?: string }).agentSafetyTier).toBe('read'); + }); + + it('aggregate_device_history rejects unknown input keys with -32602', async () => { + const { client } = await pair(); + await expect( + client.callTool({ + name: 'aggregate_device_history', + arguments: { + deviceId: 'DEV1', + metrics: ['temperature'], + bogusField: 'nope', + }, + }), + ).rejects.toMatchObject({ code: -32602 }); + }); + + it('aggregate_device_history returns the same shape as the CLI --json.data', async () => { + // The test writes synthetic JSONL into a tmp home, then calls the tool. + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sb-agg-mcp-')); + const historyDir = path.join(tmpHome, '.switchbot', 'device-history'); + fs.mkdirSync(historyDir, { recursive: true }); + vi.spyOn(os, 'homedir').mockReturnValue(tmpHome); + + fs.writeFileSync( + path.join(historyDir, 'DEV1.jsonl'), + [ + { t: '2026-04-19T10:00:00.000Z', topic: 'status', payload: { temperature: 20 } }, + { t: '2026-04-19T10:30:00.000Z', topic: 'status', payload: { temperature: 24 } }, + ].map((r) => JSON.stringify(r)).join('\n') + '\n', + ); + + try { + const { client } = await pair(); + const res = await client.callTool({ + name: 'aggregate_device_history', + arguments: { + deviceId: 'DEV1', + from: '2026-04-19T00:00:00.000Z', + to: '2026-04-20T00:00:00.000Z', + metrics: ['temperature'], + aggs: ['count', 'avg'], + }, + }); + + const sc = (res as { structuredContent?: { data?: unknown; buckets?: unknown } }).structuredContent; + expect(sc).toBeDefined(); + // Envelope may be either { schemaVersion, data: { buckets } } or { buckets } direct; + // accept either as long as buckets[].metrics.temperature.count === 2. + const payload = + sc && typeof sc === 'object' && 'data' in sc + ? (sc as { data: { buckets: Array<{ metrics: Record }> } }).data + : (sc as { buckets: Array<{ metrics: Record }> }); + expect(payload.buckets[0].metrics.temperature.count).toBe(2); + expect(payload.buckets[0].metrics.temperature.avg).toBe(22); + } finally { + try { fs.rmSync(tmpHome, { recursive: true, force: true }); } catch { /* */ } + } + }); +``` + +Make sure the imports block at the top of `tests/commands/mcp.test.ts` includes `fs`, `os`, `path` if they aren't already: +```ts +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +``` + +- [ ] **Step 2: Run — expect FAIL (tool not registered yet)** + +Run: +```bash +npx vitest run tests/commands/mcp.test.ts +``` +Expected: listing/strictness/shape tests fail. + +- [ ] **Step 3: Register the tool** + +In `src/commands/mcp.ts`, add the imports near the top (keep existing imports intact): +```ts +import { z } from 'zod'; +import { + aggregateDeviceHistory, + ALL_AGG_FNS, + MAX_SAMPLE_CAP, + type AggFn, + type AggOptions, +} from '../devices/history-agg.js'; +``` +(If `z` is already imported, skip that line.) + +Then inside `createSwitchBotMcpServer()`, alongside the other `server.registerTool(…)` calls, add: +```ts + 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), + since: z.string().optional(), + from: z.string().optional(), + to: z.string().optional(), + metrics: z.array(z.string().min(1)).min(1), + aggs: z.array(z.enum(ALL_AGG_FNS as unknown as [AggFn, ...AggFn[]])).optional(), + bucket: z.string().optional(), + maxBucketSamples: z + .number() + .int() + .positive() + .max(MAX_SAMPLE_CAP) + .optional(), + }) + .strict(), + }, + 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); + return { + content: [{ type: 'text', text: JSON.stringify(res, null, 2) }], + structuredContent: res, + }; + }, + ); +``` + +- [ ] **Step 4: Run tests — expect PASS** + +Run: +```bash +npx vitest run tests/commands/mcp.test.ts +``` +Expected: all three new cases pass; every existing case (including the "exposes the ten tools" test — which now lists eleven) updates once we bump the count in Task 9. + +- [ ] **Step 5: If the "ten tools" existing test fails, update the expected count** + +That test lives in `tests/commands/mcp.test.ts` (around the line matching `exposes the ten tools`). Bump it to `eleven` / `toHaveLength(11)`. + +Run: +```bash +npx vitest run tests/commands/mcp.test.ts +``` +Expected: all tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/commands/mcp.ts tests/commands/mcp.test.ts +git commit -m "feat(mcp): add aggregate_device_history tool with _meta.agentSafetyTier" +``` + +--- + +## Task 9: `capabilities` metadata + +Register the new CLI leaf and the new MCP tool in `capabilities` so bootstrap output stays accurate. + +**Files:** +- Modify: `src/commands/capabilities.ts` (two lines) +- Modify: `tests/commands/capabilities.test.ts` (append one case) + +- [ ] **Step 1: Append failing tests** + +Append inside an existing `describe` in `tests/commands/capabilities.test.ts`: +```ts + it('exposes history aggregate as a read-tier leaf', async () => { + const out = await runCapabilitiesWith(['--compact']); + const cmds = out.commands as Array<{ name: string; agentSafetyTier: string; mutating: boolean }>; + const agg = cmds.find((c) => c.name === 'history aggregate'); + expect(agg).toBeDefined(); + expect(agg!.agentSafetyTier).toBe('read'); + expect(agg!.mutating).toBe(false); + }); + + it('surfaces.mcp.tools includes aggregate_device_history', async () => { + const out = await runCapabilitiesWith([]); + const mcp = (out.surfaces as Record).mcp; + expect(mcp.tools).toContain('aggregate_device_history'); + }); +``` + +- [ ] **Step 2: Run — expect FAIL** + +Run: +```bash +npx vitest run tests/commands/capabilities.test.ts +``` + +- [ ] **Step 3: Update `capabilities.ts`** + +In `src/commands/capabilities.ts`, inside `COMMAND_META`, add a row next to the other `history *` entries: +```ts + 'history aggregate':{ mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 80 }, +``` + +In the same file, append `'aggregate_device_history'` to the `MCP_TOOLS` array: +```ts +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', +]; +``` + +- [ ] **Step 4: Run — expect PASS** + +Run: +```bash +npx vitest run tests/commands/capabilities.test.ts +``` + +- [ ] **Step 5: Commit** + +```bash +git add src/commands/capabilities.ts tests/commands/capabilities.test.ts +git commit -m "feat(capabilities): advertise history aggregate + aggregate_device_history" +``` + +--- + +## Task 10: CHANGELOG + version bump + +**Files:** +- Modify: `CHANGELOG.md` +- Modify: `package.json` + +- [ ] **Step 1: Add the 2.5.0 entry to `CHANGELOG.md`** + +Insert a new section above the `## [2.4.0]` heading: +```markdown +## [2.5.0] - 2026-04-20 + +### Added + +- **`history aggregate `** — on-demand bucketed statistics + (`count / min / max / avg / sum / p50 / p95`) over the append-only JSONL + device history. Flags: `--since` / `--from` / `--to`, repeatable + `--metric`, `--agg `, `--bucket `, + `--max-bucket-samples `. Non-numeric samples are skipped; empty + metrics are omitted from their bucket. +- **MCP `aggregate_device_history`** — same contract as the CLI, exposed + as a read-tier tool (`_meta.agentSafetyTier: "read"`) with a strict + Zod input schema (unknown keys reject with JSON-RPC `-32602`). +- **Capabilities manifest** — new `history aggregate` entry in + `COMMAND_META`; new `aggregate_device_history` entry in + `surfaces.mcp.tools`. + +### Notes + +- Storage format unchanged. Aggregation streams the existing JSONL + rotation files via `readline` — zero memory blow-up for large + windows, with a hard ceiling of `--max-bucket-samples` × 8 bytes per + `(bucket × metric)` for quantile computation. +- Quantiles use nearest-rank on sorted per-bucket samples; if the cap + is reached the result carries `partial: true` and a per-bucket + `notes[]` entry. `count / min / max / avg / sum` remain exact. + +### Not included (deferred) + +- Cross-device aggregation (agents merge locally). +- Trend / rate-of-change helpers (derivable from bucket series). +- `--fill-empty` for missing buckets. + +``` + +- [ ] **Step 2: Bump `package.json` version** + +Edit `package.json`: + +From: +```json + "version": "2.4.0", +``` +To: +```json + "version": "2.5.0", +``` + +- [ ] **Step 3: Rebuild + run the full test suite** + +Run: +```bash +npm run build +npm test +``` +Expected: clean build, all tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add CHANGELOG.md package.json +git commit -m "chore(release): 2.5.0 — history aggregate + aggregate_device_history" +``` + +--- + +## Task 11: Extend PR #19 with the implementation + +The design spec already lives on branch `docs/history-aggregation-spec` (PR #19). Implementation tasks 1–10 land on the same branch and extend that PR. + +**Files:** (none) + +- [ ] **Step 1: Push** + +Run: +```bash +git push +``` + +- [ ] **Step 2: Verify PR status** + +Run: +```bash +"/c/Program Files/GitHub CLI/gh.exe" pr view docs/history-aggregation-spec --json state,title,statusCheckRollup | head -80 +``` +Expected: state `OPEN`, CI either queued or running. + +- [ ] **Step 3: Update PR body with the implementation summary** + +Run: +```bash +"/c/Program Files/GitHub CLI/gh.exe" pr edit docs/history-aggregation-spec --body "$(cat <<'EOF' +## Summary +Ships the spec **and** its implementation for 2.5.0 device-history aggregation. + +### Spec (`docs/superpowers/specs/2026-04-20-device-history-aggregation-design.md`) +- Per-device bucketed aggregation on top of existing JSONL storage. +- New CLI subcommand + new MCP tool; shared pure function. + +### Implementation +- `src/devices/history-agg.ts` — pure `aggregateDeviceHistory(deviceId, opts)`. +- `src/commands/history.ts` — `aggregate` subcommand. +- `src/commands/mcp.ts` — `aggregate_device_history` tool with `.strict()` schema + `_meta.agentSafetyTier: "read"`. +- `src/commands/capabilities.ts` — `COMMAND_META` + `MCP_TOOLS` updated. +- Tests: 12 new pure-function cases, 2 CLI cases, 3 MCP cases, 2 capabilities cases. +- `CHANGELOG.md` 2.5.0 entry; `package.json` version bump. + +## Test plan +- [x] `npm test` green on branch head +- [x] `npm run build` clean +- [ ] Reviewer runs the "Verification" block from the spec §11 (quick smoke) if desired. +EOF +)" +``` + +- [ ] **Step 4: Wait for CI; do not merge until the user approves** + +--- + +## Self-Review + +### 1. Spec coverage check + +Walking the spec section-by-section: + +- **§2 Goals**: per-device bucketed aggregation → Tasks 3–6; zero storage change → no storage tasks; CLI & MCP parity → Tasks 7 + 8; agent-friendly JSON → output shape baked into `finalize()` in Task 3, validated in Task 8's parity test. ✓ +- **§2 Non-goals**: documented in CHANGELOG "Not included (deferred)" in Task 10. ✓ +- **§3.1 CLI**: every flag in the spec table is implemented in Task 7's action body (`--since`, `--from`, `--to`, repeatable `--metric`, `--agg`, `--bucket`, `--max-bucket-samples`, `--json`). ✓ +- **§3.2 MCP**: strict Zod schema + `_meta` + `execution.taskSupport: 'forbidden'` — the plan registers `_meta.agentSafetyTier: 'read'` and `.strict()`. **Gap:** spec §3.2 shows `execution: { taskSupport: 'forbidden' }` but Task 8's snippet omits it. The existing MCP tools in the codebase already set that on other tools; if that's the project-wide convention, the reviewer should add it. Accepting this as a non-blocker — it's 2 LoC. +- **§4 Output shape**: every field (`deviceId`, `bucket`, `from`, `to`, `metrics`, `aggs`, `buckets[]`, `partial`, `notes`) is produced by `finalize()` in Task 3; the "empty buckets omitted" and "metric absent when all non-numeric" rules are tested in Task 6. ✓ +- **§5 Architecture**: `history-agg.ts` as the pure function, CLI + MCP each translating to `AggOptions` — matches Tasks 3, 7, 8. ✓ +- **§6 Algorithm**: single-bucket + bucket alignment + quantile cap + mtime prune — Tasks 3, 4, 5, 6. ✓ +- **§7 Error handling**: `--metric` missing, `--agg` unknown, `--bucket` unparseable, `--since` + `--from/--to` mutex, `--from > --to`, empty device, sample cap overflow — Tasks 4 (unparseable `--bucket`), 6 (unknown device), 7 (missing `--metric`, bad `--agg`, mutex propagation), 5 (sample cap). ✓ +- **§8 Testing strategy**: 12 pure-function cases (Tasks 3–6), CLI cases (Task 7), MCP cases (Task 8). ✓ +- **§9 Backward compatibility**: additive-only — verified by the fact that no existing field or file shape changes in any task. ✓ + +### 2. Placeholder scan + +- No "TBD" / "TODO" / "fill in later". +- Every code block is concrete. +- Every test has explicit assertions with known-value expectations. + +### 3. Type / signature consistency + +- `AggOptions` extends `QueryOptions` (Task 2) → used identically in Tasks 3, 5, 7, 8. ✓ +- `AggFn` union in Task 2 (`'count' | 'min' | 'max' | 'avg' | 'sum' | 'p50' | 'p95'`) is consumed via `ALL_AGG_FNS` in Tasks 7 (CLI validation) and 8 (MCP enum). ✓ +- `aggregateDeviceHistory(deviceId, opts): Promise` signature stable across Tasks 3–6 as the body grows. ✓ +- `MAX_SAMPLE_CAP = 100_000` (Task 2) is consumed in Task 5 (runtime clamp) and Task 8 (MCP `z.number().max(…)`). ✓ +- `finalize()` signature in Task 3 takes `partial` + `notes`; Task 5 passes them through unchanged. ✓ + +Self-review clean. No inline fixes needed beyond the §3.2 `execution.taskSupport` nit (flagged as non-blocker). + +--- + +## Execution Handoff + +Plan complete and saved to `docs/superpowers/plans/2026-04-20-device-history-aggregation.md`. Two execution options: + +1. **Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration. +2. **Inline Execution** — Execute tasks in this session using executing-plans, batch execution with checkpoints. + +Which approach? diff --git a/docs/superpowers/specs/2026-04-20-device-history-aggregation-design.md b/docs/superpowers/specs/2026-04-20-device-history-aggregation-design.md new file mode 100644 index 00000000..80211d67 --- /dev/null +++ b/docs/superpowers/specs/2026-04-20-device-history-aggregation-design.md @@ -0,0 +1,336 @@ +# Device History Aggregation — Design + +- **Date:** 2026-04-20 +- **Target release:** 2.5.0 (deferred from 2.4.1 scope per `release/2.4.1` plan) +- **Status:** Design approved, implementation pending + +## 1. Motivation + +`switchbot-cli` 2.4.0 ships JSONL-backed per-device history at +`~/.switchbot/device-history/.jsonl` (50 MB × 3 rotation), with CLI +query surface `history range` / `history stats` and MCP +`query_device_history`. Agents can pull raw records but have no way to ask +"what was the p95 temperature per hour last week?" without fetching every +sample and aggregating locally — which is token-expensive and slow. + +The 2.4.1 patch plan explicitly deferred aggregation primitives to 2.5.0 +(`Aggregation primitives on history range (avg/min/max/p95/group-by). Still +deferred to 2.5.0`). This design specifies that deferred feature. + +## 2. Goals + +- **Per-device bucketed statistics** over existing JSONL storage. +- **Zero storage format change** — read-only layer on top of today's files. +- **CLI and MCP parity** — same contract shape in both surfaces. +- **Agent-friendly output** — structured JSON that an agent can feed back into + a decision without re-parsing. + +### Non-goals (explicit) + +- Cross-device aggregation. Agents multi-call and merge locally. +- Trend / rate-of-change helpers. Derivable from bucket time-series. +- Real-time streaming / subscriptions. +- Migration to SQLite or a TSDB. JSONL + streaming `readline` is sufficient + until `recordCount > 1M` per device forces a rethink. +- `--fill-empty` for missing buckets (MVP omits; agent can fill). +- Changes to `events mqtt-tail` write path or the `.json` ring buffer. + +## 3. User-facing surface + +### 3.1 CLI + +New subcommand `history aggregate`: + +```bash +# Minimum viable +switchbot history aggregate --since 7d --metric temperature --agg avg,p95 + +# Multi-metric + time bucket +switchbot history aggregate \ + --from 2026-04-13T00:00:00Z --to 2026-04-20T00:00:00Z \ + --metric temperature --metric humidity \ + --agg count,min,max,avg,p95 \ + --bucket 1h + +# Single bucket for the whole window (omit --bucket) +switchbot history aggregate --since 24h --metric battery --agg min,avg +``` + +| Flag | Meaning | Default | +|---|---|---| +| `--since ` / `--from ` / `--to ` | Reuse `history range` time-window logic (`parseDurationToMs`, `resolveRange`). `--since` and `--from/--to` are mutually exclusive. | — | +| `--metric ` (repeatable) | Payload field to aggregate. Non-numeric samples are skipped. | Required, ≥1 | +| `--agg ` | Subset of `count,min,max,avg,sum,p50,p95`. | `count,avg` | +| `--bucket ` | Duration spec (`15m`, `1h`, `1d`). Omit → one bucket for the whole window. | — | +| `--max-bucket-samples ` | Safety cap for quantile memory. | 10000 | +| `--json` | Envelope JSON output (already global). | TTY-detect | + +Text mode output: three-column aligned table whose columns are `t`, +`.` pairs (stable order from the user's `--metric` × `--agg` +product). Non-TTY defaults to ASCII (honors existing `--table-style`). + +### 3.2 MCP + +New tool `aggregate_device_history` with strict input schema and the +`_meta.agentSafetyTier: "read"` marker (2.4.1 A4 pattern once shipped): + +```ts +server.registerTool('aggregate_device_history', { + title: 'Aggregate device history', + description: 'Bucketed statistics (count/min/max/avg/sum/p50/p95) over JSONL history.', + _meta: { agentSafetyTier: 'read' }, + inputSchema: z.object({ + deviceId: z.string(), + since: z.string().optional(), + from: z.string().optional(), + to: z.string().optional(), + metrics: z.array(z.string()).min(1), + aggs: z.array(z.enum(['count','min','max','avg','sum','p50','p95'])).optional(), + bucket: z.string().optional(), + maxBucketSamples: z.number().int().positive().max(100_000).optional(), + }).strict(), + execution: { taskSupport: 'forbidden' }, +}); +``` + +## 4. Output shape (CLI `--json` and MCP share the same envelope) + +```json +{ + "deviceId": "01-202407011402-60553518", + "bucket": "1h", + "from": "2026-04-19T10:00:00.000Z", + "to": "2026-04-20T10:00:00.000Z", + "metrics": ["temperature", "humidity"], + "aggs": ["count", "avg", "p95"], + "buckets": [ + { + "t": "2026-04-19T10:00:00.000Z", + "metrics": { + "temperature": { "count": 120, "avg": 21.2, "p95": 22.1 }, + "humidity": { "count": 120, "avg": 45.7, "p95": 51.0 } + } + } + ], + "partial": false, + "notes": [] +} +``` + +Rules: + +- `buckets` is ordered by `t` ascending. +- `buckets[].metrics[M]` is **absent** when all samples in that bucket + for metric `M` were non-numeric or the bucket was empty for `M`. + (Agents must not assume every metric appears in every bucket.) +- Empty buckets (no samples for any metric) are **omitted entirely**. +- `partial: true` means at least one bucket exceeded + `maxBucketSamples` for at least one metric; the `notes[]` array + enumerates which buckets were downsampled for quantile computation. + Non-quantile aggs (count/min/max/avg/sum) are always exact. +- All timestamps are ISO-8601 UTC. +- Wrapped in the standard CLI envelope: `{ schemaVersion, data: }`. + +## 5. Architecture + +``` +┌──────────────────────────────────────┐ +│ CLI: switchbot history aggregate │──┐ +└──────────────────────────────────────┘ │ +┌──────────────────────────────────────┐ │ ┌─────────────────────────────┐ +│ MCP: aggregate_device_history tool │──┼───▶│ src/devices/history-agg.ts │ +└──────────────────────────────────────┘ │ │ (new — pure async fn) │ + │ └──────────────┬──────────────┘ + │ │ reuses + │ ▼ + │ ┌─────────────────────────────┐ + └───▶│ history-query.ts │ + │ parseDurationToMs, │ + │ jsonlFilesForDevice, │ + │ resolveRange (export) │ + └─────────────────────────────┘ +``` + +Units: + +- **`src/devices/history-agg.ts`** (new) — pure async function + `aggregateDeviceHistory(deviceId, opts): Promise`. No + side effects. No direct commander/MCP dependency. +- **`src/commands/history.ts`** — register `history aggregate` subcommand. + Parses flags, calls `aggregateDeviceHistory`, prints text or JSON. +- **`src/commands/mcp.ts`** — new `registerTool('aggregate_device_history', + …)` that delegates to the same `aggregateDeviceHistory` function. +- **`src/commands/capabilities.ts`** — `COMMAND_META` gets + `'history aggregate': { mutating:false, consumesQuota:false, + idempotencySupported:false, agentSafetyTier:'read', + verifiability:'local', typicalLatencyMs: 80 }`. + +Interface isolation: + +- `aggregateDeviceHistory` does not read `commander` or MCP types. +- CLI and MCP each translate their input schema into the same + `AggOptions` object and consume the same `AggResult`. +- Tests on the pure function cover correctness; CLI/MCP tests cover + wiring only. + +## 6. Core algorithm + +~100 LoC. Stream-read the oldest-first JSONL files; per line, pick a +bucket key and fold each metric into a running accumulator. + +```ts +interface Acc { + min: number; + max: number; + sum: number; + count: number; + samples: number[] | null; // null → quantiles not requested + sampleCapHit: boolean; +} + +async function aggregateDeviceHistory(deviceId: string, opts: AggOptions): Promise { + const { fromMs, toMs } = resolveRange(opts); + const bucketMs = opts.bucket ? parseDurationToMs(opts.bucket) : null; + if (opts.bucket && bucketMs === null) { + throw new UsageError(`Invalid --bucket "${opts.bucket}". Expected e.g. "15m", "1h", "1d".`); + } + const sampleCap = opts.maxBucketSamples ?? 10_000; + const aggs: AggFn[] = opts.aggs ?? ['count', 'avg']; + const needQuantile = aggs.includes('p50') || aggs.includes('p95'); + + // bucketKey (epoch ms, 0 when no --bucket) → metric → Acc + const buckets = new Map>(); + const notes: string[] = []; + let partial = false; + + for (const file of jsonlFilesForDevice(deviceId)) { + // mtime prune (reuse history-query convention) + try { + const st = fs.statSync(file); + if (st.mtimeMs < fromMs) continue; + } catch { continue; } + + const rl = readline.createInterface({ + input: fs.createReadStream(file, { encoding: 'utf-8' }), + crlfDelay: Infinity, + }); + for await (const line of rl) { + if (!line) continue; + let rec: HistoryRecord; + try { rec = JSON.parse(line) as HistoryRecord; } catch { continue; } + const tMs = Date.parse(rec.t); + if (!Number.isFinite(tMs) || tMs < fromMs || tMs > toMs) continue; + + const key = bucketMs ? Math.floor(tMs / bucketMs) * bucketMs : 0; + let bkt = buckets.get(key); + if (!bkt) { bkt = new Map(); buckets.set(key, bkt); } + + for (const metric of opts.metrics) { + const v = (rec.payload as Record | null | undefined)?.[metric]; + if (typeof v !== 'number' || !Number.isFinite(v)) continue; + let acc = bkt.get(metric); + if (!acc) { + acc = { min: v, max: v, sum: 0, count: 0, + samples: needQuantile ? [] : null, sampleCapHit: false }; + bkt.set(metric, acc); + } + acc.min = Math.min(acc.min, v); + acc.max = Math.max(acc.max, v); + acc.sum += v; + acc.count += 1; + if (acc.samples && acc.samples.length < sampleCap) { + acc.samples.push(v); + } else if (acc.samples && !acc.sampleCapHit) { + acc.sampleCapHit = true; + partial = true; + notes.push(`bucket ${new Date(key).toISOString()} metric ${metric}: sample cap ${sampleCap} reached, quantiles approximate`); + } + } + } + } + + return finalize(buckets, opts, aggs, partial, notes); +} +``` + +`finalize` sorts `buckets` by key ascending, computes each metric's +requested aggs, drops empty metrics/buckets per §4 rules, and returns the +envelope. + +Quantile implementation: sort `samples` ascending, index via +`samples[Math.floor(p * (n-1))]` (nearest-rank). Good enough for MVP; if +users later need interpolated percentiles we swap the helper. + +### Memory bound + +Worst case per `(bucket × metric)`: `sampleCap` numbers × 8 bytes = 80 KB. +For a 7-day window with `--bucket 1h` and 3 metrics: 24 × 7 × 3 = 504 +`(bucket, metric)` cells → max ~40 MB if every cell hits the cap. In +practice devices emit on change, not at cap density, so typical usage is +orders of magnitude smaller. Hard ceiling via `--max-bucket-samples` is +enforced server-side at 100 000. + +## 7. Error handling + +| Condition | Exit | Shape | +|---|---|---| +| `--metric` missing | 2 | `UsageError("at least one --metric required")` | +| `--agg` contains unknown function | 2 | `UsageError` lists legal names | +| `--bucket` unparseable | 2 | `UsageError` with example | +| `--since` + `--from`/`--to` | 2 | reuses `resolveRange` check | +| `--from > --to` | 2 | reuses `resolveRange` check | +| JSONL files don't exist for device | 0 | `{ buckets: [], notes: ["no history recorded for "] }` | +| Bucket samples all non-numeric for a metric | 0 | metric absent from that bucket's `metrics` object | +| Bucket overflows `maxBucketSamples` for quantiles | 0 | `partial: true` + per-bucket `notes[]` | +| JSONL line fails to parse | 0 | line silently skipped (same convention as `history range`) | + +MCP tool translates `UsageError` → `McpError(InvalidParams, …)` so +JSON-RPC clients see `-32602`. + +## 8. Testing strategy + +| File | Asserts | +|---|---| +| `tests/devices/history-agg.test.ts` | — single-bucket count/min/max/avg/sum correctness against known fixture
— multi-bucket boundary alignment (record at `10:59:59.999Z` falls in `10:00` bucket, `11:00:00.000Z` falls in `11:00`)
— p50/p95 against hand-computed values on small fixture
— non-numeric samples skipped, numeric `"21.5"` string skipped (strict `typeof v === 'number'`)
— empty device returns `buckets: []`
— sample cap: synthetic >10 001 samples → `partial: true` and `notes[]` populated
— mtime prune skips rotated files older than `fromMs` | +| `tests/commands/history-aggregate.test.ts` | — flag parsing (missing `--metric`, bad `--agg`, bad `--bucket`, both `--since` and `--from`)
— `--json` envelope shape round-trip
— repeatable `--metric` vs csv `--agg` both work
— text mode column layout stable ordering | +| `tests/mcp/aggregate-device-history.test.ts` | — tool listed in `tools/list`
— `_meta.agentSafetyTier === 'read'`
— `.strict()` rejects unknown input key with JSON-RPC `-32602`
— output shape identical to CLI `--json.data`
— oversized `maxBucketSamples` rejected | + +Fixtures: generated via a small helper that writes synthetic JSONL into +`tmpdir`/`device-history/.jsonl` with controlled timestamps and +payloads (temperature, humidity, battery). No real API. + +## 9. Backward compatibility + +- **Zero breaking**. No field in any existing shape changes. +- `COMMAND_META` gains a row — additive. +- `tools/list` gains an entry — additive. Existing agents ignoring + unknown tools are unaffected. +- `schema export`'s `cliAddedFields` is unchanged; the aggregation + output is a new payload, not a field grafted into an old one. +- `.json` ring buffer, `.jsonl` rotation, `events mqtt-tail`, + `get_device_history`, `query_device_history` all untouched. + +## 10. Open questions (deferred) + +- Non-TTY markdown table for aggregation output — defer until + requested; MVP emits ASCII table or `--json`. +- Filtering by `topic` (e.g., aggregate only `ctl` events, not + `status`) — out of scope; users can pre-filter with + `history range --topic` if that flag gets added. +- Daily / rolling jobs that persist aggregations — out of scope; this + is an on-demand query layer, not a materialized view. + +## 11. Implementation checklist (handoff to writing-plans) + +1. `src/devices/history-agg.ts` — pure function + types (~150 LoC incl. JSDoc) +2. `src/commands/history.ts` — register `aggregate` subcommand (~60 LoC) +3. `src/commands/mcp.ts` — new `registerTool` (~40 LoC) +4. `src/commands/capabilities.ts` — add `history aggregate` to `COMMAND_META` (1 LoC) +5. `src/commands/capabilities.ts` — add `'aggregate_device_history'` to `MCP_TOOLS` (1 LoC) +6. Tests per §8 (~300 LoC across three files) +7. `CHANGELOG.md` — 2.5.0 entry (new section, new features) +8. `package.json` — version → `2.5.0` + +Estimated effort: ~700 LoC total (300 source + 300 test + doc/metadata). +Risk: low — purely additive, reuses existing streaming primitives, no +storage migration. diff --git a/docs/verbose-redaction.md b/docs/verbose-redaction.md new file mode 100644 index 00000000..42f7c959 --- /dev/null +++ b/docs/verbose-redaction.md @@ -0,0 +1,35 @@ +# Verbose header redaction + +When `--verbose` is on, the CLI logs request and response traces to stderr. +To prevent credential leakage, sensitive headers are mid-masked before printing: +the first 2 characters and last 2 characters are kept; everything in between is +replaced with asterisks (e.g. `Bearer my-secret-token` → `Be**************en`). + +## Masked headers + +The following header names trigger masking (case-insensitive unless noted): + +| Header | Notes | +|----------------|---------------------------------------------------------------| +| `authorization`| Standard HTTP auth header | +| `token` | SwitchBot API token header | +| `sign` | HMAC-SHA256 signature | +| `nonce` | Random nonce used in HMAC construction | +| `x-api-key` | Generic API key header | +| `cookie` | Session cookies | +| `set-cookie` | Server-set cookies | +| `x-auth-token` | Alternative auth token header | +| `t` | Timestamp (exact match); combined with `sign`, can replay HMAC| + +## Opt-out: `--trace-unsafe` + +Pass `--trace-unsafe` to any command to disable masking and print all headers +verbatim. A prominent one-time warning is printed to stderr when this flag is +active: + +``` +WARNING --trace-unsafe: sensitive headers will be printed UNMASKED. Do not share this output. +``` + +Use `--trace-unsafe` only in local debugging sessions. Never share the output +publicly — it contains credentials that can be replayed. diff --git a/package-lock.json b/package-lock.json index 4acaf086..e2d62225 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@switchbot/openapi-cli", - "version": "2.3.0", + "version": "2.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@switchbot/openapi-cli", - "version": "2.3.0", + "version": "2.5.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", diff --git a/package.json b/package.json index 43661af4..c6367f63 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@switchbot/openapi-cli", - "version": "2.4.0", + "version": "2.5.0", "description": "SwitchBot smart home CLI — control devices, run scenes, stream real-time events, and integrate AI agents via MCP. Full API v1.1 coverage.", "keywords": [ "switchbot", diff --git a/src/commands/agent-bootstrap.ts b/src/commands/agent-bootstrap.ts index e370419c..80f572c3 100644 --- a/src/commands/agent-bootstrap.ts +++ b/src/commands/agent-bootstrap.ts @@ -141,6 +141,9 @@ Examples: scope: cachedDevices.length > 0 ? 'used' : 'all', types: catalogTypes, }, + // hints: empty array means no hints to report; always emitted, never null. + // An empty array signals "nothing to act on" — agents should not treat + // it as a disabled or missing field. hints: cachedDevices.length === 0 ? ['Run `switchbot devices list` once to populate the device cache for richer bootstrap output.'] : [], diff --git a/src/commands/batch.ts b/src/commands/batch.ts index c4be7f19..8ecfcdb2 100644 --- a/src/commands/batch.ts +++ b/src/commands/batch.ts @@ -146,7 +146,7 @@ export function registerBatchCommand(devices: Command): void { .option('--yes', 'Allow destructive commands (Smart Lock unlock, garage open, ...)') .option('--type ', '"command" (default) or "customize" for user-defined IR buttons', enumArg('--type', COMMAND_TYPES), 'command') .option('--stdin', 'Read deviceIds from stdin, one per line (same as trailing "-")') - .option('--idempotency-key-prefix ', 'Prefix for idempotency keys (key per device: -)', stringArg('--idempotency-key-prefix')) + .option('--idempotency-key-prefix ', 'Client-supplied prefix for idempotency keys (key per device: -). process-local 60s window; cache is per Node process (MCP session, batch run, plan run). Independent CLI invocations do not share cache.', stringArg('--idempotency-key-prefix')) .addHelpText('after', ` Targets are resolved in this priority order: 1. --ids when present (explicit deviceIds) diff --git a/src/commands/cache.ts b/src/commands/cache.ts index 40f26619..7bf65c3c 100644 --- a/src/commands/cache.ts +++ b/src/commands/cache.ts @@ -51,6 +51,7 @@ Examples: cache .command('show') + .alias('status') .description('Summarize the cache files (paths, ages, entry counts)') .action(() => { const summary = describeCache(); diff --git a/src/commands/capabilities.ts b/src/commands/capabilities.ts index 1af1f31c..d49e7b60 100644 --- a/src/commands/capabilities.ts +++ b/src/commands/capabilities.ts @@ -46,6 +46,7 @@ const COMMAND_META: Record = { // scenes 'scenes list': { mutating: false, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 500 }, 'scenes execute': { mutating: true, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'action', verifiability: 'deviceDependent', typicalLatencyMs: 1500 }, + 'scenes describe': { mutating: false, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 500 }, // webhook 'webhook setup': { mutating: true, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'action', verifiability: 'local', typicalLatencyMs: 500 }, 'webhook query': { mutating: false, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 500 }, @@ -69,6 +70,7 @@ const COMMAND_META: Record = { 'history replay': { mutating: true, consumesQuota: true, idempotencySupported: true, agentSafetyTier: 'action', verifiability: 'deviceDependent', typicalLatencyMs: 1000 }, 'history range': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 50 }, 'history stats': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 20 }, + 'history aggregate': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 80 }, 'plan run': { mutating: true, consumesQuota: true, idempotencySupported: true, agentSafetyTier: 'action', verifiability: 'deviceDependent', typicalLatencyMs: 2000 }, 'plan validate': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 10 }, 'plan schema': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 10 }, @@ -109,6 +111,7 @@ const MCP_TOOLS = [ 'account_overview', 'get_device_history', 'query_device_history', + 'aggregate_device_history', ]; const IDEMPOTENCY_CONTRACT = { diff --git a/src/commands/devices.ts b/src/commands/devices.ts index 9cfd2de5..76a672d5 100644 --- a/src/commands/devices.ts +++ b/src/commands/devices.ts @@ -324,7 +324,7 @@ Examples: .option('--name-room ', 'Narrow --name by room name (substring match)', stringArg('--name-room')) .option('--type ', 'Command type: "command" for built-in commands (default), "customize" for user-defined IR buttons', enumArg('--type', COMMAND_TYPES), 'command') .option('--yes', 'Confirm a destructive command (Smart Lock unlock, Garage open, …). --dry-run is always allowed without --yes.') - .option('--idempotency-key ', 'Idempotency key for deduplication (60s window; same key replays cached result)', stringArg('--idempotency-key')) + .option('--idempotency-key ', 'Client-supplied key to dedupe retries. process-local 60s window; cache is per Node process (MCP session, batch run, plan run). Independent CLI invocations do not share cache.', stringArg('--idempotency-key')) .addHelpText('after', ` ──────────────────────────────────────────────────────────────────────── For the full list of commands a specific device supports — and their diff --git a/src/commands/events.ts b/src/commands/events.ts index 95fe167a..46913d09 100644 --- a/src/commands/events.ts +++ b/src/commands/events.ts @@ -435,6 +435,12 @@ Examples: } else { console.log(JSON.stringify(ctl)); } + // Persist to __control.jsonl — best-effort, never blocks the stream. + try { + deviceHistoryStore.recordControl(ctl); + } catch { + // swallow + } }; const unsubState = client.onStateChange((state) => { if (!isJsonMode()) { diff --git a/src/commands/history.ts b/src/commands/history.ts index 9588e576..88066952 100644 --- a/src/commands/history.ts +++ b/src/commands/history.ts @@ -10,6 +10,12 @@ import { queryDeviceHistoryStats, type HistoryRecord, } from '../devices/history-query.js'; +import { + aggregateDeviceHistory, + ALL_AGG_FNS, + type AggFn, + type AggOptions, +} from '../devices/history-agg.js'; const DEFAULT_AUDIT = path.join(os.homedir(), '.switchbot', 'audit.log'); @@ -204,8 +210,8 @@ Examples: .option('--file ', `Path to the audit log (default ${DEFAULT_AUDIT})`, stringArg('--file')) .addHelpText('after', ` See docs/audit-log.md for the audit log format. Exit code: - 0 every line parses and carries the current auditVersion - 1 one or more lines are malformed OR the file is missing + 0 every line parses and carries the current auditVersion, or file is missing (warn) + 1 one or more lines are malformed or schema drift detected 2 (usage) — not emitted by this subcommand Examples: @@ -215,27 +221,142 @@ Examples: .action((options: { file?: string }) => { const file = options.file ?? DEFAULT_AUDIT; const report = verifyAudit(file); + + // Determine status and exit code + let status: 'ok' | 'warn' | 'fail' = 'ok'; + let exitCode = 0; + + if (report.fileMissing) { + status = 'warn'; + } else if (report.malformedLines > 0 || report.unversionedEntries > 0) { + status = 'fail'; + exitCode = 1; + } + if (isJsonMode()) { - printJson(report); + const output = { + status, + fileMissing: report.fileMissing === true, + parsed: report.parsedLines, + malformed: report.malformedLines, + unversioned: report.unversionedEntries, + message: report.fileMissing + ? 'Audit log file not found (fresh install)' + : report.malformedLines > 0 || report.unversionedEntries > 0 + ? 'Audit log has malformed or unversioned entries' + : 'Audit log is valid', + }; + printJson(output); } else { - console.log(`Audit log: ${report.file}`); - console.log(`Parsed lines: ${report.parsedLines} / ${report.totalLines}`); - console.log(`Malformed: ${report.malformedLines}`); - console.log(`Unversioned: ${report.unversionedEntries}`); - const versions = Object.entries(report.versionCounts) - .map(([v, n]) => `${v}:${n}`) - .join(', '); - console.log(`Version counts: ${versions || '—'}`); - if (report.earliest) console.log(`Earliest: ${report.earliest}`); - if (report.latest) console.log(`Latest: ${report.latest}`); - if (report.problems.length > 0) { - console.log('\nProblems:'); - for (const p of report.problems) { - console.log(` line ${p.line}: ${p.reason}${p.preview ? ` — "${p.preview}"` : ''}`); + if (report.fileMissing) { + console.log(`Audit log: ${report.file} (missing — fresh install)`); + console.log(`Status: ✓ warn (expected for new accounts)`); + } else { + console.log(`Audit log: ${report.file}`); + console.log(`Parsed lines: ${report.parsedLines} / ${report.totalLines}`); + console.log(`Malformed: ${report.malformedLines}`); + console.log(`Unversioned: ${report.unversionedEntries}`); + const versions = Object.entries(report.versionCounts) + .map(([v, n]) => `${v}:${n}`) + .join(', '); + console.log(`Version counts: ${versions || '—'}`); + if (report.earliest) console.log(`Earliest: ${report.earliest}`); + if (report.latest) console.log(`Latest: ${report.latest}`); + if (report.problems.length > 0) { + console.log('\nProblems:'); + for (const p of report.problems) { + console.log(` line ${p.line}: ${p.reason}${p.preview ? ` — "${p.preview}"` : ''}`); + } } } } - const ok = report.malformedLines === 0 && report.problems.length === 0; - process.exit(ok ? 0 : 1); + process.exit(exitCode); + }); + + history + .command('aggregate') + .description('Aggregate time-ranged device history metrics into buckets') + .argument('', 'Device ID to aggregate') + .option('--since ', 'Relative window ending now, e.g. "1h", "7d" (mutually exclusive with --from/--to)', stringArg('--since')) + .option('--from ', 'Range start (ISO-8601)', stringArg('--from')) + .option('--to ', 'Range end (ISO-8601)', stringArg('--to')) + .option('--metric ', 'Payload field to aggregate (repeat for multiple)', (v: string, acc: string[] = []) => acc.concat(v), [] as string[]) + .option('--agg ', 'Comma-separated aggregation functions (count,min,max,avg,sum,p50,p95)', stringArg('--agg')) + .option('--bucket ', 'Bucket width, e.g. "15m", "1h", "1d"', stringArg('--bucket')) + .option('--max-bucket-samples ', 'Max samples per bucket for quantiles (1–100000)', intArg('--max-bucket-samples', { min: 1, max: 100_000 })) + .action(async ( + deviceId: string, + options: { since?: string; from?: string; to?: string; metric?: string[]; agg?: string; bucket?: string; maxBucketSamples?: string }, + ) => { + const metrics: string[] = options.metric ?? []; + if (metrics.length === 0) { + handleError(new UsageError('at least one --metric is required.')); + } + + if (options.since && (options.from || options.to)) { + handleError(new UsageError('--since is mutually exclusive with --from/--to.')); + } + + let aggs: AggFn[] | undefined; + if (options.agg !== undefined) { + const parts = options.agg.split(',').map((s) => s.trim()).filter(Boolean); + const unknown = parts.filter((p) => !(ALL_AGG_FNS as readonly string[]).includes(p)); + if (unknown.length > 0) { + handleError(new UsageError( + `Unknown aggregation function(s): ${unknown.join(', ')}. Legal values: ${ALL_AGG_FNS.join(', ')}.`, + )); + } + aggs = parts as AggFn[]; + } + + const aggOpts: AggOptions = { + metrics, + aggs, + since: options.since, + from: options.from, + to: options.to, + bucket: options.bucket, + maxBucketSamples: options.maxBucketSamples !== undefined ? Number(options.maxBucketSamples) : undefined, + }; + + try { + const res = await aggregateDeviceHistory(deviceId, aggOpts); + + if (isJsonMode()) { + printJson(res); + return; + } + + if (res.buckets.length === 0) { + console.log(`(no history records for ${deviceId} in requested range)`); + return; + } + + const aggCols = res.aggs; + const cols = ['t', ...res.metrics.flatMap((m) => aggCols.map((a) => `${m}.${a}`))]; + console.log(cols.join('\t')); + for (const bkt of res.buckets) { + const row = cols.map((col) => { + if (col === 't') return bkt.t; + const [metric, agg] = col.split('.'); + const val = (bkt.metrics[metric] as Record | undefined)?.[agg]; + return val !== undefined ? String(val) : '\u2014'; + }); + console.log(row.join('\t')); + } + + if (res.partial) { + for (const note of res.notes) { + console.error('note: ' + note); + } + } + } catch (err) { + if (err instanceof Error) { + if (/bucket/i.test(err.message) || /--since/i.test(err.message) || /--from/i.test(err.message) || /--to/i.test(err.message)) { + handleError(new UsageError(err.message)); + } + } + handleError(err); + } }); } diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index dd5a540e..1c32cb2d 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -5,6 +5,7 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/ import { z } from 'zod'; import { intArg, stringArg } from '../utils/arg-parsers.js'; import { handleError, isJsonMode } from '../utils/output.js'; +import { VERSION } from '../version.js'; import { fetchDeviceList, fetchDeviceStatus, @@ -25,6 +26,13 @@ import { getCachedDevice } from '../devices/cache.js'; import { EventSubscriptionManager } from '../mcp/events-subscription.js'; import { deviceHistoryStore } from '../mcp/device-history.js'; import { queryDeviceHistory } from '../devices/history-query.js'; +import { + aggregateDeviceHistory, + ALL_AGG_FNS, + MAX_SAMPLE_CAP, + type AggFn, + type AggOptions, +} from '../devices/history-agg.js'; import { todayUsage } from '../utils/quota.js'; import { describeCache } from '../devices/cache.js'; import { withRequestContext } from '../lib/request-context.js'; @@ -59,7 +67,7 @@ export function createSwitchBotMcpServer(options?: { eventManager?: EventSubscri const server = new McpServer( { name: 'switchbot', - version: '2.0.0', + version: VERSION, }, { capabilities: { tools: {}, resources: {} }, @@ -93,7 +101,8 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`, title: 'List all devices on the account', description: 'Fetch the complete inventory of physical devices and IR remotes on this SwitchBot account. Refreshes the local metadata cache and groups devices by type. Use this as the bootstrap call to discover available deviceIds. Devices without enableCloudService cannot receive commands via API. IR remotes depend on a Hub for connectivity.', - inputSchema: {}, + _meta: { agentSafetyTier: 'read' }, + inputSchema: z.object({}).strict(), outputSchema: { deviceList: z.array(z.object({ deviceId: z.string(), @@ -134,9 +143,10 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`, title: 'Get live status for a device', description: 'Query the real-time status payload for a physical device. IR remotes have no status channel and will error.', - inputSchema: { + _meta: { agentSafetyTier: 'read' }, + inputSchema: z.object({ deviceId: z.string().describe('Device ID from list_devices'), - }, + }).strict(), outputSchema: { status: z.object({ deviceId: z.string().optional(), @@ -164,10 +174,11 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`, '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.', - inputSchema: { + _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)'), - }, + }).strict(), outputSchema: { deviceId: z.string().optional(), latest: z.unknown().optional(), @@ -204,14 +215,15 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`, '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".', - inputSchema: { + _meta: { agentSafetyTier: 'read' }, + inputSchema: z.object({ deviceId: z.string().describe('Device ID to query'), since: z.string().optional().describe('Relative window ending now, e.g. "30s", "15m", "1h", "7d". Mutually exclusive with from/to.'), from: z.string().optional().describe('Range start (ISO-8601).'), to: z.string().optional().describe('Range end (ISO-8601).'), fields: z.array(z.string()).optional().describe('Project these payload fields; omit for the full payload.'), limit: z.number().int().min(1).max(10000).optional().describe('Max records to return (default 1000).'), - }, + }).strict(), outputSchema: { deviceId: z.string(), count: z.number().int(), @@ -248,7 +260,8 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`, title: 'Send a control command to a device', description: 'Execute a control command on a device (turnOn, setColor, startClean, unlock, openDoor, createKey, etc.). Destructive commands (Smart Lock unlock, Garage Door open, Keypad createKey/deleteKey) require confirm:true to proceed; otherwise rejected. Commands are validated offline against the device catalog. Use idempotencyKey to safely deduplicate retries within 60 seconds.', - inputSchema: { + _meta: { agentSafetyTier: 'action' }, + inputSchema: z.object({ deviceId: z.string().describe('Device ID from list_devices'), command: z.string().describe('Command name, case-sensitive (e.g. turnOn, setColor, unlock)'), parameter: z @@ -271,12 +284,16 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`, .describe( 'Deduplication key — repeat calls with the same key within 60s replay the first result (adds replayed:true). Same key + different (command, parameter) within 60s returns an idempotency_conflict guard error.', ), - }, + dryRun: z + .boolean() + .optional() + .describe('When true, do not call the API — return { ok:true, dryRun:true, wouldSend:{...} } instead.'), + }).strict(), outputSchema: { ok: z.literal(true), - command: z.string(), - deviceId: z.string(), - result: z.unknown().describe('API response body from SwitchBot'), + command: z.string().optional(), + deviceId: z.string().optional(), + result: z.unknown().optional().describe('API response body from SwitchBot (absent on dryRun)'), verification: z .object({ verifiable: z.boolean(), @@ -287,11 +304,33 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`, .describe( 'Present when the target is an IR device. IR is unidirectional — agents should treat the success as "signal sent" not "state changed".', ), + dryRun: z.literal(true).optional().describe('Present when dryRun:true was requested'), + wouldSend: z.object({ + deviceId: z.string(), + command: z.string(), + parameter: z.unknown(), + commandType: z.string(), + }).optional().describe('The request shape that would have been POSTed (present when dryRun:true)'), }, }, - async ({ deviceId, command, parameter, commandType, confirm, idempotencyKey }) => { + async ({ deviceId, command, parameter, commandType, confirm, idempotencyKey, dryRun }) => { const effectiveType = commandType ?? 'command'; + // dryRun early-return — no API call, no validation against live device list + if (dryRun) { + const wouldSend = { + deviceId, + command, + parameter: parameter ?? 'default', + commandType: effectiveType, + }; + const structured = { ok: true as const, dryRun: true as const, wouldSend }; + return { + content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }], + structuredContent: structured, + }; + } + // Resolve the device's catalog type via cache or a fresh lookup so we // can evaluate destructive/validation without an extra round-trip if // the cache is warm. @@ -395,15 +434,32 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`, { title: 'Execute a manual scene', description: 'Execute a manual SwitchBot scene by its sceneId (from list_scenes).', - inputSchema: { + _meta: { agentSafetyTier: 'action' }, + inputSchema: z.object({ sceneId: z.string().describe('Scene ID from list_scenes'), - }, + dryRun: z + .boolean() + .optional() + .describe('When true, do not call the API — return { ok:true, dryRun:true, wouldSend:{...} } instead.'), + }).strict(), outputSchema: { ok: z.literal(true), - sceneId: z.string(), + sceneId: z.string().optional(), + dryRun: z.literal(true).optional().describe('Present when dryRun:true was requested'), + wouldSend: z.object({ + sceneId: z.string(), + }).optional().describe('The request shape that would have been POSTed (present when dryRun:true)'), }, }, - async ({ sceneId }) => { + async ({ sceneId, dryRun }) => { + if (dryRun) { + const wouldSend = { sceneId }; + const structured = { ok: true as const, dryRun: true as const, wouldSend }; + return { + content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }], + structuredContent: structured, + }; + } await executeScene(sceneId); const structured = { ok: true as const, sceneId }; return { @@ -419,7 +475,8 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`, { title: 'List all manual scenes', description: 'Fetch all manual scenes configured in the SwitchBot app.', - inputSchema: {}, + _meta: { agentSafetyTier: 'read' }, + inputSchema: z.object({}).strict(), outputSchema: { scenes: z.array(z.object({ sceneId: z.string(), sceneName: z.string() })), }, @@ -440,10 +497,11 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`, title: 'Search the offline device catalog', description: 'Search the built-in device catalog by type name or alias. Returns matching entries with their commands, roles, destructive flags, and status fields. No API call.', - inputSchema: { + _meta: { agentSafetyTier: 'read' }, + inputSchema: z.object({ query: z.string().describe('Search query (matches type and aliases, case-insensitive). Use empty string to list all.'), limit: z.number().int().min(1).max(100).optional().default(20).describe('Max entries returned (default 20)'), - }, + }).strict(), outputSchema: { results: z.array(z.object({ type: z.string(), @@ -481,10 +539,11 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`, title: 'Describe a specific device', description: 'Resolve a deviceId to its metadata + catalog entry + suggested safe actions. Pass live:true to also fetch real-time status values.', - inputSchema: { + _meta: { agentSafetyTier: 'read' }, + inputSchema: z.object({ deviceId: z.string().describe('Device ID from list_devices'), live: z.boolean().optional().default(false).describe('Also fetch live /status values (costs 1 extra API call)'), - }, + }).strict(), outputSchema: { device: z.object({ device: z.object({ deviceId: z.string(), deviceName: z.string() }).passthrough(), @@ -524,6 +583,45 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`, } ); + // ---- 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), + since: z.string().optional(), + from: z.string().optional(), + to: z.string().optional(), + metrics: z.array(z.string().min(1)).min(1), + aggs: z.array(z.enum(ALL_AGG_FNS as unknown as [AggFn, ...AggFn[]])).optional(), + bucket: z.string().optional(), + maxBucketSamples: z.number().int().positive().max(MAX_SAMPLE_CAP).optional(), + }) + .strict(), + }, + 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); + return { + content: [{ type: 'text', text: JSON.stringify(res, null, 2) }], + structuredContent: res as unknown as Record, + }; + }, + ); + // ---- account_overview --------------------------------------------------- server.registerTool( 'account_overview', @@ -531,7 +629,8 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`, title: 'Bootstrap account overview', description: 'Get a complete account snapshot: devices, scenes, quota usage, cache status, and MQTT connection state. Use this for cold-start initialization or periodic health checks.', - inputSchema: {}, + _meta: { agentSafetyTier: 'read' }, + inputSchema: z.object({}).strict(), outputSchema: { version: z.string(), schemaVersion: z.string(), @@ -584,7 +683,7 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`, const quota = todayUsage(); const overview = { - version: '2.0.0', + version: VERSION, schemaVersion: '1.1', devices: deviceList.deviceList.map(toMcpDeviceListShape), infraredRemotes: deviceList.infraredRemoteList.map(toMcpIrDeviceShape), @@ -656,15 +755,18 @@ 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 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) - - list_scenes list all manual scenes - - run_scene execute a manual scene - - 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 +The MCP server exposes eleven 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) + - list_scenes list all manual scenes + - run_scene execute a manual scene + - 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 Resource (read-only): - switchbot://events snapshot of recent MQTT shadow events from the ring buffer @@ -775,7 +877,7 @@ Inspect locally: res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: true, - version: '2.0.0', + version: VERSION, pid: process.pid, uptimeSec: Math.floor(process.uptime()), })); @@ -786,7 +888,7 @@ Inspect locally: const state = eventManager.getState(); const ready = state !== 'failed' && state !== 'disabled'; const status = ready ? 200 : 503; - const body: Record = { ready, version: '2.0.0', mqtt: state }; + const body: Record = { ready, version: VERSION, mqtt: state }; if (!ready) body.reason = state === 'disabled' ? 'mqtt disabled' : 'mqtt failed'; res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(body)); diff --git a/src/commands/plan.ts b/src/commands/plan.ts index 79e0ba86..5621b51f 100644 --- a/src/commands/plan.ts +++ b/src/commands/plan.ts @@ -240,7 +240,13 @@ Workflow: .command('schema') .description('Print the JSON Schema for the plan format') .action(() => { - printJson(PLAN_JSON_SCHEMA); + printJson({ + ...PLAN_JSON_SCHEMA, + agentNotes: { + deviceNameStrategy: + "Plan step `deviceName` fields are resolved with the `require-unique` strategy (same default as `devices command`). Plans that expect a specific device should pin `deviceId` instead.", + }, + }); }); plan diff --git a/src/commands/scenes.ts b/src/commands/scenes.ts index 38b6130b..47bf6f74 100644 --- a/src/commands/scenes.ts +++ b/src/commands/scenes.ts @@ -1,5 +1,5 @@ import { Command } from 'commander'; -import { printJson, isJsonMode, handleError } from '../utils/output.js'; +import { printJson, isJsonMode, handleError, StructuredUsageError } from '../utils/output.js'; import { resolveFormat, resolveFields, renderRows } from '../utils/format.js'; import { fetchScenes, executeScene } from '../lib/scenes.js'; @@ -68,4 +68,45 @@ Example: handleError(error); } }); + + // switchbot scenes describe + scenes + .command('describe') + .description('Show metadata for a scene by its ID (SwitchBot API v1.1 does not expose step detail)') + .argument('', 'Scene ID from "scenes list"') + .addHelpText('after', ` +Note: SwitchBot API v1.1 does not return scene step detail. Only the scene name is available. + +Example: + $ switchbot scenes describe T12345678 +`) + .action(async (sceneId: string) => { + try { + const sceneList = await fetchScenes(); + const found = sceneList.find((s) => s.sceneId === sceneId); + if (!found) { + throw new StructuredUsageError(`scene not found: ${sceneId}`, { + error: 'scene_not_found', + sceneId, + candidates: sceneList.map((s) => ({ sceneId: s.sceneId, sceneName: s.sceneName })), + }); + } + const result = { + sceneId: found.sceneId, + sceneName: found.sceneName, + stepCount: null, + note: 'SwitchBot API v1.1 does not expose scene steps — displayed name only', + }; + if (isJsonMode()) { + printJson(result); + } else { + console.log(`sceneId: ${result.sceneId}`); + console.log(`sceneName: ${result.sceneName}`); + console.log(`stepCount: (not available)`); + console.log(`note: ${result.note}`); + } + } catch (error) { + handleError(error); + } + }); } diff --git a/src/commands/schema.ts b/src/commands/schema.ts index 0a0d12d0..d3f0d43b 100644 --- a/src/commands/schema.ts +++ b/src/commands/schema.ts @@ -212,6 +212,13 @@ Examples: description: 'CLI-synthesized receipt-acknowledgment metadata. For IR devices, verifiable:false signals that no device-side confirmation is possible.', }, + { + field: 'hints', + appliesTo: ['agent-bootstrap'], + type: 'string[]', + description: + 'CLI-synthesized advisory messages for the calling agent. Always emitted; empty array ([]) means no hints to report — never null and not a disabled-field signal.', + }, ]; } printJson(payload); diff --git a/src/devices/history-agg.ts b/src/devices/history-agg.ts new file mode 100644 index 00000000..98eb8113 --- /dev/null +++ b/src/devices/history-agg.ts @@ -0,0 +1,190 @@ +import fs from 'node:fs'; +import readline from 'node:readline'; +import type { QueryOptions, HistoryRecord } from './history-query.js'; +import { jsonlFilesForDevice, parseDurationToMs, resolveRange } from './history-query.js'; + +export type AggFn = 'count' | 'min' | 'max' | 'avg' | 'sum' | 'p50' | 'p95'; + +export const ALL_AGG_FNS: readonly AggFn[] = ['count', 'min', 'max', 'avg', 'sum', 'p50', 'p95']; +export const DEFAULT_AGGS: readonly AggFn[] = ['count', 'avg']; +export const DEFAULT_SAMPLE_CAP = 10_000; +export const MAX_SAMPLE_CAP = 100_000; + +export interface AggOptions extends QueryOptions { + metrics: string[]; + aggs?: AggFn[]; + bucket?: string; + maxBucketSamples?: number; +} + +export interface BucketMetricResult { + count?: number; + min?: number; + max?: number; + avg?: number; + sum?: number; + p50?: number; + p95?: number; +} + +export interface AggBucket { + t: string; + metrics: Record; +} + +export interface AggResult { + deviceId: string; + bucket?: string; + from: string; + to: string; + metrics: string[]; + aggs: AggFn[]; + buckets: AggBucket[]; + partial: boolean; + notes: string[]; +} + +interface Acc { + min: number; + max: number; + sum: number; + count: number; + samples: number[] | null; + sampleCapHit: boolean; +} + +export async function aggregateDeviceHistory( + deviceId: string, + opts: AggOptions, +): Promise { + const { fromMs, toMs } = resolveRange(opts); + const aggs: AggFn[] = (opts.aggs && opts.aggs.length > 0) ? opts.aggs : [...DEFAULT_AGGS]; + const needQuantile = aggs.includes('p50') || aggs.includes('p95'); + + let bucketMs: number | null = null; + if (opts.bucket !== undefined) { + bucketMs = parseDurationToMs(opts.bucket); + if (bucketMs === null) { + throw new Error(`Invalid --bucket "${opts.bucket}". Expected e.g. "15m", "1h", "1d".`); + } + } + + const sampleCap = Math.max( + 1, + Math.min(opts.maxBucketSamples ?? DEFAULT_SAMPLE_CAP, MAX_SAMPLE_CAP), + ); + let partial = false; + const notes: string[] = []; + + // bucketKey (epoch ms; 0 when no --bucket) → metric name → Acc + const buckets = new Map>(); + + for (const file of jsonlFilesForDevice(deviceId)) { + try { + const st = fs.statSync(file); + if (st.mtimeMs < fromMs) continue; + } catch { + continue; + } + const stream = fs.createReadStream(file, { encoding: 'utf-8' }); + const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); + for await (const line of rl) { + if (!line) continue; + let rec: HistoryRecord; + try { rec = JSON.parse(line) as HistoryRecord; } catch { continue; } + const tMs = Date.parse(rec.t); + if (!Number.isFinite(tMs) || tMs < fromMs || tMs > toMs) continue; + + const key = bucketMs !== null ? Math.floor(tMs / bucketMs) * bucketMs : 0; + let bkt = buckets.get(key); + if (!bkt) { bkt = new Map(); buckets.set(key, bkt); } + + for (const metric of opts.metrics) { + const v = (rec.payload as Record | null | undefined)?.[metric]; + if (typeof v !== 'number' || !Number.isFinite(v)) continue; + let acc = bkt.get(metric); + if (!acc) { + acc = { + min: v, + max: v, + sum: 0, + count: 0, + samples: needQuantile ? [] : null, + sampleCapHit: false, + }; + bkt.set(metric, acc); + } + acc.min = Math.min(acc.min, v); + acc.max = Math.max(acc.max, v); + acc.sum += v; + acc.count += 1; + if (acc.samples) { + if (acc.samples.length < sampleCap) { + acc.samples.push(v); + } else if (!acc.sampleCapHit) { + acc.sampleCapHit = true; + partial = true; + notes.push( + `bucket ${new Date(key).toISOString()} metric ${metric}: sample cap ${sampleCap} reached, quantiles approximate`, + ); + } + } + } + } + } + + return finalize(deviceId, opts, aggs, buckets, partial, notes, fromMs, toMs); +} + +function finalize( + deviceId: string, + opts: AggOptions, + aggs: AggFn[], + buckets: Map>, + partial: boolean, + notes: string[], + fromMs: number, + toMs: number, +): AggResult { + const fromIso = Number.isFinite(fromMs) ? new Date(fromMs).toISOString() : new Date(0).toISOString(); + const toIso = Number.isFinite(toMs) ? new Date(toMs).toISOString() : new Date(Date.now()).toISOString(); + + const keys = [...buckets.keys()].sort((a, b) => a - b); + const outBuckets: AggBucket[] = []; + for (const key of keys) { + const perMetric = buckets.get(key)!; + const metricsOut: Record = {}; + for (const [metric, acc] of perMetric.entries()) { + if (acc.count === 0) continue; + const r: BucketMetricResult = {}; + if (aggs.includes('count')) r.count = acc.count; + if (aggs.includes('min')) r.min = acc.min; + if (aggs.includes('max')) r.max = acc.max; + if (aggs.includes('avg')) r.avg = acc.sum / acc.count; + if (aggs.includes('sum')) r.sum = acc.sum; + if ((aggs.includes('p50') || aggs.includes('p95')) && acc.samples) { + const sorted = [...acc.samples].sort((a, b) => a - b); + if (aggs.includes('p50')) r.p50 = sorted[Math.floor(0.5 * (sorted.length - 1))]; + if (aggs.includes('p95')) r.p95 = sorted[Math.floor(0.95 * (sorted.length - 1))]; + } + metricsOut[metric] = r; + } + if (Object.keys(metricsOut).length === 0) continue; + outBuckets.push({ + t: new Date(key).toISOString(), + metrics: metricsOut, + }); + } + + return { + deviceId, + bucket: opts.bucket, + from: fromIso, + to: toIso, + metrics: [...opts.metrics], + aggs: [...aggs], + buckets: outBuckets, + partial, + notes, + }; +} diff --git a/src/devices/history-query.ts b/src/devices/history-query.ts index d1cc75cf..653e9e1f 100644 --- a/src/devices/history-query.ts +++ b/src/devices/history-query.ts @@ -54,7 +54,7 @@ export function parseInstantToMs(spec: string): number | null { return Number.isFinite(ms) ? ms : null; } -function resolveRange(opts: QueryOptions): { fromMs: number; toMs: number } { +export function resolveRange(opts: QueryOptions): { fromMs: number; toMs: number } { let fromMs = Number.NEGATIVE_INFINITY; let toMs = Number.POSITIVE_INFINITY; diff --git a/src/index.ts b/src/index.ts index d6fe24e1..066b9fec 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node import { Command, CommanderError, InvalidArgumentError } from 'commander'; import { createRequire } from 'node:module'; +import chalk from 'chalk'; import { intArg, stringArg, enumArg } from './utils/arg-parsers.js'; import { parseDurationToMs } from './utils/flags.js'; import { registerConfigCommand } from './commands/config.js'; @@ -23,6 +24,12 @@ import { registerAgentBootstrapCommand } from './commands/agent-bootstrap.js'; const require = createRequire(import.meta.url); const { version: pkgVersion } = require('../package.json') as { version: string }; +// Early initialization: check for --no-color flag or NO_COLOR env var and disable chalk. +// This must happen before any commands run so all chalk output is affected. +if (process.argv.includes('--no-color') || Boolean(process.env.NO_COLOR)) { + chalk.level = 0; +} + const program = new Command(); // Top-level subcommand names. Used by stringArg to produce clearer errors when @@ -51,6 +58,7 @@ program .name('switchbot') .description('Command-line tool for SwitchBot API v1.1') .version(pkgVersion) + .option('--no-color', 'Disable ANSI colors in output') .option('--json', 'Output raw JSON response (disables tables; useful for pipes/scripts)') .option('--format ', 'Output format: table (default), json, jsonl, tsv, yaml, id, markdown', enumArg('--format', ['table', 'json', 'jsonl', 'tsv', 'yaml', 'id', 'markdown'])) .option('--fields ', 'Comma-separated list of columns to include (e.g. --fields=id,name,type)', stringArg('--fields', { disallow: TOP_LEVEL_COMMANDS })) diff --git a/src/mcp/device-history.ts b/src/mcp/device-history.ts index 63e9597c..ca943501 100644 --- a/src/mcp/device-history.ts +++ b/src/mcp/device-history.ts @@ -9,6 +9,12 @@ export interface HistoryEntry { payload: unknown; } +export interface ControlEvent { + type: '__connect' | '__reconnect' | '__disconnect' | '__heartbeat'; + at: string; + eventId: string; +} + export interface DeviceHistory { latest: HistoryEntry | null; history: HistoryEntry[]; @@ -50,20 +56,30 @@ export class DeviceHistoryStore { fs.writeFileSync(file, JSON.stringify(existing, null, 2), { mode: 0o600 }); // 2. Append-only JSONL for range queries. - this.appendJsonl(deviceId, entry); + this.writeJsonl(deviceId, entry); } catch { // best-effort — history loss is non-fatal } } - private appendJsonl(deviceId: string, entry: HistoryEntry): void { + /** Append a mqtt control event (no deviceId) to the dedicated __control.jsonl file. */ + recordControl(event: ControlEvent): void { + try { + if (!fs.existsSync(this.dir)) fs.mkdirSync(this.dir, { recursive: true }); + this.writeJsonl('__control', event); + } catch { + // best-effort — never block the event stream + } + } + + private writeJsonl(fileKey: string, record: unknown): void { try { - const jsonlPath = path.join(this.dir, `${deviceId}.jsonl`); - const line = JSON.stringify(entry) + '\n'; + const jsonlPath = path.join(this.dir, `${fileKey}.jsonl`); + const line = JSON.stringify(record) + '\n'; const lineBytes = Buffer.byteLength(line, 'utf-8'); // Seed size counter from disk on first touch (avoids drift across restarts). - let size = this.jsonlSizes.get(deviceId); + let size = this.jsonlSizes.get(fileKey); if (size === undefined) { try { size = fs.existsSync(jsonlPath) ? fs.statSync(jsonlPath).size : 0; @@ -73,19 +89,19 @@ export class DeviceHistoryStore { } if (size + lineBytes > JSONL_ROTATE_BYTES) { - this.rotateJsonl(deviceId); + this.rotateJsonl(fileKey); size = 0; } fs.appendFileSync(jsonlPath, line, { mode: 0o600 }); - this.jsonlSizes.set(deviceId, size + lineBytes); + this.jsonlSizes.set(fileKey, size + lineBytes); } catch { // best-effort } } - private rotateJsonl(deviceId: string): void { - const base = path.join(this.dir, `${deviceId}.jsonl`); + private rotateJsonl(fileKey: string): void { + const base = path.join(this.dir, `${fileKey}.jsonl`); // .jsonl.3 is dropped; .2 → .3, .1 → .2, current → .1 try { const oldest = `${base}.${JSONL_KEEP_ROTATIONS}`; diff --git a/src/utils/audit.ts b/src/utils/audit.ts index ad8dd79f..f1e57236 100644 --- a/src/utils/audit.ts +++ b/src/utils/audit.ts @@ -72,6 +72,7 @@ export interface VerifyReport { problems: Array<{ line: number; reason: string; preview?: string }>; earliest?: string; latest?: string; + fileMissing?: boolean; } export function verifyAudit(file: string): VerifyReport { @@ -86,7 +87,7 @@ export function verifyAudit(file: string): VerifyReport { problems: [], }; if (!fs.existsSync(file)) { - report.problems.push({ line: 0, reason: 'audit log file does not exist' }); + report.fileMissing = true; return report; } const raw = fs.readFileSync(file, 'utf-8'); diff --git a/src/utils/format.ts b/src/utils/format.ts index 0edafcf5..ff2e6e36 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -2,7 +2,7 @@ import { printTable, printJson, isJsonMode, UsageError } from './output.js'; import { getFormat, getFields } from './flags.js'; import { dump as yamlDump } from 'js-yaml'; -export type OutputFormat = 'table' | 'json' | 'jsonl' | 'tsv' | 'yaml' | 'id'; +export type OutputFormat = 'table' | 'json' | 'jsonl' | 'tsv' | 'yaml' | 'id' | 'markdown'; export function parseFormat(flag: string | undefined): OutputFormat { if (!flag) return 'table'; @@ -14,8 +14,9 @@ export function parseFormat(flag: string | undefined): OutputFormat { case 'tsv': return 'tsv'; case 'yaml': return 'yaml'; case 'id': return 'id'; + case 'markdown': return 'markdown'; default: { - const msg = `Unknown --format "${flag}". Expected: table, json, jsonl, tsv, yaml, id.`; + const msg = `Unknown --format "${flag}". Expected: table, json, jsonl, tsv, yaml, id, markdown.`; if (isJsonMode()) { console.error(JSON.stringify({ error: { code: 2, kind: 'usage', message: msg } })); } else { @@ -83,6 +84,14 @@ export function renderRows( const h = filtered.headers; const r = filtered.rows; + // Markdown format is rendered as table with markdown style forced regardless + // of the user's --table-style, so `--format markdown` is a self-contained + // contract (bug #8). + if (format === 'markdown') { + printTable(h, r as (string | number | boolean | null | undefined)[][], 'markdown'); + return; + } + switch (format) { case 'table': printTable(h, r as (string | number | boolean | null | undefined)[][]); diff --git a/src/utils/name-resolver.ts b/src/utils/name-resolver.ts index fa6a8de2..0ed87fed 100644 --- a/src/utils/name-resolver.ts +++ b/src/utils/name-resolver.ts @@ -69,9 +69,17 @@ function resolveDeviceByName( const rawName = normalizeDeviceName(device.name); const normAlias = alias ? normalizeDeviceName(alias) : null; - // exact alias/name wins regardless of strategy + // exact alias/name wins immediately for lenient strategies. + // Under require-unique we must NOT short-circuit: there may be other devices + // that also match (e.g. via substring), making the result ambiguous. Collect + // the exact hit as a candidate and let the full ambiguity check decide below. if ((normAlias && normAlias === q) || rawName === q) { - return { ok: true, deviceId }; + if (strategy !== 'require-unique') { + return { ok: true, deviceId }; + } + // require-unique: treat exact match as a high-priority candidate (score 0) + candidates.push({ deviceId, name: device.name, score: 0 }); + continue; } if (strategy === 'exact') continue; diff --git a/src/utils/output.ts b/src/utils/output.ts index 96eca847..aa50433e 100644 --- a/src/utils/output.ts +++ b/src/utils/output.ts @@ -59,8 +59,12 @@ const ASCII_BORDER_CHARS = { right: '|', 'right-mid': '+', middle: '|', }; -export function printTable(headers: string[], rows: (string | number | boolean | null | undefined)[][]): void { - const style = getTableStyle(); +export function printTable( + headers: string[], + rows: (string | number | boolean | null | undefined)[][], + styleOverride?: TableStyle, +): void { + const style = styleOverride ?? getTableStyle(); if (style === 'markdown') { console.log(renderMarkdownTable(headers, rows)); return; diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 00000000..45cefb62 --- /dev/null +++ b/src/version.ts @@ -0,0 +1,6 @@ +import { createRequire } from 'module'; + +const require = createRequire(import.meta.url); +const { version: VERSION } = require('../package.json') as { version: string }; + +export { VERSION }; diff --git a/tests/commands/cache.test.ts b/tests/commands/cache.test.ts index 87ee7b05..15471282 100644 --- a/tests/commands/cache.test.ts +++ b/tests/commands/cache.test.ts @@ -43,6 +43,16 @@ const SAMPLE_BODY = { }; describe('cache show', () => { + it('works with "status" alias', async () => { + const result = await runCli(registerCacheCommand, ['cache', 'status']); + expect(result.exitCode).toBeNull(); + const out = result.stdout.join('\n'); + expect(out).toMatch(/Device list cache/); + expect(out).toMatch(/Exists:\s+no/); + expect(out).toMatch(/Status cache/); + expect(out).toMatch(/Entries:\s+0/); + }); + it('prints empty summaries on a fresh machine', async () => { const result = await runCli(registerCacheCommand, ['cache', 'show']); expect(result.exitCode).toBeNull(); diff --git a/tests/commands/capabilities.test.ts b/tests/commands/capabilities.test.ts index 56767510..fa96722b 100644 --- a/tests/commands/capabilities.test.ts +++ b/tests/commands/capabilities.test.ts @@ -20,6 +20,9 @@ function makeProgram(): Command { describe.argument('', 'Device ID'); describe.option('--json', 'JSON output'); + const history = p.command('history').description('Device history and aggregation'); + history.command('aggregate').description('Aggregate device history'); + p.command('scenes').description('List and run scenes'); p.command('schema').description('Export device catalog'); p.command('mcp').description('Start MCP server'); @@ -206,4 +209,19 @@ describe('capabilities B3/B4', () => { expect(ic.windowSeconds).toBe(60); expect(ic.replayBehavior).toMatch(/replayed:true/); }); + + it('exposes history aggregate as a read-tier leaf', async () => { + const out = await runCapabilitiesWith(['--compact']); + const cmds = out.commands as Array<{ name: string; agentSafetyTier: string; mutating: boolean }>; + const agg = cmds.find((c) => c.name === 'history aggregate'); + expect(agg).toBeDefined(); + expect(agg!.agentSafetyTier).toBe('read'); + expect(agg!.mutating).toBe(false); + }); + + it('surfaces.mcp.tools includes aggregate_device_history', async () => { + const out = await runCapabilitiesWith([]); + const mcp = (out.surfaces as Record).mcp; + expect(mcp.tools).toContain('aggregate_device_history'); + }); }); diff --git a/tests/commands/dry-run.test.ts b/tests/commands/dry-run.test.ts new file mode 100644 index 00000000..03acbc0a --- /dev/null +++ b/tests/commands/dry-run.test.ts @@ -0,0 +1,179 @@ +/** + * dryRun tests (bug #4): send_command and run_scene must return + * { ok:true, dryRun:true, wouldSend:{...} } when dryRun:true is passed, + * and must NOT call the API mock. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- +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'; + } + }, +})); + +const cacheMock = vi.hoisted(() => ({ + map: new Map(), + getCachedDevice: vi.fn((id: string) => cacheMock.map.get(id) ?? null), + updateCacheFromDeviceList: vi.fn(), +})); + +vi.mock('../../src/devices/cache.js', () => ({ + getCachedDevice: cacheMock.getCachedDevice, + updateCacheFromDeviceList: cacheMock.updateCacheFromDeviceList, + 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 } from '../../src/commands/mcp.js'; + +async function pair() { + const server = createSwitchBotMcpServer(); + const client = new Client({ name: 'test', version: '0.0.1' }); + const [clientT, serverT] = InMemoryTransport.createLinkedPair(); + await Promise.all([server.connect(serverT), client.connect(clientT)]); + return { server, client }; +} + +describe('dryRun support on mutating tools', () => { + beforeEach(() => { + apiMock.__instance.get.mockReset(); + apiMock.__instance.post.mockReset(); + cacheMock.map.clear(); + }); + + // ---- send_command --------------------------------------------------------- + + it('send_command dryRun:true returns wouldSend without calling the API', async () => { + cacheMock.map.set('BULB1', { type: 'Color Bulb', name: 'Desk Lamp', category: 'physical' }); + const { client } = await pair(); + + const res = await client.callTool({ + name: 'send_command', + arguments: { + deviceId: 'BULB1', + command: 'turnOn', + dryRun: true, + }, + }); + + expect(res.isError).toBeFalsy(); + const parsed = JSON.parse((res.content as Array<{ text: string }>)[0].text); + expect(parsed.ok).toBe(true); + expect(parsed.dryRun).toBe(true); + expect(parsed.wouldSend).toMatchObject({ + deviceId: 'BULB1', + command: 'turnOn', + commandType: 'command', + }); + + // Must not have hit the API + expect(apiMock.__instance.post).not.toHaveBeenCalled(); + expect(apiMock.__instance.get).not.toHaveBeenCalled(); + }); + + it('send_command dryRun:true with parameter and commandType mirrors the full request shape', async () => { + const { client } = await pair(); + + const res = await client.callTool({ + name: 'send_command', + arguments: { + deviceId: 'IR1', + command: 'SetChannel', + parameter: '5', + commandType: 'customize', + dryRun: true, + }, + }); + + expect(res.isError).toBeFalsy(); + const parsed = JSON.parse((res.content as Array<{ text: string }>)[0].text); + expect(parsed.wouldSend).toMatchObject({ + deviceId: 'IR1', + command: 'SetChannel', + parameter: '5', + commandType: 'customize', + }); + expect(apiMock.__instance.post).not.toHaveBeenCalled(); + }); + + it('send_command without dryRun still calls the API (regression)', async () => { + cacheMock.map.set('BULB2', { type: 'Color Bulb', name: 'Ceiling', category: 'physical' }); + apiMock.__instance.post.mockResolvedValueOnce({ + data: { statusCode: 100, body: {} }, + }); + const { client } = await pair(); + + const res = await client.callTool({ + name: 'send_command', + arguments: { deviceId: 'BULB2', command: 'turnOn' }, + }); + + expect(res.isError).toBeFalsy(); + expect(apiMock.__instance.post).toHaveBeenCalledTimes(1); + }); + + // ---- run_scene ------------------------------------------------------------ + + it('run_scene dryRun:true returns wouldSend without calling the API', async () => { + const { client } = await pair(); + + const res = await client.callTool({ + name: 'run_scene', + arguments: { sceneId: 'SCENE42', dryRun: true }, + }); + + expect(res.isError).toBeFalsy(); + const parsed = JSON.parse((res.content as Array<{ text: string }>)[0].text); + expect(parsed.ok).toBe(true); + expect(parsed.dryRun).toBe(true); + expect(parsed.wouldSend).toMatchObject({ sceneId: 'SCENE42' }); + + expect(apiMock.__instance.post).not.toHaveBeenCalled(); + }); + + it('run_scene without dryRun still calls the API (regression)', async () => { + apiMock.__instance.post.mockResolvedValueOnce({ data: { statusCode: 100, body: {} } }); + const { client } = await pair(); + + const res = await client.callTool({ + name: 'run_scene', + arguments: { sceneId: 'S123' }, + }); + + expect(res.isError).toBeFalsy(); + expect(apiMock.__instance.post).toHaveBeenCalledWith('/v1.1/scenes/S123/execute'); + }); +}); diff --git a/tests/commands/events.test.ts b/tests/commands/events.test.ts index c73810cf..3f5c7e40 100644 --- a/tests/commands/events.test.ts +++ b/tests/commands/events.test.ts @@ -1,8 +1,12 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import http from 'node:http'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import { once } from 'node:events'; import { AddressInfo } from 'node:net'; import { startReceiver, registerEventsCommand } from '../../src/commands/events.js'; +import { deviceHistoryStore } from '../../src/mcp/device-history.js'; import { runCli } from '../helpers/cli.js'; // --------------------------------------------------------------------------- @@ -346,3 +350,74 @@ describe('events mqtt-tail', () => { expect(disconnect).toBeDefined(); }); }); + +// --------------------------------------------------------------------------- +// __control.jsonl persistence tests (bug #10) +// --------------------------------------------------------------------------- +describe('events mqtt-tail — control event persistence', () => { + let tmpHome: string; + + beforeEach(() => { + mqttMock.messageHandler = null; + mqttMock.stateHandler = null; + mqttMock.connectShouldFireMessage = false; + mqttMock.connectShouldFireState = null; + vi.mocked(fetchMqttCredential).mockResolvedValue(mockCredential); + vi.mocked(tryLoadConfig).mockReturnValue({ token: 'test-token', secret: 'test-secret' }); + + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sbcli-ctlevt-')); + vi.spyOn(os, 'homedir').mockReturnValue(tmpHome); + deviceHistoryStore.resetSizes(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + try { fs.rmSync(tmpHome, { recursive: true, force: true }); } catch { /* */ } + }); + + it('writes __control.jsonl with __connect event on initial connect', async () => { + mqttMock.connectShouldFireMessage = true; + mqttMock.connectShouldFireState = 'connected'; + + await runCli(registerEventsCommand, ['events', 'mqtt-tail', '--max', '1']); + + const controlFile = path.join(tmpHome, '.switchbot', 'device-history', '__control.jsonl'); + expect(fs.existsSync(controlFile)).toBe(true); + + const lines = fs.readFileSync(controlFile, 'utf-8').trim().split('\n').filter(Boolean); + expect(lines.length).toBeGreaterThanOrEqual(1); + + const parsed = JSON.parse(lines[0]) as { type: string; at: string; eventId: string }; + expect(parsed.type).toBe('__connect'); + expect(typeof parsed.at).toBe('string'); + expect(typeof parsed.eventId).toBe('string'); + }); + + it('writes __disconnect to __control.jsonl on failed state', async () => { + mqttMock.connectShouldFireState = 'failed'; + + await runCli(registerEventsCommand, ['events', 'mqtt-tail']); + + const controlFile = path.join(tmpHome, '.switchbot', 'device-history', '__control.jsonl'); + expect(fs.existsSync(controlFile)).toBe(true); + + const lines = fs.readFileSync(controlFile, 'utf-8').trim().split('\n').filter(Boolean); + const types = lines.map((l) => (JSON.parse(l) as { type: string }).type); + expect(types).toContain('__disconnect'); + }); + + it('does not write per-device files for control events (no cross-contamination)', async () => { + // Use 'failed' so the command exits cleanly without needing --max or a device message. + mqttMock.connectShouldFireState = 'failed'; + + await runCli(registerEventsCommand, ['events', 'mqtt-tail']); + + const histDir = path.join(tmpHome, '.switchbot', 'device-history'); + if (!fs.existsSync(histDir)) return; // no dir means no per-device files — pass + + const files = fs.readdirSync(histDir); + // Only __control.jsonl should exist; no per-device .json or .jsonl for a real deviceId + const deviceFiles = files.filter((f) => !f.startsWith('__')); + expect(deviceFiles).toHaveLength(0); + }); +}); diff --git a/tests/commands/history.test.ts b/tests/commands/history.test.ts index 973da607..e41c439f 100644 --- a/tests/commands/history.test.ts +++ b/tests/commands/history.test.ts @@ -137,6 +137,81 @@ describe('history command', () => { expect(res.stdout.join('\n')).toMatch(/replayed turnOff on BOT2/); }); }); + + describe('verify', () => { + it('exits 0 with status warn when audit.log does not exist (fresh install)', async () => { + const res = await runCli(registerHistoryCommand, [ + 'history', 'verify', '--file', auditFile, + ]); + expect(res.exitCode).toBe(0); + const out = res.stdout.join('\n'); + expect(out).toMatch(/fresh install/i); + expect(out).toMatch(/warn/i); + }); + + it('exits 0 with status ok for an empty-but-existing file', async () => { + fs.writeFileSync(auditFile, ''); + const res = await runCli(registerHistoryCommand, [ + 'history', 'verify', '--file', auditFile, + ]); + expect(res.exitCode).toBe(0); + const out = res.stdout.join('\n'); + expect(out).not.toMatch(/warn/i); + }); + + it('exits 1 with status fail when file has a malformed line', async () => { + seed([ + { t: 't1', kind: 'command', deviceId: 'A', command: 'cmd', parameter: undefined, commandType: 'command', dryRun: false }, + 'not valid json', + ]); + const res = await runCli(registerHistoryCommand, [ + 'history', 'verify', '--file', auditFile, + ]); + expect(res.exitCode).toBe(1); + const out = res.stdout.join('\n'); + expect(out).toMatch(/Malformed:/); + expect(out).toMatch(/1/); + }); + + it('exits 0 with status warn and fileMissing=true when --json on missing file', async () => { + const res = await runCli(registerHistoryCommand, [ + '--json', 'history', 'verify', '--file', auditFile, + ]); + expect(res.exitCode).toBe(0); + const envelope = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')); + expect(envelope.data.status).toBe('warn'); + expect(envelope.data.fileMissing).toBe(true); + expect(envelope.data.parsed).toBe(0); + expect(envelope.data.malformed).toBe(0); + expect(envelope.data.unversioned).toBe(0); + }); + + it('exits 1 with status fail when --json on file with malformed entries', async () => { + // Write a file with a line that doesn't parse as JSON + fs.writeFileSync(auditFile, 'not valid json\n'); + const res = await runCli(registerHistoryCommand, [ + '--json', 'history', 'verify', '--file', auditFile, + ]); + expect(res.exitCode).toBe(1); + const envelope = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')); + expect(envelope.data.status).toBe('fail'); + expect(envelope.data.fileMissing).toBe(false); + expect(envelope.data.malformed).toBeGreaterThan(0); + }); + + it('exits 0 with status ok when all entries are valid', async () => { + seed([ + { auditVersion: 1, t: '2026-04-18T10:00:00.000Z', kind: 'command', deviceId: 'BOT1', command: 'turnOn', parameter: undefined, commandType: 'command', dryRun: false }, + { auditVersion: 1, t: '2026-04-18T10:00:05.000Z', kind: 'command', deviceId: 'BOT1', command: 'turnOff', parameter: undefined, commandType: 'command', dryRun: false }, + ]); + const res = await runCli(registerHistoryCommand, [ + 'history', 'verify', '--file', auditFile, + ]); + expect(res.exitCode).toBe(0); + const out = res.stdout.join('\n'); + expect(out).toMatch(/Parsed lines:\s+2/); + }); + }); }); describe('history range / stats (D3)', () => { @@ -232,3 +307,51 @@ describe('history range / stats (D3)', () => { expect(env.data.oldest).toBe('2026-04-10T00:00:00.000Z'); }); }); + +describe('history aggregate (D7)', () => { + let tmpHome: string; + let historyDir: string; + + beforeEach(() => { + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sb-histcmd-agg-')); + historyDir = path.join(tmpHome, '.switchbot', 'device-history'); + fs.mkdirSync(historyDir, { recursive: true }); + vi.spyOn(os, 'homedir').mockReturnValue(tmpHome); + }); + + afterEach(() => { + vi.restoreAllMocks(); + try { fs.rmSync(tmpHome, { recursive: true, force: true }); } catch { /* */ } + }); + + function seedJsonl(deviceId: string, records: Array>): void { + const line = records.map((r) => JSON.stringify(r)).join('\n') + '\n'; + fs.writeFileSync(path.join(historyDir, `${deviceId}.jsonl`), line); + } + + it('emits the expected --json envelope for a single-bucket aggregation', async () => { + seedJsonl('DEV1', [ + { t: '2026-04-19T10:00:00.000Z', topic: 'sb/DEV1', payload: { temperature: 20 } }, + { t: '2026-04-19T10:30:00.000Z', topic: 'sb/DEV1', payload: { temperature: 24 } }, + ]); + const res = await runCli(registerHistoryCommand, [ + '--json', 'history', 'aggregate', 'DEV1', + '--from', '2026-04-19T00:00:00.000Z', + '--to', '2026-04-20T00:00:00.000Z', + '--metric', 'temperature', + '--agg', 'count,avg', + ]); + const parsed = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')); + expect(parsed.data.buckets[0].metrics.temperature.count).toBe(2); + expect(parsed.data.buckets[0].metrics.temperature.avg).toBe(22); + }); + + it('exits 2 with UsageError when --metric is missing', async () => { + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((_code) => { throw new Error('process.exit'); }); + const res = await runCli(registerHistoryCommand, [ + 'history', 'aggregate', 'DEV1', '--since', '1h', + ]); + exitSpy.mockRestore(); + expect(res.exitCode).toBe(2); + }); +}); diff --git a/tests/commands/mcp.test.ts b/tests/commands/mcp.test.ts index c0d64840..f48106e4 100644 --- a/tests/commands/mcp.test.ts +++ b/tests/commands/mcp.test.ts @@ -1,4 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; // --------------------------------------------------------------------------- // Mock the API layer so we don't hit real HTTPS. @@ -76,7 +79,7 @@ describe('mcp server', () => { cacheMock.updateCacheFromDeviceList.mockClear(); }); - it('exposes the ten tools with titles and input schemas', async () => { + it('exposes the eleven tools with titles and input schemas', async () => { const { client } = await pair(); const { tools } = await client.listTools(); @@ -84,6 +87,7 @@ describe('mcp server', () => { expect(names).toEqual( [ 'account_overview', + 'aggregate_device_history', 'describe_device', 'get_device_history', 'get_device_status', @@ -326,4 +330,67 @@ describe('mcp server', () => { expect.objectContaining({ command: 'turnOn' }) ); }); + + it('lists aggregate_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(); + expect((tool as { _meta?: { agentSafetyTier?: string } } | undefined)?._meta?.agentSafetyTier).toBe('read'); + }); + + it('aggregate_device_history 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' }, + }); + 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 () => { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'mcp-agg-test-')); + 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/DEV1', payload: { temperature: 20 } }), + JSON.stringify({ t: '2026-04-19T10:30:00.000Z', topic: 't/DEV1', payload: { temperature: 24 } }), + ]; + fs.writeFileSync(path.join(histDir, 'DEV1.jsonl'), lines.join('\n') + '\n'); + + const { client } = await pair(); + const res = await client.callTool({ + name: 'aggregate_device_history', + arguments: { + deviceId: 'DEV1', + from: '2026-04-19T00:00:00.000Z', + to: '2026-04-20T00:00:00.000Z', + metrics: ['temperature'], + aggs: ['count', 'avg'], + }, + }); + + expect(res.isError).toBeFalsy(); + const sc = (res as { structuredContent?: unknown }).structuredContent as Record | undefined; + const buckets = ( + sc && 'buckets' in sc + ? (sc as { buckets: unknown[] }).buckets + : (sc as { data?: { buckets: unknown[] } } | undefined)?.data?.buckets + ) as Array<{ metrics: { temperature: { count?: number; avg?: number } } }> | undefined; + + expect(buckets, 'structuredContent should have buckets').toBeDefined(); + expect(buckets![0].metrics.temperature.count).toBe(2); + expect(buckets![0].metrics.temperature.avg).toBe(22); + } finally { + vi.restoreAllMocks(); + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }); }); diff --git a/tests/commands/scenes.test.ts b/tests/commands/scenes.test.ts index bfb9b385..42b02f29 100644 --- a/tests/commands/scenes.test.ts +++ b/tests/commands/scenes.test.ts @@ -129,4 +129,42 @@ describe('scenes command', () => { expect(res.stderr.join('\n').toLowerCase()).toContain('missing required'); }); }); + + describe('describe', () => { + it('returns scene metadata for a known sceneId', async () => { + apiMock.__instance.get.mockResolvedValue({ + data: { + body: [ + { sceneId: 'S1', sceneName: 'Good Morning' }, + { sceneId: 'S2', sceneName: 'Movie Time' }, + ], + }, + }); + const res = await runCli(registerScenesCommand, ['scenes', 'describe', 'S1', '--json']); + expect(res.exitCode).toBeNull(); + const out = res.stdout.join('\n'); + const parsed = JSON.parse(out); + expect(parsed.data.sceneId).toBe('S1'); + expect(parsed.data.sceneName).toBe('Good Morning'); + expect(parsed.data.stepCount).toBeNull(); + expect(parsed.data.note).toMatch(/does not expose scene steps/); + }); + + it('exits 2 with scene_not_found when sceneId is unknown', async () => { + apiMock.__instance.get.mockResolvedValue({ + data: { + body: [ + { sceneId: 'S1', sceneName: 'Good Morning' }, + ], + }, + }); + const res = await runCli(registerScenesCommand, ['scenes', 'describe', 'MISSING', '--json']); + expect(res.exitCode).toBe(2); + const out = res.stderr.join('\n'); + const parsed = JSON.parse(out); + expect(parsed.error?.context?.error).toBe('scene_not_found'); + expect(parsed.error?.context?.sceneId).toBe('MISSING'); + expect(parsed.error?.context?.candidates).toHaveLength(1); + }); + }); }); diff --git a/tests/commands/strict-schemas.test.ts b/tests/commands/strict-schemas.test.ts new file mode 100644 index 00000000..8219bb20 --- /dev/null +++ b/tests/commands/strict-schemas.test.ts @@ -0,0 +1,155 @@ +/** + * Strict-schema tests (bug #4): every MCP tool must reject unknown input keys + * with JSON-RPC -32602 / unrecognized_keys. + * + * SDK @1.29.0 returns { isError:true, content:[{type:'text', text:'MCP error -32602…'}] } + * rather than throwing. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// --------------------------------------------------------------------------- +// Mocks — same boilerplate as mcp.test.ts +// --------------------------------------------------------------------------- +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'; + } + }, +})); + +const cacheMock = vi.hoisted(() => ({ + map: new Map(), + getCachedDevice: vi.fn((id: string) => cacheMock.map.get(id) ?? null), + updateCacheFromDeviceList: vi.fn(), +})); + +vi.mock('../../src/devices/cache.js', () => ({ + getCachedDevice: cacheMock.getCachedDevice, + updateCacheFromDeviceList: cacheMock.updateCacheFromDeviceList, + 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 } from '../../src/commands/mcp.js'; + +async function pair() { + const server = createSwitchBotMcpServer(); + const client = new Client({ name: 'test', version: '0.0.1' }); + const [clientT, serverT] = InMemoryTransport.createLinkedPair(); + await Promise.all([server.connect(serverT), client.connect(clientT)]); + return { server, client }; +} + +/** Assert that a tool call with an extra unknown field returns a -32602 error. */ +async function assertRejectsUnknownKey( + client: Client, + toolName: string, + validArgs: Record, +) { + const args = { ...validArgs, fooBarBaz: true }; + const res = await client.callTool({ name: toolName, arguments: args }); + expect(res.isError, `${toolName}: expected isError to be true`).toBe(true); + const text = (res.content as Array<{ type: string; text: string }>)[0].text; + expect(text, `${toolName}: expected -32602 or unrecognized_keys`).toMatch( + /-32602|unrecognized_keys|Unrecognized key/i, + ); +} + +describe('MCP strict schemas — all 11 tools reject unknown keys', () => { + beforeEach(() => { + apiMock.__instance.get.mockReset(); + apiMock.__instance.post.mockReset(); + cacheMock.map.clear(); + }); + + it('list_devices rejects unknown keys', async () => { + const { client } = await pair(); + await assertRejectsUnknownKey(client, 'list_devices', {}); + }); + + it('get_device_status rejects unknown keys', async () => { + const { client } = await pair(); + await assertRejectsUnknownKey(client, 'get_device_status', { deviceId: 'D1' }); + }); + + it('get_device_history rejects unknown keys', async () => { + const { client } = await pair(); + await assertRejectsUnknownKey(client, 'get_device_history', {}); + }); + + it('query_device_history rejects unknown keys', async () => { + const { client } = await pair(); + await assertRejectsUnknownKey(client, 'query_device_history', { deviceId: 'D1' }); + }); + + it('send_command rejects unknown keys', async () => { + cacheMock.map.set('BOT1', { type: 'Bot', name: 'Kitchen Bot', category: 'physical' }); + const { client } = await pair(); + await assertRejectsUnknownKey(client, 'send_command', { + deviceId: 'BOT1', + command: 'turnOn', + }); + }); + + it('run_scene rejects unknown keys', async () => { + const { client } = await pair(); + await assertRejectsUnknownKey(client, 'run_scene', { sceneId: 'S1' }); + }); + + it('list_scenes rejects unknown keys', async () => { + const { client } = await pair(); + await assertRejectsUnknownKey(client, 'list_scenes', {}); + }); + + it('search_catalog rejects unknown keys', async () => { + const { client } = await pair(); + await assertRejectsUnknownKey(client, 'search_catalog', { query: 'Bot' }); + }); + + it('describe_device rejects unknown keys', async () => { + const { client } = await pair(); + await assertRejectsUnknownKey(client, 'describe_device', { deviceId: 'D1' }); + }); + + it('aggregate_device_history rejects unknown keys', async () => { + const { client } = await pair(); + await assertRejectsUnknownKey(client, 'aggregate_device_history', { + deviceId: 'D1', + metrics: ['temperature'], + }); + }); + + it('account_overview rejects unknown keys', async () => { + const { client } = await pair(); + await assertRejectsUnknownKey(client, 'account_overview', {}); + }); +}); diff --git a/tests/devices/history-agg.test.ts b/tests/devices/history-agg.test.ts new file mode 100644 index 00000000..758d9968 --- /dev/null +++ b/tests/devices/history-agg.test.ts @@ -0,0 +1,265 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { aggregateDeviceHistory } from '../../src/devices/history-agg.js'; + +function writeJsonl(file: string, records: Array>): void { + fs.writeFileSync(file, records.map((r) => JSON.stringify(r)).join('\n') + '\n'); +} + +describe('aggregateDeviceHistory — single bucket', () => { + let tmpHome: string; + let historyDir: string; + + beforeEach(() => { + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sb-agg-')); + historyDir = path.join(tmpHome, '.switchbot', 'device-history'); + fs.mkdirSync(historyDir, { recursive: true }); + vi.spyOn(os, 'homedir').mockReturnValue(tmpHome); + }); + + afterEach(() => { + vi.restoreAllMocks(); + try { fs.rmSync(tmpHome, { recursive: true, force: true }); } catch { /* */ } + }); + + it('folds all samples into one bucket when --bucket is omitted', async () => { + const file = path.join(historyDir, 'DEV1.jsonl'); + writeJsonl(file, [ + { t: '2026-04-19T10:00:00.000Z', topic: 'status', payload: { temperature: 20 } }, + { t: '2026-04-19T10:30:00.000Z', topic: 'status', payload: { temperature: 22 } }, + { t: '2026-04-19T11:00:00.000Z', topic: 'status', payload: { temperature: 24 } }, + ]); + + const res = await aggregateDeviceHistory('DEV1', { + from: '2026-04-19T00:00:00.000Z', + to: '2026-04-20T00:00:00.000Z', + metrics: ['temperature'], + aggs: ['count', 'min', 'max', 'avg', 'sum'], + }); + + expect(res.buckets).toHaveLength(1); + const m = res.buckets[0].metrics.temperature; + expect(m.count).toBe(3); + expect(m.min).toBe(20); + expect(m.max).toBe(24); + expect(m.avg).toBe(22); + expect(m.sum).toBe(66); + expect(res.partial).toBe(false); + expect(res.notes).toEqual([]); + }); + + it('buckets by --bucket duration with UTC-aligned boundaries', async () => { + const file = path.join(historyDir, 'DEV1.jsonl'); + writeJsonl(file, [ + { t: '2026-04-19T10:00:00.000Z', topic: 'status', payload: { temperature: 20 } }, + { t: '2026-04-19T10:30:00.000Z', topic: 'status', payload: { temperature: 22 } }, + { t: '2026-04-19T11:00:00.000Z', topic: 'status', payload: { temperature: 24 } }, + { t: '2026-04-19T11:59:59.999Z', topic: 'status', payload: { temperature: 26 } }, + ]); + + const res = await aggregateDeviceHistory('DEV1', { + from: '2026-04-19T00:00:00.000Z', + to: '2026-04-20T00:00:00.000Z', + metrics: ['temperature'], + aggs: ['count', 'avg'], + bucket: '1h', + }); + + expect(res.buckets.map((b) => b.t)).toEqual([ + '2026-04-19T10:00:00.000Z', + '2026-04-19T11:00:00.000Z', + ]); + expect(res.buckets[0].metrics.temperature.count).toBe(2); + expect(res.buckets[0].metrics.temperature.avg).toBe(21); + expect(res.buckets[1].metrics.temperature.count).toBe(2); + expect(res.buckets[1].metrics.temperature.avg).toBe(25); + }); + + it('places a record at HH:59:59.999 in the HH bucket and HH+1:00:00.000 in HH+1', async () => { + const file = path.join(historyDir, 'DEV1.jsonl'); + writeJsonl(file, [ + { t: '2026-04-19T10:59:59.999Z', topic: 'status', payload: { temperature: 20 } }, + { t: '2026-04-19T11:00:00.000Z', topic: 'status', payload: { temperature: 40 } }, + ]); + + const res = await aggregateDeviceHistory('DEV1', { + from: '2026-04-19T00:00:00.000Z', + to: '2026-04-20T00:00:00.000Z', + metrics: ['temperature'], + aggs: ['count'], + bucket: '1h', + }); + + expect(res.buckets).toHaveLength(2); + expect(res.buckets[0].t).toBe('2026-04-19T10:00:00.000Z'); + expect(res.buckets[1].t).toBe('2026-04-19T11:00:00.000Z'); + }); + + it('throws UsageError-like for unparseable --bucket', async () => { + const file = path.join(historyDir, 'DEV1.jsonl'); + writeJsonl(file, [ + { t: '2026-04-19T10:00:00.000Z', topic: 'status', payload: { temperature: 20 } }, + ]); + + await expect( + aggregateDeviceHistory('DEV1', { + from: '2026-04-19T00:00:00.000Z', + to: '2026-04-20T00:00:00.000Z', + metrics: ['temperature'], + bucket: 'banana', + }), + ).rejects.toThrow(/Invalid --bucket/); + }); + + it('computes p50 and p95 via nearest-rank on sorted samples', async () => { + const file = path.join(historyDir, 'DEV1.jsonl'); + // 100 samples uniformly 1..100 + const records = []; + for (let i = 1; i <= 100; i++) { + records.push({ + t: `2026-04-19T10:${String(Math.floor((i - 1) / 2)).padStart(2, '0')}:${String((i - 1) % 2 * 30).padStart(2, '0')}.000Z`, + topic: 'status', + payload: { v: i }, + }); + } + writeJsonl(file, records); + + const res = await aggregateDeviceHistory('DEV1', { + from: '2026-04-19T00:00:00.000Z', + to: '2026-04-20T00:00:00.000Z', + metrics: ['v'], + aggs: ['p50', 'p95'], + }); + + expect(res.buckets).toHaveLength(1); + // Nearest-rank on 1..100: p50 → index floor(0.5*99)=49 → 50; p95 → floor(0.95*99)=94 → 95 + expect(res.buckets[0].metrics.v.p50).toBe(50); + expect(res.buckets[0].metrics.v.p95).toBe(95); + }); + + it('flips partial:true and appends a note when sample cap is hit', async () => { + const file = path.join(historyDir, 'DEV1.jsonl'); + const records = []; + // 5 samples, cap=3 → cap hit on the 4th + for (let i = 0; i < 5; i++) { + records.push({ + t: `2026-04-19T10:00:0${i}.000Z`, + topic: 'status', + payload: { v: i + 1 }, + }); + } + writeJsonl(file, records); + + const res = await aggregateDeviceHistory('DEV1', { + from: '2026-04-19T00:00:00.000Z', + to: '2026-04-20T00:00:00.000Z', + metrics: ['v'], + aggs: ['count', 'p95'], + maxBucketSamples: 3, + }); + + expect(res.partial).toBe(true); + expect(res.notes.length).toBe(1); + expect(res.notes[0]).toMatch(/sample cap 3 reached/); + // count is still exact (all 5 samples folded in) + expect(res.buckets[0].metrics.v.count).toBe(5); + }); + + it('skips non-numeric samples for a metric', async () => { + const file = path.join(historyDir, 'DEV2.jsonl'); + writeJsonl(file, [ + { t: '2026-04-19T10:00:00.000Z', topic: 'status', payload: { temperature: 20 } }, + { t: '2026-04-19T10:01:00.000Z', topic: 'status', payload: { temperature: 'hot' } }, + { t: '2026-04-19T10:02:00.000Z', topic: 'status', payload: { temperature: null } }, + { t: '2026-04-19T10:03:00.000Z', topic: 'status', payload: { temperature: 24 } }, + ]); + + const res = await aggregateDeviceHistory('DEV2', { + from: '2026-04-19T00:00:00.000Z', + to: '2026-04-20T00:00:00.000Z', + metrics: ['temperature'], + aggs: ['count', 'avg'], + }); + + expect(res.buckets).toHaveLength(1); + expect(res.buckets[0].metrics.temperature.count).toBe(2); + expect(res.buckets[0].metrics.temperature.avg).toBe(22); + }); + + it('omits metric entirely when no numeric samples exist in a bucket', async () => { + const file = path.join(historyDir, 'DEV3.jsonl'); + writeJsonl(file, [ + { t: '2026-04-19T10:00:00.000Z', topic: 'status', payload: { temperature: 20, humidity: 'dry' } }, + { t: '2026-04-19T10:01:00.000Z', topic: 'status', payload: { temperature: 22, humidity: null } }, + ]); + + const res = await aggregateDeviceHistory('DEV3', { + from: '2026-04-19T00:00:00.000Z', + to: '2026-04-20T00:00:00.000Z', + metrics: ['temperature', 'humidity'], + aggs: ['count', 'avg'], + }); + + // One bucket exists because temperature has numeric samples + expect(res.buckets).toHaveLength(1); + // humidity is absent because it has no numeric samples + expect(res.buckets[0].metrics.humidity).toBeUndefined(); + // temperature is present + expect(res.buckets[0].metrics.temperature.count).toBe(2); + }); + + it('returns empty buckets for an unknown device', async () => { + const res = await aggregateDeviceHistory('does-not-exist', { + from: '2026-04-19T00:00:00.000Z', + to: '2026-04-20T00:00:00.000Z', + metrics: ['temperature'], + }); + + expect(res.buckets).toEqual([]); + expect(res.partial).toBe(false); + expect(res.notes).toEqual([]); + }); + + it('skips rotated files whose mtime is older than --since window', async () => { + const id = 'DEV4'; + const rotatedFile = path.join(historyDir, `${id}.jsonl.1`); + const currentFile = path.join(historyDir, `${id}.jsonl`); + + const threeDaysAgo = new Date(Date.now() - 3 * 24 * 3600 * 1000); + const thirtySecondsAgo = new Date(Date.now() - 30_000); // Inside 5m window + const nowish = new Date(); + + // Rotated file: RECENT record (inside 5m window) but backdated file mtime (outside window) + const rotatedRecord = { t: thirtySecondsAgo.toISOString(), topic: 'status', payload: { temperature: 99 } }; + writeJsonl(rotatedFile, [rotatedRecord]); + fs.utimesSync(rotatedFile, threeDaysAgo, threeDaysAgo); // Backdate file mtime only + + // Current file: recent record with different value + writeJsonl(currentFile, [ + { t: nowish.toISOString(), topic: 'status', payload: { temperature: 21 } }, + ]); + + // Before calling aggregateDeviceHistory, assert that the rotated record is recent enough + // to pass the per-record timestamp filter (if there were no mtime prune) + const fromMs = Date.now() - 5 * 60 * 1000; + const rotatedRecordTms = Date.parse(rotatedRecord.t); + expect(rotatedRecordTms).toBeGreaterThan(fromMs); // record is inside window + expect(fs.statSync(rotatedFile).mtimeMs).toBeLessThan(fromMs); // but file mtime is outside + + const res = await aggregateDeviceHistory(id, { + since: '5m', + metrics: ['temperature'], + aggs: ['count', 'min', 'max'], + }); + + // Only the current file's record should be present (rotated file filtered by mtime) + expect(res.buckets).toHaveLength(1); + expect(res.buckets[0].metrics.temperature.count).toBe(1); + // Verify it's the current file's value (21), not the rotated file's (99) + expect(res.buckets[0].metrics.temperature.min).toBe(21); + expect(res.buckets[0].metrics.temperature.max).toBe(21); + }); +}); diff --git a/tests/helpers/cli.ts b/tests/helpers/cli.ts index 98faa735..3c23e6d6 100644 --- a/tests/helpers/cli.ts +++ b/tests/helpers/cli.ts @@ -24,6 +24,7 @@ export async function runCli( // tests can use `--dry-run`, `--verbose`, etc. without Commander rejecting // them as unknown-option before the action runs. Values here do not need // argParser validation — the tests don't exercise those paths. + program.option('--no-color', 'Disable ANSI colors in output'); program.option('--json', 'Output results in JSON format'); program.option('--format ', 'Output format'); program.option('--fields ', 'Column filter'); diff --git a/tests/mcp/server-version.test.ts b/tests/mcp/server-version.test.ts new file mode 100644 index 00000000..a5da5adb --- /dev/null +++ b/tests/mcp/server-version.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { VERSION } from '../../src/version.js'; + +describe('mcp server version', () => { + it('VERSION constant matches package.json version', () => { + // Read package.json from disk to get the expected version + const pkgPath = path.resolve(__dirname, '../../package.json'); + const pkgContent = fs.readFileSync(pkgPath, 'utf-8'); + const pkg = JSON.parse(pkgContent) as { version: string }; + const expectedVersion = pkg.version; + + // Verify the VERSION constant matches + expect(VERSION).toBe(expectedVersion); + expect(VERSION).toBe('2.5.0'); + }); +}); + + diff --git a/tests/mcp/tool-meta.test.ts b/tests/mcp/tool-meta.test.ts new file mode 100644 index 00000000..cd8d501d --- /dev/null +++ b/tests/mcp/tool-meta.test.ts @@ -0,0 +1,126 @@ +/** + * MCP tool _meta.agentSafetyTier test — bug #6 + * + * Verifies that every MCP tool has _meta.agentSafetyTier set to one of + * 'read' | 'action' | 'destructive', and spot-checks specific expected tiers. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// --------------------------------------------------------------------------- +// Mocks — same boilerplate as strict-schemas.test.ts +// --------------------------------------------------------------------------- +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'; + } + }, +})); + +const cacheMock = vi.hoisted(() => ({ + map: new Map(), + getCachedDevice: vi.fn((id: string) => cacheMock.map.get(id) ?? null), + updateCacheFromDeviceList: vi.fn(), +})); + +vi.mock('../../src/devices/cache.js', () => ({ + getCachedDevice: cacheMock.getCachedDevice, + updateCacheFromDeviceList: cacheMock.updateCacheFromDeviceList, + 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 } from '../../src/commands/mcp.js'; + +async function pair() { + const server = createSwitchBotMcpServer(); + const client = new Client({ name: 'test', version: '0.0.1' }); + const [clientT, serverT] = InMemoryTransport.createLinkedPair(); + await Promise.all([server.connect(serverT), client.connect(clientT)]); + return { server, client }; +} + +describe('MCP tool _meta.agentSafetyTier', () => { + beforeEach(() => { + apiMock.__instance.get.mockReset(); + apiMock.__instance.post.mockReset(); + cacheMock.map.clear(); + }); + + it('every tool has _meta.agentSafetyTier set to read | action | destructive', async () => { + const { client } = await pair(); + + const toolsList = await client.listTools(); + expect(toolsList.tools.length).toBeGreaterThan(0); + + for (const tool of toolsList.tools) { + const meta = (tool as any)._meta; + expect(meta, `${tool.name} must have _meta field`).toBeDefined(); + expect(meta.agentSafetyTier, `${tool.name} must have agentSafetyTier`).toBeDefined(); + expect( + ['read', 'action', 'destructive'].includes(meta.agentSafetyTier), + `${tool.name} agentSafetyTier must be 'read' | 'action' | 'destructive', got: ${meta.agentSafetyTier}` + ).toBe(true); + } + }); + + it('send_command is marked as action tier', async () => { + const { client } = await pair(); + const toolsList = await client.listTools(); + const tool = toolsList.tools.find((t) => t.name === 'send_command'); + expect(tool).toBeDefined(); + expect((tool as any)._meta.agentSafetyTier).toBe('action'); + }); + + it('run_scene is marked as action tier', async () => { + const { client } = await pair(); + const toolsList = await client.listTools(); + const tool = toolsList.tools.find((t) => t.name === 'run_scene'); + expect(tool).toBeDefined(); + expect((tool as any)._meta.agentSafetyTier).toBe('action'); + }); + + it('list_devices is marked as read tier', async () => { + const { client } = await pair(); + const toolsList = await client.listTools(); + const tool = toolsList.tools.find((t) => t.name === 'list_devices'); + expect(tool).toBeDefined(); + expect((tool as any)._meta.agentSafetyTier).toBe('read'); + }); + + it('aggregate_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'); + expect(tool).toBeDefined(); + expect((tool as any)._meta.agentSafetyTier).toBe('read'); + }); +}); diff --git a/tests/utils/audit.test.ts b/tests/utils/audit.test.ts index e70abbfb..5a9f9fea 100644 --- a/tests/utils/audit.test.ts +++ b/tests/utils/audit.test.ts @@ -137,7 +137,7 @@ describe('audit log', () => { it('verifyAudit returns a problem when file is missing', () => { const report = verifyAudit(path.join(tmp, 'missing.log')); expect(report.parsedLines).toBe(0); - expect(report.problems).toHaveLength(1); - expect(report.problems[0].reason).toContain('does not exist'); + expect(report.fileMissing).toBe(true); + expect(report.problems).toHaveLength(0); }); }); diff --git a/tests/utils/color-flag.test.ts b/tests/utils/color-flag.test.ts new file mode 100644 index 00000000..bc31d174 --- /dev/null +++ b/tests/utils/color-flag.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import chalk from 'chalk'; + +describe('--no-color flag and NO_COLOR env', () => { + let originalArgv: string[]; + let originalNoColor: string | undefined; + let originalChalkLevel: number; + + beforeEach(() => { + originalArgv = process.argv; + originalNoColor = process.env.NO_COLOR; + originalChalkLevel = chalk.level; + }); + + afterEach(() => { + process.argv = originalArgv; + process.env.NO_COLOR = originalNoColor; + chalk.level = originalChalkLevel; + }); + + describe('chalk.level = 0 when --no-color is present', () => { + it('disables chalk colors when --no-color flag is set', () => { + process.argv = ['node', 'cli', 'devices', 'list', '--no-color']; + // Simulate the early initialization in src/index.ts + if (process.argv.includes('--no-color') || Boolean(process.env.NO_COLOR)) { + chalk.level = 0; + } + expect(chalk.level).toBe(0); + // With level 0, chalk should return plain strings + expect(chalk.red('error')).toBe('error'); + expect(chalk.green('success')).toBe('success'); + }); + + it('disables chalk colors when NO_COLOR env var is set (non-empty)', () => { + process.argv = ['node', 'cli', 'devices', 'list']; + process.env.NO_COLOR = '1'; + // Simulate the early initialization + if (process.argv.includes('--no-color') || Boolean(process.env.NO_COLOR)) { + chalk.level = 0; + } + expect(chalk.level).toBe(0); + expect(chalk.cyan('test')).toBe('test'); + }); + + it('respects empty NO_COLOR env var (no effect)', () => { + chalk.level = 3; // Reset to default color support + process.argv = ['node', 'cli', 'devices', 'list']; + process.env.NO_COLOR = ''; + // Empty string should not trigger disabling + if (process.argv.includes('--no-color') || Boolean(process.env.NO_COLOR)) { + chalk.level = 0; + } + // chalk.level should still be 3 (colors enabled) + expect(chalk.level).toBe(3); + }); + + it('respects unset NO_COLOR env var (no effect)', () => { + chalk.level = 3; + delete process.env.NO_COLOR; + process.argv = ['node', 'cli', 'devices', 'list']; + // No NO_COLOR or --no-color should leave chalk enabled + if (process.argv.includes('--no-color') || Boolean(process.env.NO_COLOR)) { + chalk.level = 0; + } + expect(chalk.level).toBe(3); + }); + + it('produces plain text output (no ANSI escape sequences) with chalk.level = 0', () => { + chalk.level = 0; + const output = chalk.red('error') + chalk.green(' success ') + chalk.cyan('info'); + // Should contain no ANSI escape sequences + expect(output).not.toMatch(/\u001b\[/); + expect(output).toBe('error success info'); + }); + + it('produces ANSI-colored output with chalk.level = 3', () => { + chalk.level = 3; + const redText = chalk.red('error'); + const greenText = chalk.green('success'); + // Should contain ANSI escape sequences + expect(redText).toMatch(/\u001b\[/); + expect(greenText).toMatch(/\u001b\[/); + }); + }); + + describe('priority: --no-color takes precedence', () => { + it('--no-color disables colors even if NO_COLOR is empty', () => { + process.argv = ['node', 'cli', '--no-color']; + process.env.NO_COLOR = ''; + if (process.argv.includes('--no-color') || Boolean(process.env.NO_COLOR)) { + chalk.level = 0; + } + expect(chalk.level).toBe(0); + }); + }); +}); diff --git a/tests/utils/format.test.ts b/tests/utils/format.test.ts index 41b947bd..62a516e0 100644 --- a/tests/utils/format.test.ts +++ b/tests/utils/format.test.ts @@ -10,6 +10,7 @@ vi.mock('../../src/utils/flags.js', () => ({ getCacheMode: vi.fn(() => ({ listTtlMs: 0, statusTtlMs: 0 })), getFormat: vi.fn(() => undefined), getFields: vi.fn(() => undefined), + getTableStyle: vi.fn(() => 'unicode'), })); import { parseFormat, filterFields, renderRows, resolveFormat, type OutputFormat } from '../../src/utils/format.js'; @@ -26,6 +27,7 @@ describe('parseFormat', () => { expect(parseFormat('yaml')).toBe('yaml'); expect(parseFormat('id')).toBe('id'); expect(parseFormat('table')).toBe('table'); + expect(parseFormat('markdown')).toBe('markdown'); }); it('is case-insensitive', () => { @@ -172,6 +174,17 @@ describe('renderRows', () => { expect(combined).toContain('a: null'); expect(combined).toContain('b: ok'); }); + + it('markdown: renders pipe-delimited table with header separator, ignoring getTableStyle', () => { + // getTableStyle mock still returns 'unicode'; --format markdown must still + // produce markdown output (style override at call site). + renderRows(headers, rows, 'markdown'); + const combined = logOutput.join('\n'); + expect(combined).toContain('| deviceId | name | type |'); + expect(combined).toMatch(/\|\s*-+\s*\|\s*-+\s*\|\s*-+\s*\|/); + expect(combined).toContain('| DEV1 | Light | Bot |'); + expect(combined).toContain('| DEV2 | Door | Smart Lock |'); + }); }); import { afterEach } from 'vitest'; diff --git a/tests/utils/name-resolver.test.ts b/tests/utils/name-resolver.test.ts index 6f76485f..2e1d062b 100644 --- a/tests/utils/name-resolver.test.ts +++ b/tests/utils/name-resolver.test.ts @@ -169,3 +169,94 @@ describe('resolveDeviceId narrowing + strategies', () => { expect(structured.context?.hint).toMatch(/--name-type|--name-room|--name-category|deviceId|--name-strategy/); }); }); + +// Bug #1 regression suite: require-unique must not short-circuit on exact match +describe('resolveDeviceId require-unique exact-match edge cases (bug #1)', () => { + let tmpHome: string; + + beforeEach(() => { + tmpHome = fs.mkdtempSync(`${os.tmpdir()}/sbcli-resolver-bug1-`); + vi.spyOn(os, 'homedir').mockReturnValue(tmpHome); + resetListCache(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + resetListCache(); + try { fs.rmSync(tmpHome, { recursive: true, force: true }); } catch { /* */ } + }); + + it('require-unique: exact match + substring match → ambiguous (reporter scenario)', () => { + // Device A has name exactly '空调'; device B has '空调' as a substring. + // Under require-unique the caller expects a unique match, but since B also + // matches (substring) the result must be ambiguous — NOT a silent pick of A. + updateCacheFromDeviceList({ + deviceList: [], + infraredRemoteList: [ + { deviceId: 'A', deviceName: '空调', remoteType: 'Air Conditioner', hubDeviceId: 'H1' }, + { deviceId: 'B', deviceName: '卧室空调', remoteType: 'Air Conditioner', hubDeviceId: 'H1' }, + ], + }); + + let err: Error | null = null; + try { + resolveDeviceId(undefined, '空调', { strategy: 'require-unique' }); + } catch (e) { + err = e as Error; + } + expect(err).not.toBeNull(); + expect(err!.message).toMatch(/ambiguous/i); + const structured = err as { context?: { error?: string; candidates?: unknown[] } }; + expect(structured.context?.error).toBe('ambiguous_name_match'); + expect(Array.isArray(structured.context?.candidates)).toBe(true); + expect((structured.context?.candidates as unknown[]).length).toBeGreaterThanOrEqual(2); + }); + + it('require-unique: two devices with the same exact name → ambiguous', () => { + updateCacheFromDeviceList({ + deviceList: [], + infraredRemoteList: [ + { deviceId: 'A', deviceName: '空调', remoteType: 'Air Conditioner', hubDeviceId: 'H1' }, + { deviceId: 'B', deviceName: '空调', remoteType: 'Air Conditioner', hubDeviceId: 'H1' }, + ], + }); + + let err: Error | null = null; + try { + resolveDeviceId(undefined, '空调', { strategy: 'require-unique' }); + } catch (e) { + err = e as Error; + } + expect(err).not.toBeNull(); + expect(err!.message).toMatch(/ambiguous/i); + const structured = err as { context?: { error?: string } }; + expect(structured.context?.error).toBe('ambiguous_name_match'); + }); + + it('require-unique: single exact match, no other matches → succeeds', () => { + updateCacheFromDeviceList({ + deviceList: [], + infraredRemoteList: [ + { deviceId: 'A', deviceName: '空调', remoteType: 'Air Conditioner', hubDeviceId: 'H1' }, + { deviceId: 'B', deviceName: '风扇', remoteType: 'Fan', hubDeviceId: 'H1' }, + ], + }); + + expect(resolveDeviceId(undefined, '空调', { strategy: 'require-unique' })).toBe('A'); + }); + + it('fuzzy: exact match short-circuits even with a substring match elsewhere (no regression)', () => { + // Under fuzzy strategy the exact hit SHOULD short-circuit — ensure we do not regress that. + updateCacheFromDeviceList({ + deviceList: [], + infraredRemoteList: [ + { deviceId: 'A', deviceName: '空调', remoteType: 'Air Conditioner', hubDeviceId: 'H1' }, + { deviceId: 'B', deviceName: '卧室空调', remoteType: 'Air Conditioner', hubDeviceId: 'H1' }, + ], + }); + + // fuzzy: exact-match wins immediately → returns A, no ambiguity error + const id = resolveDeviceId(undefined, '空调', { strategy: 'fuzzy' }); + expect(id).toBe('A'); + }); +});