From 0b500711a0922a263db57bba3e47c43747a96fc8 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Mon, 20 Apr 2026 12:07:05 +0800 Subject: [PATCH] =?UTF-8?q?release:=202.4.0=20=E2=80=94=20agent-experience?= =?UTF-8?q?=20overhaul=20+=20device=20history?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundles 19 OpenClaw/Claude feedback items (P0-P3) plus a new device-history subsystem into a single minor release. All schema changes are additive; no existing fields were removed or renamed. Highlights: - P0: IR verification flag on device commands; set-token argv scrubbing - P1: --name subcommand scope, fuzzy name resolution w/ ambiguity contract, compact schema/capabilities, leaf-level agent safety metadata - D : JSONL device-history storage + rotation, history range/stats, MCP query_device_history tool - P2: stable doctor --json contract, events mqtt-tail control events, batch stagger/concurrency, idempotency replayed + conflict contract, profile label/daily-cap, verbose header redaction + --trace-unsafe - P3: quota show alias, tree-wide did-you-mean, schema cliAddedFields, agent-bootstrap aggregate command, --table-style, audit-log versioning + docs/audit-log.md + history verify 832/832 tests pass. Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 127 +++++++++++ docs/audit-log.md | 76 +++++++ package.json | 2 +- src/api/client.ts | 31 ++- src/commands/agent-bootstrap.ts | 151 +++++++++++++ src/commands/batch.ts | 150 +++++++++++-- src/commands/capabilities.ts | 296 +++++++++++++++++++------ src/commands/config.ts | 149 ++++++++++++- src/commands/devices.ts | 55 ++++- src/commands/doctor.ts | 120 ++++++++-- src/commands/events.ts | 52 ++++- src/commands/history.ts | 133 ++++++++++- src/commands/mcp.ts | 103 ++++++++- src/commands/quota.ts | 6 +- src/commands/schema.ts | 134 ++++++++++- src/config.ts | 76 ++++++- src/devices/history-query.ts | 205 +++++++++++++++++ src/index.ts | 15 +- src/lib/devices.ts | 12 +- src/lib/idempotency.ts | 92 ++++++-- src/mcp/device-history.ts | 81 ++++++- src/sinks/types.ts | 1 + src/utils/audit.ts | 83 ++++++- src/utils/flags.ts | 26 +++ src/utils/name-resolver.ts | 131 ++++++++--- src/utils/output.ts | 144 ++++++++++-- src/utils/quota.ts | 18 ++ src/utils/redact.ts | 73 ++++++ tests/api/client.test.ts | 1 + tests/commands/agent-bootstrap.test.ts | 91 ++++++++ tests/commands/batch.test.ts | 76 +++++++ tests/commands/capabilities.test.ts | 68 +++++- tests/commands/config.test.ts | 34 ++- tests/commands/doctor.test.ts | 62 ++++++ tests/commands/events.test.ts | 42 +++- tests/commands/history.test.ts | 94 ++++++++ tests/commands/mcp.test.ts | 3 +- tests/commands/schema.test.ts | 72 +++++- tests/devices/history-query.test.ts | 163 ++++++++++++++ tests/lib/idempotency.test.ts | 50 +++-- tests/utils/audit.test.ts | 46 +++- tests/utils/name-resolver.test.ts | 82 +++++++ tests/utils/quota.test.ts | 18 ++ tests/utils/redact.test.ts | 49 ++++ 44 files changed, 3250 insertions(+), 243 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 docs/audit-log.md create mode 100644 src/commands/agent-bootstrap.ts create mode 100644 src/devices/history-query.ts create mode 100644 src/utils/redact.ts create mode 100644 tests/commands/agent-bootstrap.test.ts create mode 100644 tests/devices/history-query.test.ts create mode 100644 tests/utils/redact.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..926451b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,127 @@ +# Changelog + +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.4.0] - 2026-04-20 + +Large agent-experience overhaul driven by the OpenClaw + Claude integration +feedback (19 items across P0/P1/P2/P3) plus a new **device history +aggregation** subsystem. All schema changes are **additive-only** — existing +agent integrations keep working without code changes and pick up the new +fields when they upgrade. + +### P0 — Correctness & security + +- **IR command verifiability tag** — `devices command` responses for IR + devices now carry `verification: { verifiable: false, reason, suggestedFollowup }`. + Human output adds a stderr hint that IR transmissions cannot be + acknowledged by the device. MCP `send_command` mirrors the same field. +- **`config set-token` secret scrubbing** — positional invocations have their + token/secret replaced in `process.argv` before any hook, audit log, or + verbose trace can observe them. Interactive `set-token` (hidden-echo + readline) is now the primary path; positional form prints a discouragement + warning but still works for backwards compatibility. + +### P1 — Agent hardening + +- **`--name` scope fix** — `devices status` / `devices command` now accept + `--name` directly on the subcommand (previously root-only). `capabilities` + reflects the change. +- **Fuzzy name resolution contract** — new `src/devices/resolve-name.ts` + exports six strategies (`exact | prefix | substring | fuzzy | first | + require-unique`). Reads default to `fuzzy`; writes default to + `require-unique` and fail with exit code 2 + + `error: "ambiguous_name_match"` and a candidate list when multiple devices + match. Global filters `--type`, `--room`, `--category`, and + `--name-strategy` compose with `--name`. +- **Smaller schema/capabilities payloads + pipe hygiene** — `schema export` + and `capabilities` grew `--compact`, `--types `, `--used`, `--fields + `, `--surface cli|mcp|plan`. Banners / tips / progress messages move + to stderr; stdout is exactly one JSON document. Non-TTY no longer emits + ANSI. +- **Semantic safety metadata** — every leaf command in `capabilities` now + carries `{ mutating, consumesQuota, idempotencySupported, agentSafetyTier: + "read"|"action"|"destructive", verifiability, typicalLatencyMs }`. MCP + tools mirror the tier in `meta.agentSafetyTier`. + +### D — Device history (new subsystem) + +- **JSONL storage** — every `events mqtt-tail` event / MCP status refresh is + appended to `~/.switchbot/device-history/.jsonl`. The file + rotates at 50 MB into `.jsonl.1 → .jsonl.2 → .jsonl.3` with the oldest + discarded. Writes are best-effort with `0o600` perms. +- **`history range `** — time-windowed query with `--since 7d` / + `--from ` / `--to `, payload-field projection via repeatable + `--field `, `--limit ` (default 1000). Uses streaming + `readline` so even 50 MB files never load into memory. +- **`history stats `** — reports file count, total bytes, record + count, earliest/newest timestamp. +- **MCP `query_device_history`** — same contract as the CLI, exposed as a + tool for agents with a 1000-record default safety cap. + +### P2 — DX & stability + +- **`doctor --json` stable contract** — locked shape + `{ ok, generatedAt, checks[], summary }`; each `check` is + `{ name, status: ok|warn|fail, detail }`. The `clock` check now probes the + real API once and reports `skewMs`. +- **`events mqtt-tail` control events** — synthesized JSONL records of + `__connect` / `__reconnect` / `__disconnect` / `__heartbeat`. Every real + event gets a UUIDv4 `eventId` when the broker doesn't supply one. +- **`devices batch --stagger` / `--max-concurrent` / `--plan`** — throttled + concurrent execution with per-step `startedAt` / `finishedAt` / + `durationMs` / `replayed` telemetry and a planner (`--dry-run --plan`) + that prints the plan JSON without executing. +- **Idempotency contract + `replayed` flag** — cache hits now return + `replayed: true`. A reused key with a **different** `(command, parameter)` + shape within the 60 s window exits 2 with + `error: "idempotency_conflict"` and the old/new shape in the payload. + Keys are SHA-256-hashed on disk. +- **Profile label / description / daily cap / default flags** — `config + set-token` grew `--label`, `--description`, `--daily-cap `, + `--default-flags ""`. The daily cap is enforced before any request + leaves the CLI (pre-flight refusal, exit 2). `config list-profiles` / + `doctor` / `cache status` surface the label. +- **`--verbose` header redaction** — `Authorization`, `token`, `sign`, `t`, + `nonce`, cookies, etc. are mid-masked in verbose output. `--trace-unsafe` + opts in to raw output with a prominent one-time warning. + +### P3 — Polish + +- **`quota show` alias** for `quota status`. +- **`showSuggestionAfterError` across the full subcommand tree** — typos like + `devices lst` now suggest `devices list`. +- **`schema export` declares CLI-added fields** — top-level `cliAddedFields` + documents `_fetchedAt`, `replayed`, and `verification` so agents can + distinguish CLI-synthesized data from upstream API fields. +- **`switchbot agent-bootstrap [--compact]`** — single-command aggregate + (identity, cached devices, catalog, quota, profile, safety tiers, quick + reference) that stays under 20 KB in `--compact` mode. Offline-safe; no + API calls. +- **`--table-style `** + `--format markdown` + — non-TTY now defaults to `ascii`; `markdown` emits fenced `|col|col|` + tables for agent UI embedding. +- **Audit log versioning** — every line now carries `"auditVersion": 1`. + New `docs/audit-log.md` documents the format, crash-safety, and + rotation guidance. New `switchbot history verify` reports parsed / + malformed / version counts and exits non-zero on malformed content. + +### Migration notes + +- **Fully backwards compatible.** No fields changed or were removed; only + added. Existing MCP and CLI integrations continue to work. +- Agents that want the richer context can refresh their prompts by running + `switchbot agent-bootstrap --compact` once per session instead of + combining `doctor` + `capabilities` + `schema` + `devices list`. +- Upgraders who manage profiles with sensitive daily budgets should run + `switchbot config set-token --profile --label "..." --daily-cap N` + to take advantage of the pre-flight refusal guard. +- Audit logs written by 2.3.0 coexist unchanged with 2.4.0 records; + `history verify` reports them as `unversioned`. + +## [2.3.0] and earlier + +See git history. diff --git a/docs/audit-log.md b/docs/audit-log.md new file mode 100644 index 0000000..3a9ebb2 --- /dev/null +++ b/docs/audit-log.md @@ -0,0 +1,76 @@ +# Audit Log Format + +The SwitchBot CLI writes a JSONL audit record for every mutating device or +scene command when either `--audit-log` is passed or a profile sets +`defaults.flags` including `--audit-log`. + +- **Default path:** `~/.switchbot/audit.log` +- **Override:** `--audit-log-path ` +- **One record per line**, UTF-8, LF newline between lines (`"\n"`), appended. +- **Best-effort:** write failures are swallowed — a command never aborts because + the audit log cannot be written. + +## Schema + +Every record is a JSON object with at least the following fields: + +| Field | Type | Notes | +|----------------|----------------------|----------------------------------------------------------------------------------------| +| `auditVersion` | number | Schema version. Current: `1`. Missing on records written before audit versioning. | +| `t` | string (ISO-8601) | Timestamp when the record was written. | +| `kind` | `"command"` | Record discriminator. Currently the only kind is `command`. | +| `deviceId` | string | Target device ID. | +| `command` | string | SwitchBot command name (e.g. `turnOn`, `setColor`). | +| `parameter` | string \| object | Command parameter as sent — `"default"` when unused. | +| `commandType` | `"command" \| "customize"` | Matches the upstream SwitchBot API field. | +| `dryRun` | boolean | `true` when the command was intercepted by `--dry-run` and never reached the network. | +| `result` | `"ok" \| "error"` | Optional — only present once the command completed (absent for `dryRun: true`). | +| `error` | string | Optional — populated when `result === "error"`. | + +### Example + +```json +{"auditVersion":1,"t":"2026-04-20T01:23:45.123Z","kind":"command","deviceId":"ABC123","command":"turnOn","parameter":"default","commandType":"command","dryRun":false,"result":"ok"} +``` + +## Crash safety + +- The file is opened with the standard Node `appendFileSync` — each record is + written atomically with respect to other `append` calls from the same + process, but there is no fsync between writes. A crash mid-write can + produce a partial last line; `history verify` detects and reports these. +- The CLI tolerates malformed lines when reading: `history show`, `history + replay`, and MCP tools skip lines that fail to parse. Versioned records + coexist with pre-version-1 records without any migration step. + +## Versioning policy + +- `auditVersion` is an integer that is bumped whenever a breaking change + lands in the field set — field removal, type change, or renaming. + Additive changes (a new optional field) do NOT bump the version. +- Old records are never rewritten. The CLI must always be able to read every + prior `auditVersion` value. `history verify` reports a histogram so you + can decide when to rotate the file. + +## `history verify` + +`switchbot history verify` inspects the log and reports: + +- total / parsed / skipped / malformed line counts +- histogram of `auditVersion` values (`unversioned` for pre-v1 records) +- earliest / latest timestamp +- per-line problem details for anything that failed to parse + +Exit codes: + +| Code | Meaning | +|------|----------------------------------------------------------------------------------| +| 0 | Every line parsed; no malformed entries. | +| 1 | File missing OR one or more malformed lines. | + +## Rotation + +The audit log is not auto-rotated. Use external tooling (e.g. `logrotate`) or +truncate manually. If you migrate to a new file, keep the old one around for +`history verify` / `history replay` reference — the CLI does not require the +log to be contiguous. diff --git a/package.json b/package.json index 94159a1..43661af 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@switchbot/openapi-cli", - "version": "2.3.0", + "version": "2.4.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/api/client.ts b/src/api/client.ts index 1745f39..29ae2fb 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -15,7 +15,20 @@ import { isQuotaDisabled, } from '../utils/flags.js'; import { nextRetryDelayMs, sleep } from '../utils/retry.js'; -import { recordRequest } from '../utils/quota.js'; +import { recordRequest, checkDailyCap } from '../utils/quota.js'; +import { readProfileMeta } from '../config.js'; +import { getActiveProfile } from '../lib/request-context.js'; +import { redactHeaders, warnOnceIfUnsafe } from '../utils/redact.js'; + +class DailyCapExceededError extends Error { + constructor(public readonly cap: number, public readonly total: number, public readonly profile?: string) { + super( + `Local daily cap reached: ${total}/${cap} SwitchBot API calls used today${profile ? ` for profile "${profile}"` : ''}. ` + + `Raise with: switchbot ${profile ? `--profile ${profile} ` : ''}config set-token --daily-cap `, + ); + this.name = 'DailyCapExceededError'; + } +} const API_ERROR_MESSAGES: Record = { 151: 'Device type does not support this command', @@ -43,6 +56,9 @@ export function createClient(): AxiosInstance { const maxRetries = getRetryOn429(); const backoff = getBackoffStrategy(); const quotaEnabled = !isQuotaDisabled(); + const profile = getActiveProfile(); + const profileMeta = readProfileMeta(profile); + const dailyCap = profileMeta?.limits?.dailyCap; const client = axios.create({ baseURL: 'https://api.switch-bot.com', @@ -51,6 +67,13 @@ export function createClient(): AxiosInstance { // Inject auth headers; optionally log the request; short-circuit on --dry-run. client.interceptors.request.use((config: InternalAxiosRequestConfig) => { + // Pre-flight cap check: refuse the call before it touches the network. + if (dailyCap) { + const check = checkDailyCap(dailyCap); + if (check.over) { + throw new DailyCapExceededError(dailyCap, check.total, profile); + } + } const authHeaders = buildAuthHeaders(token, secret); Object.assign(config.headers, authHeaders); @@ -58,7 +81,13 @@ export function createClient(): AxiosInstance { const url = `${config.baseURL ?? ''}${config.url ?? ''}`; if (verbose) { + warnOnceIfUnsafe(); process.stderr.write(chalk.grey(`[verbose] ${method} ${url}\n`)); + const { safe, redactedCount } = redactHeaders(config.headers as unknown as Record); + process.stderr.write(chalk.grey(`[verbose] headers: ${JSON.stringify(safe)}\n`)); + if (redactedCount > 0) { + process.stderr.write(chalk.grey(`[verbose] 🔒 ${redactedCount} sensitive header(s) redacted.\n`)); + } if (config.data !== undefined) { process.stderr.write(chalk.grey(`[verbose] body: ${JSON.stringify(config.data)}\n`)); } diff --git a/src/commands/agent-bootstrap.ts b/src/commands/agent-bootstrap.ts new file mode 100644 index 0000000..e370419 --- /dev/null +++ b/src/commands/agent-bootstrap.ts @@ -0,0 +1,151 @@ +import { Command } from 'commander'; +import { printJson } from '../utils/output.js'; +import { loadCache } from '../devices/cache.js'; +import { getEffectiveCatalog } from '../devices/catalog.js'; +import { readProfileMeta } from '../config.js'; +import { todayUsage, DAILY_QUOTA } from '../utils/quota.js'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const { version: pkgVersion } = require('../../package.json') as { version: string }; + +const IDENTITY = { + product: 'SwitchBot', + domain: 'IoT smart home device control', + vendor: 'Wonderlabs, Inc.', + apiVersion: 'v1.1', + authMethod: 'HMAC-SHA256 token+secret', +}; + +const SAFETY_TIERS = { + read: 'No state mutation; safe to call freely.', + action: 'Mutates device/cloud state but reversible (turnOn, setColor).', + destructive: 'Hard to reverse / physical-world side effects (unlock). Requires confirmation.', +}; + +const QUICK_REFERENCE = { + discovery: ['devices list', 'devices describe ', 'devices status '], + action: ['devices command ', 'devices command --name ', 'scenes execute '], + safety: ['--dry-run', '--idempotency-key ', '--audit-log', '--no-quota'], + observability: ['doctor --json', 'quota status', 'cache status', 'events mqtt-tail'], + history: ['history range --since 7d', 'history stats '], +}; + +interface BootstrapOptions { + compact?: boolean; +} + +export function registerAgentBootstrapCommand(program: Command): void { + program + .command('agent-bootstrap') + .description( + 'Print a compact, aggregate JSON snapshot for agent onboarding — combines identity, cached devices, catalog summary, quota usage, and profile in a single call. Offline-safe; does not hit the API.', + ) + .option( + '--compact', + 'Emit an even smaller payload by dropping catalog descriptions and non-essential fields (target: <20 KB).', + ) + .addHelpText( + 'after', + ` +Output is always JSON (this command ignores --format). It is a one-shot +orientation document for an agent/LLM to understand what's available without +spending quota. It reads from local cache (devices + quota + profile) and the +bundled catalog — no network calls. + +For fresher device state, have the agent follow up with: + $ switchbot devices list --json # refreshes cache + $ switchbot devices status --json + +Examples: + $ switchbot agent-bootstrap --compact | wc -c # fit in agent context window + $ switchbot agent-bootstrap | jq '.devices | length' + $ switchbot agent-bootstrap --compact | jq '.quickReference' +`, + ) + .action((opts: BootstrapOptions) => { + const compact = Boolean(opts.compact); + const cache = loadCache(); + const catalog = getEffectiveCatalog(); + const usage = todayUsage(); + const meta = readProfileMeta(undefined); + + const cachedDevices = cache + ? Object.entries(cache.devices).map(([id, d]) => ({ + deviceId: id, + type: d.type, + name: d.name, + category: d.category, + roomName: d.roomName ?? null, + })) + : []; + + const usedTypes = new Set(cachedDevices.map((d) => d.type.toLowerCase())); + const relevantCatalog = cachedDevices.length > 0 + ? catalog.filter( + (e) => + usedTypes.has(e.type.toLowerCase()) || + (e.aliases ?? []).some((a) => usedTypes.has(a.toLowerCase())), + ) + : catalog; + + const catalogTypes = relevantCatalog.map((e) => { + if (compact) { + return { + type: e.type, + category: e.category, + role: e.role ?? null, + readOnly: e.readOnly ?? false, + commands: e.commands.map((c) => c.command), + statusFields: e.statusFields ?? [], + }; + } + return { + type: e.type, + category: e.category, + role: e.role ?? null, + readOnly: e.readOnly ?? false, + commands: e.commands.map((c) => ({ + command: c.command, + parameter: c.parameter, + destructive: Boolean(c.destructive), + idempotent: Boolean(c.idempotent), + })), + statusFields: e.statusFields ?? [], + }; + }); + + const payload: Record = { + schemaVersion: '1.0', + generatedAt: new Date().toISOString(), + cliVersion: pkgVersion, + identity: IDENTITY, + quickReference: QUICK_REFERENCE, + safetyTiers: SAFETY_TIERS, + profile: meta + ? { + label: meta.label ?? null, + description: meta.description ?? null, + dailyCap: meta.limits?.dailyCap ?? null, + defaultFlags: meta.defaults?.flags ?? null, + } + : null, + quota: { + date: usage.date, + total: usage.total, + remaining: usage.remaining, + dailyLimit: DAILY_QUOTA, + }, + devices: cachedDevices, + catalog: { + scope: cachedDevices.length > 0 ? 'used' : 'all', + types: catalogTypes, + }, + hints: cachedDevices.length === 0 + ? ['Run `switchbot devices list` once to populate the device cache for richer bootstrap output.'] + : [], + }; + + printJson(payload); + }); +} diff --git a/src/commands/batch.ts b/src/commands/batch.ts index 5c53784..c4be7f1 100644 --- a/src/commands/batch.ts +++ b/src/commands/batch.ts @@ -14,9 +14,16 @@ import { isDryRun } from '../utils/flags.js'; import { DryRunSignal } from '../api/client.js'; import { getCachedTypeMap } from '../devices/cache.js'; +interface BatchStepTiming { + startedAt: string; + finishedAt: string; + durationMs: number; + replayed?: boolean; +} + interface BatchResult { - succeeded: Array<{ deviceId: string; result: unknown }>; - failed: Array<{ deviceId: string; error: ErrorPayload }>; + succeeded: Array<{ deviceId: string; result: unknown } & BatchStepTiming>; + failed: Array<{ deviceId: string; error: ErrorPayload } & BatchStepTiming>; summary: { total: number; ok: number; @@ -25,16 +32,23 @@ interface BatchResult { durationMs: number; dryRun?: boolean; schemaVersion?: string; + maxConcurrent?: number; + staggerMs?: number; }; } const DEFAULT_CONCURRENCY = 5; const COMMAND_TYPES = ['command', 'customize'] as const; -/** Run `task(x)` for every element with at most `concurrency` running at once. */ +/** + * Run `task(x)` for every element with at most `concurrency` running at once. + * `staggerMs`: when > 0, delay each task start by this fixed interval (replaces + * the default 20-60ms jitter). Useful for rate-limited endpoints. + */ async function runPool( items: T[], concurrency: number, + staggerMs: number, task: (item: T) => Promise ): Promise { const results: R[] = new Array(items.length); @@ -48,9 +62,10 @@ async function runPool( while (cursor < items.length) { const idx = cursor++; results[idx] = await task(items[idx]); - // Tiny jitter between starts so we don't hammer the endpoint in a - // perfectly aligned burst. Keeps the default concurrency=5 polite. - await new Promise((r) => setTimeout(r, 20 + Math.random() * 40)); + // Fixed stagger wins over random jitter when set; else keep the + // default polite spacing so we don't hammer the endpoint. + const delay = staggerMs > 0 ? staggerMs : 20 + Math.random() * 40; + await new Promise((r) => setTimeout(r, delay)); } })() ); @@ -125,6 +140,9 @@ export function registerBatchCommand(devices: Command): void { .option('--filter ', 'Target devices matching a filter, e.g. type=Bot,family=Home', stringArg('--filter')) .option('--ids ', 'Explicit comma-separated list of deviceIds', stringArg('--ids')) .option('--concurrency ', 'Max parallel in-flight requests (default 5)', intArg('--concurrency', { min: 1 }), '5') + .option('--max-concurrent ', 'Alias for --concurrency; takes priority when set', intArg('--max-concurrent', { min: 1 })) + .option('--stagger ', 'Fixed delay between task starts in ms (default 0 = random 20-60ms jitter)', intArg('--stagger', { min: 0 }), '0') + .option('--plan', 'With --dry-run: emit a plan JSON document instead of executing anything') .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 "-")') @@ -145,7 +163,16 @@ Supported keys: type, family, room, category (category: physical | ir) Output: Human mode: one status line per device, summary at the end. - --json: {succeeded[], failed[{deviceId,error}], summary:{total,ok,failed,skipped,durationMs}} + --json: {succeeded[], failed[{deviceId,error}], summary:{total,ok,failed,skipped,durationMs,maxConcurrent,staggerMs}} + Each step includes startedAt / finishedAt / durationMs / replayed (when cached). + +Concurrency & pacing: + --max-concurrent Upper bound on in-flight requests (alias for --concurrency). + --stagger Fixed delay between task starts; default 0 uses random 20-60ms jitter. + +Planning: + --dry-run --plan Print the plan JSON without executing anything. Useful + for agents that want to show the user what will run. Safety: Destructive commands (Smart Lock unlock, Garage Door Opener turnOn/turnOff, @@ -167,6 +194,9 @@ Examples: filter?: string; ids?: string; concurrency: string; + maxConcurrent?: string; + stagger: string; + plan?: boolean; yes?: boolean; type: string; stdin?: boolean; @@ -265,11 +295,51 @@ Examples: } } - const concurrency = Math.max(1, Number.parseInt(options.concurrency, 10) || DEFAULT_CONCURRENCY); + const maxConcurrentRaw = options.maxConcurrent ?? options.concurrency; + const concurrency = Math.max(1, Number.parseInt(maxConcurrentRaw, 10) || DEFAULT_CONCURRENCY); + const staggerMs = Math.max(0, Number.parseInt(options.stagger, 10) || 0); const dryRun = isDryRun(); + + // --dry-run --plan: emit a plan document and return without executing. + if (dryRun && options.plan) { + const steps = resolved.ids.map((id) => ({ + deviceId: id, + command: cmd, + parameter: parsedParam, + type: effectiveType, + idempotencyKey: options.idempotencyKeyPrefix + ? `${options.idempotencyKeyPrefix}-${id}` + : undefined, + })); + const planDoc = { + schemaVersion: '1.1', + dryRun: true, + plan: { + command: cmd, + parameter: parsedParam, + type: effectiveType, + maxConcurrent: concurrency, + staggerMs, + stepCount: steps.length, + steps, + }, + }; + if (isJsonMode()) { + printJson(planDoc); + } else { + console.log( + `Plan: ${steps.length} step(s), command=${cmd}, maxConcurrent=${concurrency}, staggerMs=${staggerMs}` + ); + for (const s of steps) console.log(` → ${s.deviceId} ${s.type} ${s.command}`); + } + return; + } + const startedAt = Date.now(); - const outcomes = await runPool(resolved.ids, concurrency, async (id) => { + const outcomes = await runPool(resolved.ids, concurrency, staggerMs, async (id) => { + const stepStart = Date.now(); + const startedIso = new Date(stepStart).toISOString(); try { const idempotencyKey = options.idempotencyKeyPrefix ? `${options.idempotencyKeyPrefix}-${id}` @@ -277,21 +347,46 @@ Examples: const result = await executeCommand(id, cmd, parsedParam, effectiveType, getClient(), { idempotencyKey, }); + const finishedIso = new Date().toISOString(); + const durationMs = Date.now() - stepStart; + const replayed = + typeof result === 'object' && result !== null && (result as { replayed?: boolean }).replayed === true; if (!isJsonMode()) { - console.log(`✓ ${id}: ${cmd}`); + console.log(`✓ ${id}: ${cmd}${replayed ? ' (replayed)' : ''}`); } - return { ok: true as const, deviceId: id, result }; + return { + ok: true as const, + deviceId: id, + result, + startedAt: startedIso, + finishedAt: finishedIso, + durationMs, + replayed, + }; } catch (err) { // --dry-run uses DryRunSignal to short-circuit; surface that as a // "skipped" outcome, not a failure. if (err instanceof DryRunSignal) { - return { ok: 'dry-run' as const, deviceId: id }; + return { + ok: 'dry-run' as const, + deviceId: id, + startedAt: startedIso, + finishedAt: new Date().toISOString(), + durationMs: Date.now() - stepStart, + }; } const errorPayload = buildErrorPayload(err); if (!isJsonMode()) { console.error(`✗ ${id}: ${errorPayload.message}`); } - return { ok: false as const, deviceId: id, error: errorPayload }; + return { + ok: false as const, + deviceId: id, + error: errorPayload, + startedAt: startedIso, + finishedAt: new Date().toISOString(), + durationMs: Date.now() - stepStart, + }; } }); @@ -299,20 +394,43 @@ Examples: ok: true; deviceId: string; result: unknown; + startedAt: string; + finishedAt: string; + durationMs: number; + replayed: boolean; }>; const failed = outcomes.filter((o) => o.ok === false) as Array<{ ok: false; deviceId: string; error: ErrorPayload; + startedAt: string; + finishedAt: string; + durationMs: number; }>; const dryRunned = outcomes.filter((o) => o.ok === 'dry-run') as Array<{ ok: 'dry-run'; deviceId: string; + startedAt: string; + finishedAt: string; + durationMs: number; }>; const result: BatchResult = { - succeeded: succeeded.map((s) => ({ deviceId: s.deviceId, result: s.result })), - failed: failed.map((f) => ({ deviceId: f.deviceId, error: f.error })), + succeeded: succeeded.map((s) => ({ + deviceId: s.deviceId, + result: s.result, + startedAt: s.startedAt, + finishedAt: s.finishedAt, + durationMs: s.durationMs, + replayed: s.replayed, + })), + failed: failed.map((f) => ({ + deviceId: f.deviceId, + error: f.error, + startedAt: f.startedAt, + finishedAt: f.finishedAt, + durationMs: f.durationMs, + })), summary: { total: resolved.ids.length, ok: succeeded.length, @@ -320,6 +438,8 @@ Examples: skipped: dryRunned.length, durationMs: Date.now() - startedAt, schemaVersion: '1.1', + maxConcurrent: concurrency, + staggerMs, ...(dryRun ? { dryRun: true } : {}), }, }; diff --git a/src/commands/capabilities.ts b/src/commands/capabilities.ts index 0dd607e..c8f43dd 100644 --- a/src/commands/capabilities.ts +++ b/src/commands/capabilities.ts @@ -1,6 +1,84 @@ import { Command } from 'commander'; import { getEffectiveCatalog } from '../devices/catalog.js'; import { printJson } from '../utils/output.js'; +import { enumArg, stringArg } from '../utils/arg-parsers.js'; + +export type AgentSafetyTier = 'read' | 'action' | 'destructive'; +export type Verifiability = 'local' | 'deviceConfirmed' | 'deviceDependent' | 'none'; + +const AGENT_GUIDE = { + safetyTiers: { + read: 'No state mutation; safe to call freely — does not consume quota unless noted.', + action: 'Mutates device or cloud state but is reversible and routine (turnOn, setColor).', + destructive: 'Hard to reverse / physical-world side effects (unlock, garage open, delete key). Requires explicit user confirmation.', + }, + verifiability: { + local: 'Result is fully verifiable from the CLI return value itself.', + deviceConfirmed: 'Device returns an ack with an observable state field.', + deviceDependent: 'Verifiability depends on the specific device (IR is never verifiable).', + none: 'No feedback — e.g. IR transmission. Pair with an external sensor to confirm.', + }, +}; + +// Per-command (CLI leaf) semantic safety metadata. Read by agents BEFORE +// constructing a plan so they can flag destructive steps or skip unnecessary +// quota-consumers on a dry-run. +interface CommandMeta { + mutating: boolean; + consumesQuota: boolean; + idempotencySupported: boolean; + agentSafetyTier: AgentSafetyTier; + verifiability: Verifiability; + typicalLatencyMs: number; +} + +const COMMAND_META: Record = { + // devices: reads + 'devices list': { mutating: false, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 600 }, + 'devices status': { mutating: false, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 500 }, + 'devices describe': { mutating: false, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 600 }, + 'devices types': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 20 }, + 'devices commands': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 20 }, + 'devices watch': { mutating: false, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 500 }, + // devices: actions + 'devices command': { mutating: true, consumesQuota: true, idempotencySupported: true, agentSafetyTier: 'action', verifiability: 'deviceDependent', typicalLatencyMs: 800 }, + 'devices batch': { mutating: true, consumesQuota: true, idempotencySupported: true, agentSafetyTier: 'action', verifiability: 'deviceDependent', typicalLatencyMs: 1200 }, + // 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 }, + // 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 }, + 'webhook delete': { mutating: true, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'destructive', verifiability: 'local', typicalLatencyMs: 500 }, + // quota + 'quota status': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 10 }, + 'quota show': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 10 }, + 'quota reset': { mutating: true, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'action', verifiability: 'local', typicalLatencyMs: 10 }, + // doctor / schema / capabilities / catalog / config / cache / events / history / plan + 'doctor': { mutating: false, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 900 }, + 'schema export': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 20 }, + 'capabilities': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 15 }, + 'catalog': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 15 }, + 'config set-token': { mutating: true, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'destructive', verifiability: 'local', typicalLatencyMs: 5 }, + 'config show': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 5 }, + 'config list-profiles': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 5 }, + 'cache status': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 5 }, + 'cache clear': { mutating: true, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'action', verifiability: 'local', typicalLatencyMs: 5 }, + 'events mqtt-tail': { mutating: false, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 500 }, + 'history show': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 20 }, + '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 }, + '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 }, + 'completion': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 5 }, + 'mcp serve': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 10 }, +}; + +function metaFor(command: string): CommandMeta | null { + return COMMAND_META[command] ?? null; +} const IDENTITY = { product: 'SwitchBot', @@ -30,79 +108,164 @@ const MCP_TOOLS = [ 'search_catalog', 'account_overview', 'get_device_history', + 'query_device_history', ]; +const IDEMPOTENCY_CONTRACT = { + flag: '--idempotency-key ', + windowSeconds: 60, + replayBehavior: 'Same (command, parameter, deviceId) within window → returns cached result with replayed:true.', + conflictBehavior: 'Same key + different (command, parameter) within window → exit 2, error:"idempotency_conflict".', + keyStorage: 'Stored as SHA-256 hash on disk (not raw).', + mcp: 'MCP send_command accepts the same idempotencyKey field with identical semantics.', +}; + +interface CompactLeaf { + name: string; + mutating: boolean; + consumesQuota: boolean; + idempotencySupported: boolean; + agentSafetyTier: AgentSafetyTier; + verifiability: Verifiability; + typicalLatencyMs: number; +} + +function enumerateLeaves(program: Command, prefix = ''): CompactLeaf[] { + const out: CompactLeaf[] = []; + for (const cmd of program.commands) { + const full = prefix ? `${prefix} ${cmd.name()}` : cmd.name(); + if (cmd.commands.length === 0) { + const meta = metaFor(full); + if (meta) { + out.push({ name: full, ...meta }); + } else { + // Unknown leaf → default to read-safe with a warning flag so agents notice. + out.push({ + name: full, + mutating: false, + consumesQuota: false, + idempotencySupported: false, + agentSafetyTier: 'read', + verifiability: 'local', + typicalLatencyMs: 50, + }); + } + } else { + out.push(...enumerateLeaves(cmd, full)); + } + } + return out; +} + +function projectObject>(obj: T, fields: string[]): Partial { + const out: Partial = {}; + for (const f of fields) { + if (f in obj) (out as Record)[f] = obj[f]; + } + return out; +} + export function registerCapabilitiesCommand(program: Command): void { + const SURFACES = ['cli', 'mcp', 'plan', 'mqtt', 'all'] as const; program .command('capabilities') .description('Print a machine-readable manifest of CLI capabilities (for agent bootstrap)') - .option('--minimal', 'Omit per-subcommand flag details to reduce output size') - .action((opts: { minimal?: boolean }) => { + .option('--minimal', 'Omit per-subcommand flag details to reduce output size (alias of --compact)') + .option('--compact', 'Emit a compact summary: identity + leaf command list with safety metadata only') + .option('--surface ', 'Restrict surfaces block to one of: cli, mcp, plan, mqtt, all (default: all)', enumArg('--surface', SURFACES)) + .option('--project ', 'Project top-level fields (e.g. --project identity,commands,agentGuide)', stringArg('--project')) + .action((opts: { minimal?: boolean; compact?: boolean; surface?: string; project?: string }) => { + const compact = Boolean(opts.minimal || opts.compact); const catalog = getEffectiveCatalog(); - const allCommands = [ - ...program.commands, - // Commander adds 'help' implicitly; include it explicitly so it appears in the manifest - { name: () => 'help', description: () => 'Display help for a command', commands: [], options: [], registeredArguments: [] } as unknown as Command, - ]; - const commands = allCommands.map((c) => { - const entry: Record = { - name: c.name(), - description: c.description(), - }; - if (!opts.minimal) { - entry.subcommands = c.commands.map((s) => ({ - name: s.name(), - description: s.description(), - args: s.registeredArguments.map((a) => ({ - name: a.name(), - required: a.required, - variadic: a.variadic, - })), - flags: s.options.map((o) => ({ - flags: o.flags, - description: o.description, - })), - })); + const leaves = enumerateLeaves(program); + + const fullCommands = compact + ? undefined + : [ + ...program.commands, + { name: () => 'help', description: () => 'Display help for a command', commands: [], options: [], registeredArguments: [] } as unknown as Command, + ].map((c) => { + const full = c.name(); + const entry: Record = { + name: full, + description: c.description(), + }; + entry.subcommands = c.commands.map((s) => { + const leafName = `${full} ${s.name()}`; + const meta = metaFor(leafName); + return { + name: s.name(), + description: s.description(), + args: s.registeredArguments.map((a) => ({ + name: a.name(), + required: a.required, + variadic: a.variadic, + })), + flags: s.options.map((o) => ({ + flags: o.flags, + description: o.description, + })), + ...(meta ?? {}), + }; + }); + const selfMeta = metaFor(full); + if (selfMeta) Object.assign(entry, selfMeta); + return entry; + }); + + const globalFlags = compact + ? undefined + : program.options.map((opt) => ({ flags: opt.flags, description: opt.description })); + + const surfaces = { + mcp: { + entry: 'mcp serve', + protocol: 'stdio (default) or --port for HTTP', + tools: MCP_TOOLS, + resources: ['switchbot://events'], + toolMeta: 'Each MCP tool mirrors the CLI leaf command metadata (mutating, consumesQuota, agentSafetyTier, idempotencySupported).', + }, + mqtt: { + mode: 'consumer', + authSource: 'SWITCHBOT_TOKEN + SWITCHBOT_SECRET (auto-provisioned via POST /v1.1/iot/credential)', + cliCmd: 'events mqtt-tail', + mcpResource: 'switchbot://events', + protocol: 'MQTTS with TLS client certificates (AWS IoT)', + }, + plan: { + schemaCmd: 'plan schema', + validateCmd: 'plan validate -', + runCmd: 'plan run -', + }, + cli: { + catalogCmd: 'schema export', + discoveryCmd: 'capabilities', + healthCmd: 'doctor --json', + healthCmdSchemaVersion: 1, + helpFlag: '--help', + idempotencyContract: IDEMPOTENCY_CONTRACT, + }, + }; + + const filteredSurfaces = (() => { + if (!opts.surface || opts.surface === 'all') return surfaces; + const picked: Record = {}; + if (opts.surface in surfaces) { + picked[opts.surface] = (surfaces as Record)[opts.surface]; } - return entry; - }); - const globalFlags = program.options.map((opt) => ({ - flags: opt.flags, - description: opt.description, - })); + return picked; + })(); + const roles = [...new Set(catalog.map((e) => e.role ?? 'other'))].sort(); - printJson({ + + const payload: Record = { version: program.version(), - generatedAt: new Date().toISOString(), + schemaVersion: '2', + agentGuide: AGENT_GUIDE, identity: IDENTITY, - surfaces: { - mcp: { - entry: 'mcp serve', - protocol: 'stdio (default) or --port for HTTP', - tools: MCP_TOOLS, - resources: ['switchbot://events'], - }, - mqtt: { - mode: 'consumer', - authSource: 'SWITCHBOT_TOKEN + SWITCHBOT_SECRET (auto-provisioned via POST /v1.1/iot/credential)', - cliCmd: 'events mqtt-tail', - mcpResource: 'switchbot://events', - protocol: 'MQTTS with TLS client certificates (AWS IoT)', - }, - plan: { - schemaCmd: 'plan schema', - validateCmd: 'plan validate -', - runCmd: 'plan run -', - }, - cli: { - catalogCmd: 'schema export', - discoveryCmd: 'capabilities', - healthCmd: 'doctor --json', - helpFlag: '--help', - }, - }, - commands, - globalFlags, + surfaces: filteredSurfaces, + commands: compact ? leaves : fullCommands, + ...(globalFlags ? { globalFlags } : {}), catalog: { typeCount: catalog.length, roles, @@ -112,6 +275,13 @@ export function registerCapabilitiesCommand(program: Command): void { ), readOnlyTypeCount: catalog.filter((e) => e.readOnly).length, }, - }); + }; + if (!compact) payload.generatedAt = new Date().toISOString(); + + const projected = opts.project + ? projectObject(payload, opts.project.split(',').map((s) => s.trim()).filter(Boolean)) + : payload; + + printJson(projected); }); } diff --git a/src/commands/config.ts b/src/commands/config.ts index 312603e..304228b 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -1,8 +1,10 @@ import { Command } from 'commander'; import fs from 'node:fs'; +import readline from 'node:readline'; import { execFileSync } from 'node:child_process'; import { stringArg } from '../utils/arg-parsers.js'; -import { saveConfig, showConfig, listProfiles } from '../config.js'; +import { intArg } from '../utils/arg-parsers.js'; +import { saveConfig, showConfig, listProfiles, readProfileMeta } from '../config.js'; import { isJsonMode, printJson } from '../utils/output.js'; import chalk from 'chalk'; @@ -31,6 +33,62 @@ function readFromOp(ref: string): string { return stdout.trim(); } +// Replace raw token/secret positional slots in process.argv with "***" so +// neither verbose traces nor crash dumps nor any later inspector observe them. +function scrubArgvCredentials(): void { + const argv = process.argv; + for (let i = 2; i < argv.length - 2; i++) { + if (argv[i] === 'config' && argv[i + 1] === 'set-token') { + // Slots i+2 and i+3 (if not option flags) are token/secret. + for (const off of [2, 3]) { + const slot = i + off; + if (slot < argv.length && !argv[slot].startsWith('-')) { + argv[slot] = '***'; + } + } + return; + } + } +} + +async function promptSecret(question: string): Promise { + const rl = readline.createInterface({ input: process.stdin, output: process.stderr, terminal: true }); + const stdoutAny = process.stdout as unknown as { isTTY?: boolean }; + const mutableStdout = process.stderr as unknown as { _writeToMute?: boolean }; + return new Promise((resolve) => { + process.stderr.write(question); + const stdin = process.stdin as unknown as NodeJS.ReadStream & { setRawMode?: (m: boolean) => void }; + let answer = ''; + const onData = (chunk: Buffer) => { + const s = chunk.toString('utf-8'); + for (const ch of s) { + if (ch === '\r' || ch === '\n') { + stdin.removeListener('data', onData); + if (stdin.setRawMode) stdin.setRawMode(false); + stdin.pause(); + process.stderr.write('\n'); + rl.close(); + resolve(answer); + return; + } + if (ch === '\u0003') { + process.exit(130); + } + if (ch === '\u007f' || ch === '\b') { + answer = answer.slice(0, -1); + continue; + } + answer += ch; + } + }; + if (stdin.setRawMode) stdin.setRawMode(true); + stdin.resume(); + stdin.on('data', onData); + void stdoutAny; + void mutableStdout; + }); +} + export function registerConfigCommand(program: Command): void { const config = program .command('config') @@ -54,22 +112,53 @@ Obtain your token/secret from the SwitchBot mobile app: .option('--from-env-file ', 'Read SWITCHBOT_TOKEN and SWITCHBOT_SECRET from a dotenv file', stringArg('--from-env-file')) .option('--from-op ', 'Read token via 1Password CLI (op read). Pair with --op-secret ', stringArg('--from-op')) .option('--op-secret ', '1Password reference for the secret, used with --from-op', stringArg('--op-secret')) + .option('--label ', 'Human-friendly label for this profile (shown in config show / list-profiles)', stringArg('--label')) + .option('--description ', 'Longer description, e.g. "home account" or "work devices"', stringArg('--description')) + .option('--daily-cap ', 'Local cap on SwitchBot API calls per UTC day for this profile', intArg('--daily-cap', { min: 1 })) + .option('--default-flags ', 'Comma-separated flags auto-applied for this profile (e.g. "--audit-log")', stringArg('--default-flags')) .addHelpText('after', ` Examples: - $ switchbot config set-token - $ switchbot --profile work config set-token + # Interactive (recommended) — credentials never touch shell history / ps listing + $ switchbot config set-token + Token: **** + Secret: **** + + # Import from dotenv / 1Password (non-interactive, still safe) $ switchbot config set-token --from-env-file ./.env $ switchbot config set-token --from-op op://vault/switchbot/token --op-secret op://vault/switchbot/secret + # Advanced / non-interactive (DISCOURAGED — leaks to shell history) + $ switchbot config set-token + $ switchbot --profile work config set-token + Files are written with mode 0600. Profiles live under ~/.switchbot/profiles/.json. `) .action(async ( tokenArg: string | undefined, secretArg: string | undefined, - options: { fromEnvFile?: string; fromOp?: string; opSecret?: string }, + options: { + fromEnvFile?: string; + fromOp?: string; + opSecret?: string; + label?: string; + description?: string; + dailyCap?: string; + defaultFlags?: string; + }, ) => { let token = tokenArg; let secret = secretArg; + const hadPositional = tokenArg !== undefined && secretArg !== undefined; + + // Scrub early: commander has already parsed the values, so we can safely + // rewrite argv before anything else (verbose trace, crash dumps, …) sees it. + if (hadPositional) { + scrubArgvCredentials(); + console.error( + '⚠ Passing token/secret as positional arguments is discouraged — they may be persisted in shell history, process listings, and agent logs.', + ); + console.error(' Prefer: switchbot config set-token (interactive), --from-env-file, or --from-op.'); + } if (options.fromEnvFile) { if (!fs.existsSync(options.fromEnvFile)) { @@ -111,8 +200,24 @@ Files are written with mode 0600. Profiles live under ~/.switchbot/profiles/ s.trim()) + .filter(Boolean), + } + : undefined, + }); if (isJsonMode()) { printJson({ ok: true, message: 'credentials saved' }); } else { @@ -138,17 +255,31 @@ Files are written with mode 0600. Profiles live under ~/.switchbot/profiles/ { const profiles = listProfiles(); + const enriched = profiles.map((p) => { + const meta = readProfileMeta(p); + return { + name: p, + label: meta?.label, + description: meta?.description, + dailyCap: meta?.limits?.dailyCap, + }; + }); if (isJsonMode()) { - printJson({ profiles }); + printJson({ profiles: enriched }); return; } if (profiles.length === 0) { console.log('No profiles. Create one with: switchbot --profile config set-token ...'); return; } - for (const p of profiles) console.log(p); + for (const p of enriched) { + const bits = [p.name]; + if (p.label) bits.push(`— ${p.label}`); + if (p.dailyCap) bits.push(`[dailyCap=${p.dailyCap}]`); + console.log(bits.join(' ')); + } }); } diff --git a/src/commands/devices.ts b/src/commands/devices.ts index f04ccc1..9cfd2de 100644 --- a/src/commands/devices.ts +++ b/src/commands/devices.ts @@ -5,7 +5,7 @@ import { resolveFormat, resolveFields, renderRows } from '../utils/format.js'; import { findCatalogEntry, getEffectiveCatalog, DeviceCatalogEntry } from '../devices/catalog.js'; import { getCachedDevice } from '../devices/cache.js'; import { loadDeviceMeta } from '../devices/device-meta.js'; -import { resolveDeviceId } from '../utils/name-resolver.js'; +import { resolveDeviceId, NameResolveStrategy } from '../utils/name-resolver.js'; import { fetchDeviceList, fetchDeviceStatus, @@ -216,6 +216,10 @@ Examples: .description('Query the real-time status of a specific device') .argument('[deviceId]', 'Device ID from "devices list" (or use --name or --ids)') .option('--name ', 'Resolve device by fuzzy name instead of deviceId', stringArg('--name')) + .option('--name-strategy ', 'Name match strategy: exact|prefix|substring|fuzzy|first|require-unique (default: fuzzy)', stringArg('--name-strategy')) + .option('--name-type ', 'Narrow --name by device type (e.g. "Bot", "Color Bulb")', stringArg('--name-type')) + .option('--name-category ', 'Narrow --name by category: physical|ir', enumArg('--name-category', ['physical', 'ir'] as const)) + .option('--name-room ', 'Narrow --name by room name (substring match)', stringArg('--name-room')) .option('--ids ', 'Comma-separated device IDs for batch status (incompatible with --name)', stringArg('--ids')) .addHelpText('after', ` Status fields vary by device type. To discover them without a live call: @@ -235,7 +239,7 @@ Examples: $ switchbot devices status --ids ABC123,DEF456,GHI789 $ switchbot devices status --ids ABC123,DEF456 --fields power,battery `) - .action(async (deviceIdArg: string | undefined, options: { name?: string; ids?: string }) => { + .action(async (deviceIdArg: string | undefined, options: { name?: string; nameStrategy?: string; nameType?: string; nameCategory?: 'physical' | 'ir'; nameRoom?: string; ids?: string }) => { try { // Batch mode: --ids id1,id2,id3 if (options.ids) { @@ -275,7 +279,12 @@ Examples: return; } - const deviceId = resolveDeviceId(deviceIdArg, options.name); + const deviceId = resolveDeviceId(deviceIdArg, options.name, { + strategy: (options.nameStrategy as NameResolveStrategy | undefined) ?? 'fuzzy', + type: options.nameType, + category: options.nameCategory, + room: options.nameRoom, + }); const body = await fetchDeviceStatus(deviceId); const fetchedAt = new Date().toISOString(); const fmt = resolveFormat(); @@ -309,6 +318,10 @@ Examples: .argument('[cmd]', 'Command name, e.g. turnOn, turnOff, setColor, setBrightness, setAll, startClean') .argument('[parameter]', 'Command parameter. Omit for commands like turnOn/turnOff (defaults to "default"). Format depends on the command (see below).') .option('--name ', 'Resolve device by fuzzy name instead of deviceId', stringArg('--name')) + .option('--name-strategy ', 'Name match strategy: exact|prefix|substring|fuzzy|first|require-unique (default for command: require-unique)', stringArg('--name-strategy')) + .option('--name-type ', 'Narrow --name by device type (e.g. "Bot", "Color Bulb")', stringArg('--name-type')) + .option('--name-category ', 'Narrow --name by category: physical|ir', enumArg('--name-category', ['physical', 'ir'] as const)) + .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')) @@ -357,7 +370,7 @@ Examples: $ switchbot devices command ABC123 "MyButton" --type customize $ switchbot devices command unlock --yes `) - .action(async (deviceIdArg: string | undefined, cmdArg: string | undefined, parameter: string | undefined, options: { name?: string; type: string; yes?: boolean; idempotencyKey?: string }) => { + .action(async (deviceIdArg: string | undefined, cmdArg: string | undefined, parameter: string | undefined, options: { name?: string; nameStrategy?: string; nameType?: string; nameCategory?: 'physical' | 'ir'; nameRoom?: string; type: string; yes?: boolean; idempotencyKey?: string }) => { try { // BUG-FIX: When --name is provided, Commander fills positionals left-to-right // starting at [deviceId]. Shift them back to their semantic slots. @@ -384,7 +397,13 @@ Examples: effectiveDeviceIdArg = deviceIdArg; } - const deviceId = resolveDeviceId(effectiveDeviceIdArg, options.name); + const deviceId = resolveDeviceId(effectiveDeviceIdArg, options.name, { + // Mutating command → default require-unique (never silently pick between ambiguous matches). + strategy: (options.nameStrategy as NameResolveStrategy | undefined) ?? 'require-unique', + type: options.nameType, + category: options.nameCategory, + room: options.nameRoom, + }); if (!getCachedDevice(deviceId)) { console.error( `Note: device ${deviceId} is not in the local cache — run 'switchbot devices list' first to enable command validation.`, @@ -505,10 +524,20 @@ Examples: ); const isIr = getCachedDevice(deviceId)?.category === 'ir'; + const verification = isIr + ? { + verifiable: false, + reason: 'IR transmission is unidirectional; no receipt acknowledgment is possible.', + suggestedFollowup: 'Confirm visible change manually or via a paired state sensor.', + } + : null; if (isJsonMode()) { const result: Record = { ok: true, command: cmd, deviceId }; - if (isIr) result.subKind = 'ir-no-feedback'; + if (isIr) { + result.subKind = 'ir-no-feedback'; + result.verification = verification; + } if (body && typeof body === 'object' && Object.keys(body as object).length > 0) { Object.assign(result, body); } @@ -518,6 +547,7 @@ Examples: if (isIr) { console.log(`→ IR signal sent: ${cmd} (no feedback — fire-and-forget)`); + console.error('⚠ IR (unverifiable) — no receipt acknowledgment. Confirm state manually.'); } else { console.log(`✓ Command sent: ${cmd}`); if (body && typeof body === 'object' && Object.keys(body as object).length > 0) { @@ -618,6 +648,10 @@ Examples: .description('Describe a device by ID: metadata + supported commands + status fields (1 API call)') .argument('[deviceId]', 'Target device ID (or use --name)') .option('--name ', 'Resolve device by fuzzy name instead of deviceId', stringArg('--name')) + .option('--name-strategy ', 'Name match strategy: exact|prefix|substring|fuzzy|first|require-unique (default: fuzzy)', stringArg('--name-strategy')) + .option('--name-type ', 'Narrow --name by device type', stringArg('--name-type')) + .option('--name-category ', 'Narrow --name by category: physical|ir', enumArg('--name-category', ['physical', 'ir'] as const)) + .option('--name-room ', 'Narrow --name by room name (substring match)', stringArg('--name-room')) .option('--live', 'Also fetch live status values and merge them into capabilities (costs 1 extra API call)') .addHelpText('after', ` Makes a GET /v1.1/devices call to look up the device's type, then prints its @@ -647,9 +681,14 @@ Examples: $ switchbot devices describe ABC123DEF456 --json $ switchbot devices describe --json | jq '.capabilities.commands[] | select(.destructive)' `) - .action(async (deviceIdArg: string | undefined, options: { name?: string; live?: boolean }) => { + .action(async (deviceIdArg: string | undefined, options: { name?: string; nameStrategy?: string; nameType?: string; nameCategory?: 'physical' | 'ir'; nameRoom?: string; live?: boolean }) => { try { - const deviceId = resolveDeviceId(deviceIdArg, options.name); + const deviceId = resolveDeviceId(deviceIdArg, options.name, { + strategy: (options.nameStrategy as NameResolveStrategy | undefined) ?? 'fuzzy', + type: options.nameType, + category: options.nameCategory, + room: options.nameRoom, + }); const result = await describeDevice(deviceId, options); const { device, isPhysical, typeName, controlType, catalog, capabilities, source, suggestedActions: picks } = result; diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 20ccbd2..f290693 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -4,15 +4,17 @@ import os from 'node:os'; import path from 'node:path'; import { printJson, isJsonMode } from '../utils/output.js'; import { getEffectiveCatalog } from '../devices/catalog.js'; -import { configFilePath, listProfiles } from '../config.js'; +import { configFilePath, listProfiles, readProfileMeta } from '../config.js'; import { describeCache } from '../devices/cache.js'; interface Check { name: string; status: 'ok' | 'warn' | 'fail'; - detail: string; + detail: string | Record; } +export const DOCTOR_SCHEMA_VERSION = 1; + async function checkCredentials(): Promise { const envOk = Boolean(process.env.SWITCHBOT_TOKEN && process.env.SWITCHBOT_SECRET); if (envOk) return { name: 'credentials', status: 'ok', detail: 'env: SWITCHBOT_TOKEN + SWITCHBOT_SECRET' }; @@ -46,22 +48,96 @@ function checkProfiles(): Check { return { name: 'profiles', status: 'ok', detail: 'no profile dir (default profile only)' }; } const profiles = listProfiles(); + if (profiles.length === 0) { + return { name: 'profiles', status: 'ok', detail: 'profile dir empty' }; + } + const labelled = profiles.map((p) => { + const meta = readProfileMeta(p); + if (meta?.label) return `${p} (${meta.label})`; + return p; + }); return { name: 'profiles', status: 'ok', - detail: profiles.length ? `found ${profiles.length}: ${profiles.join(', ')}` : 'profile dir empty', + detail: `found ${profiles.length}: ${labelled.join(', ')}`, }; } -function checkClockSkew(): Check { - const now = Date.now(); - const drift = now - Math.floor(now / 1000) * 1000; - // HMAC signing uses ms timestamps — we can't detect remote skew without a - // round-trip, but we can flag if the local clock has NTP issues via the - // classic "jumps back" pattern. Best-effort: just report local time. - const iso = new Date().toISOString(); - return { name: 'clock', status: 'ok', detail: `local time ${iso} (drift check needs API round-trip)` }; - void drift; +async function checkClockSkew(): Promise { + // Real probe: HEAD the SwitchBot API endpoint and compare the server's Date + // header against local time. No auth required for the Date header — the API + // returns 401 but still stamps the response. Gracefully degrades to + // probeSource:'none' if offline / no network reachable. + // + // Under vitest, only run the probe if fetch has been stubbed (detected via + // vi.fn marker) — otherwise skip network I/O to keep unrelated tests fast. + const underVitest = Boolean(process.env.VITEST); + const fetchFn = globalThis.fetch as unknown as { mock?: unknown } | undefined; + const fetchIsMocked = Boolean(fetchFn && typeof fetchFn === 'function' && 'mock' in fetchFn); + if (underVitest && !fetchIsMocked) { + return { + name: 'clock', + status: 'warn', + detail: { probeSource: 'none', skewMs: null, message: 'skipped: test environment' }, + }; + } + + const localBefore = Date.now(); + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), 2500); + try { + const res = await fetch('https://api.switch-bot.com/v1.1/devices', { + method: 'HEAD', + signal: ctrl.signal, + }); + const localAfter = Date.now(); + const dateHeader = res.headers.get('date'); + if (!dateHeader) { + return { + name: 'clock', + status: 'warn', + detail: { probeSource: 'api', skewMs: null, message: 'server returned no Date header' }, + }; + } + const serverMs = Date.parse(dateHeader); + if (!Number.isFinite(serverMs)) { + return { + name: 'clock', + status: 'warn', + detail: { probeSource: 'api', skewMs: null, message: `unparseable Date header: ${dateHeader}` }, + }; + } + // Split the round-trip in half to estimate the local instant that matches + // the server's Date header. HTTP Date resolution is 1s, so treat anything + // under 2000ms as ok, 2000–60000ms as warn, beyond that as fail (HMAC + // auth rejects requests with skew > 5 minutes anyway). + const midpoint = (localBefore + localAfter) / 2; + const skewMs = Math.round(midpoint - serverMs); + const absSkew = Math.abs(skewMs); + const status: 'ok' | 'warn' | 'fail' = absSkew < 2000 ? 'ok' : absSkew < 60_000 ? 'warn' : 'fail'; + return { + name: 'clock', + status, + detail: { + probeSource: 'api', + skewMs, + localIso: new Date(midpoint).toISOString(), + serverIso: new Date(serverMs).toISOString(), + }, + }; + } catch (err) { + return { + name: 'clock', + status: 'warn', + detail: { + probeSource: 'none', + skewMs: null, + message: `probe failed: ${err instanceof Error ? err.message : String(err)}`, + }, + }; + } finally { + clearTimeout(timer); + } } function checkCatalog(): Check { @@ -164,7 +240,7 @@ Examples: checkCatalog(), checkCache(), checkQuotaFile(), - checkClockSkew(), + await checkClockSkew(), checkMqtt(), ]; const summary = { @@ -173,13 +249,27 @@ Examples: fail: checks.filter((c) => c.status === 'fail').length, }; const overallFail = summary.fail > 0; + const overall: 'ok' | 'warn' | 'fail' = overallFail ? 'fail' : summary.warn > 0 ? 'warn' : 'ok'; if (isJsonMode()) { - printJson({ overall: overallFail ? 'fail' : summary.warn > 0 ? 'warn' : 'ok', summary, checks }); + // Stable contract (locked as doctor.schemaVersion=1): + // { ok: boolean, overall: 'ok'|'warn'|'fail', generatedAt, schemaVersion, + // summary: { ok, warn, fail }, checks: [{ name, status, detail }] } + // `ok` is an alias of (overall === 'ok') — agents prefer the boolean, + // humans prefer the string; both are provided. + printJson({ + ok: overall === 'ok', + overall, + generatedAt: new Date().toISOString(), + schemaVersion: DOCTOR_SCHEMA_VERSION, + summary, + checks, + }); } else { for (const c of checks) { const icon = c.status === 'ok' ? '✓' : c.status === 'warn' ? '!' : '✗'; - console.log(`${icon} ${c.name.padEnd(12)} ${c.detail}`); + const detailStr = typeof c.detail === 'string' ? c.detail : JSON.stringify(c.detail); + console.log(`${icon} ${c.name.padEnd(12)} ${detailStr}`); } console.log(''); console.log(`${summary.ok} ok, ${summary.warn} warn, ${summary.fail} fail`); diff --git a/src/commands/events.ts b/src/commands/events.ts index c363ad1..95fe167 100644 --- a/src/commands/events.ts +++ b/src/commands/events.ts @@ -1,5 +1,6 @@ import { Command } from 'commander'; import http from 'node:http'; +import crypto from 'node:crypto'; import { printJson, isJsonMode, handleError, UsageError } from '../utils/output.js'; import { intArg, stringArg } from '../utils/arg-parsers.js'; import { SwitchBotMqttClient } from '../mqtt/client.js'; @@ -20,6 +21,15 @@ const DEFAULT_PORT = 3000; const DEFAULT_PATH = '/'; const MAX_BODY_BYTES = 1_000_000; +function extractEventId(parsed: unknown): string | null { + if (!parsed || typeof parsed !== 'object') return null; + const p = parsed as Record; + if (typeof p.eventId === 'string' && p.eventId.length > 0) return p.eventId; + const ctx = p.context as Record | undefined; + if (ctx && typeof ctx.eventId === 'string' && ctx.eventId.length > 0) return ctx.eventId; + return null; +} + interface EventRecord { t: string; remote: string; @@ -252,7 +262,19 @@ Connects to the SwitchBot MQTT service using your existing credentials No additional MQTT configuration required. Output (JSONL, one event per line): - { "t": "", "topic": "", "payload": } + { "t": "", "eventId": "", "topic": "", "payload": } + +Control records (interleaved, no "payload" field — use type-prefix to filter): + { "type": "__connect", "at": "", "eventId": "" } first successful connect + { "type": "__reconnect", "at": "", "eventId": "" } connect after a disconnect + { "type": "__disconnect", "at": "", "eventId": "" } reconnecting or failed + +Reconnect policy: the MQTT client retries with exponential backoff +(1s → 30s capped, forever) while the credential is still valid; if the +credential is rejected or 5 consecutive reconnects fail, state goes to +'failed' and the command exits non-zero so supervisors can restart it. +QoS is 0 (at-most-once); agents requiring at-least-once delivery should +fan-out via --sink file and deduplicate by eventId on the consumer side. Sink types (--sink, repeatable): stdout Print JSONL to stdout (default when no --sink given) @@ -373,17 +395,22 @@ Examples: } const t = new Date().toISOString(); + // Every event carries an eventId so downstream sinks / replay tools + // can dedupe. If the broker supplied one (some providers do via a + // header), prefer that; otherwise synth a UUID locally. + const existingId = extractEventId(parsed); + const eventId = existingId ?? crypto.randomUUID(); if (dispatcher) { const { deviceId, deviceType, text } = parseSinkEvent(parsed); - const sinkEvent: MqttSinkEvent = { t, topic: msgTopic, deviceId, deviceType, payload: parsed, text }; + const sinkEvent: MqttSinkEvent = { t, topic: msgTopic, deviceId, deviceType, payload: parsed, text, eventId }; deviceHistoryStore.record(deviceId, msgTopic, deviceType, parsed, t); dispatcher.dispatch(sinkEvent).catch(() => {}); } else { // Default behavior: record history + print to stdout const { deviceId, deviceType } = parseSinkEvent(parsed); deviceHistoryStore.record(deviceId, msgTopic, deviceType, parsed, t); - const record = { t, topic: msgTopic, payload: parsed }; + const record = { t, eventId, topic: msgTopic, payload: parsed }; if (isJsonMode()) { printJson(record); } else { @@ -398,12 +425,29 @@ Examples: }); let mqttFailed = false; + let hasConnectedBefore = false; + const emitControl = (kind: '__connect' | '__reconnect' | '__disconnect' | '__heartbeat'): void => { + const ctl = { type: kind, at: new Date().toISOString(), eventId: crypto.randomUUID() }; + // Control events always go to stdout as JSONL so consumers that + // filter real events by presence of `payload` can skip them. + if (isJsonMode()) { + printJson(ctl); + } else { + console.log(JSON.stringify(ctl)); + } + }; const unsubState = client.onStateChange((state) => { if (!isJsonMode()) { console.error(`[${new Date().toLocaleTimeString()}] MQTT state: ${state}`); } - if (state === 'failed') { + if (state === 'connected') { + emitControl(hasConnectedBefore ? '__reconnect' : '__connect'); + hasConnectedBefore = true; + } else if (state === 'reconnecting') { + emitControl('__disconnect'); + } else if (state === 'failed') { mqttFailed = true; + emitControl('__disconnect'); if (!isJsonMode()) { console.error( 'MQTT connection failed permanently (credential expired or reconnect exhausted) — exiting.', diff --git a/src/commands/history.ts b/src/commands/history.ts index ab59cb6..9588e57 100644 --- a/src/commands/history.ts +++ b/src/commands/history.ts @@ -2,9 +2,14 @@ import { Command } from 'commander'; import path from 'node:path'; import os from 'node:os'; import { intArg, stringArg } from '../utils/arg-parsers.js'; -import { printJson, isJsonMode, handleError } from '../utils/output.js'; -import { readAudit, type AuditEntry } from '../utils/audit.js'; +import { printJson, isJsonMode, handleError, UsageError } from '../utils/output.js'; +import { readAudit, verifyAudit, type AuditEntry } from '../utils/audit.js'; import { executeCommand } from '../lib/devices.js'; +import { + queryDeviceHistory, + queryDeviceHistoryStats, + type HistoryRecord, +} from '../devices/history-query.js'; const DEFAULT_AUDIT = path.join(os.homedir(), '.switchbot', 'audit.log'); @@ -109,4 +114,128 @@ Examples: handleError(err); } }); + + history + .command('range') + .description('Query time-ranged device history from JSONL storage (populated by events mqtt-tail / MCP)') + .argument('', 'Device ID to query') + .option('--since ', 'Relative window ending now, e.g. "30s", "15m", "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('--field ', 'Project a payload field (repeat to keep multiple)', (v, acc: string[] = []) => acc.concat(v), [] as string[]) + .option('--limit ', 'Maximum records to return (default 1000)', intArg('--limit', { min: 1 })) + .addHelpText('after', ` +History is the append-only JSONL mirror of the per-device ring buffer: every +'events mqtt-tail' event and every MCP tool status-refresh is written to +~/.switchbot/device-history/.jsonl (rotates at 50MB × 3 files). + +Examples: + $ switchbot history range --since 7d --json + $ switchbot history range --since 1h --field temperature --field humidity + $ switchbot history range --from 2026-04-18T00:00:00Z --to 2026-04-19T00:00:00Z +`) + .action(async ( + deviceId: string, + options: { since?: string; from?: string; to?: string; field?: string[]; limit?: string }, + ) => { + // Usage-level validation: keep synchronous and pre-query so handleError + // maps these to exit 2 (via UsageError) rather than runtime exit 1. + if (options.since && (options.from || options.to)) { + handleError(new UsageError('--since is mutually exclusive with --from/--to.')); + } + + try { + const records: HistoryRecord[] = await queryDeviceHistory(deviceId, { + since: options.since, + from: options.from, + to: options.to, + fields: options.field ?? [], + limit: options.limit !== undefined ? Number(options.limit) : undefined, + }); + + if (isJsonMode()) { + printJson({ deviceId, count: records.length, records }); + return; + } + if (records.length === 0) { + console.log(`(no history records for ${deviceId} in requested range)`); + return; + } + for (const r of records) { + const payloadStr = JSON.stringify(r.payload); + console.log(`${r.t} ${r.topic} ${payloadStr}`); + } + } catch (err) { + // Convert history-query's plain Error range messages into UsageError so + // they exit 2 instead of 1. + if (err instanceof Error && /^(Invalid --|--from|--since)/i.test(err.message)) { + handleError(new UsageError(err.message)); + } + handleError(err); + } + }); + + history + .command('stats') + .description('Show on-disk size + record counts for a device history') + .argument('', 'Device ID to inspect') + .action((deviceId: string) => { + try { + const stats = queryDeviceHistoryStats(deviceId); + if (isJsonMode()) { + printJson(stats); + return; + } + console.log(`Device: ${stats.deviceId}`); + console.log(`History dir: ${stats.historyDir}`); + console.log(`JSONL files: ${stats.fileCount} (${stats.jsonlFiles.join(', ') || '—'})`); + console.log(`Total size: ${stats.totalBytes.toLocaleString()} bytes`); + console.log(`Record count: ${stats.recordCount}`); + console.log(`Oldest: ${stats.oldest ?? '—'}`); + console.log(`Newest: ${stats.newest ?? '—'}`); + } catch (err) { + handleError(err); + } + }); + + history + .command('verify') + .description('Check the audit log for malformed lines and schema-version drift') + .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 + 2 (usage) — not emitted by this subcommand + +Examples: + $ switchbot history verify + $ switchbot history verify --file ./custom.log --json +`) + .action((options: { file?: string }) => { + const file = options.file ?? DEFAULT_AUDIT; + const report = verifyAudit(file); + if (isJsonMode()) { + printJson(report); + } 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); + }); } diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index b5ebade..dd5a540 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -24,6 +24,7 @@ import { findCatalogEntry } from '../devices/catalog.js'; 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 { todayUsage } from '../utils/quota.js'; import { describeCache } from '../devices/cache.js'; import { withRequestContext } from '../lib/request-context.js'; @@ -194,6 +195,52 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`, } ); + // ---- query_device_history -------------------------------------------------- + server.registerTool( + 'query_device_history', + { + title: 'Query time-ranged device history', + description: + 'Return records from the append-only JSONL history (~/.switchbot/device-history/.jsonl) ' + + 'filtered by a relative duration (since) or absolute ISO-8601 range (from/to). ' + + 'No API call — zero quota cost. Use for trend questions like "how many times did this switch turn on last week".', + inputSchema: { + 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).'), + }, + outputSchema: { + deviceId: z.string(), + count: z.number().int(), + records: z.array(z.object({ + t: z.string(), + topic: z.string(), + deviceType: z.string().optional(), + payload: z.unknown(), + })), + }, + }, + async ({ deviceId, since, from, to, fields, limit }) => { + if (since && (from || to)) { + return mcpError('usage', 2, '--since is mutually exclusive with --from/--to.'); + } + try { + const records = await queryDeviceHistory(deviceId, { since, from, to, fields, limit }); + const result = { deviceId, count: records.length, records }; + return { + content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], + structuredContent: result, + }; + } catch (err) { + const msg = err instanceof Error ? err.message : 'history query failed'; + return mcpError('usage', 2, msg); + } + } + ); + // ---- send_command --------------------------------------------------------- server.registerTool( 'send_command', @@ -218,15 +265,31 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`, .optional() .default(false) .describe('Required true for destructive commands (unlock, garage open, createKey, ...)'), + idempotencyKey: z + .string() + .optional() + .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.', + ), }, outputSchema: { ok: z.literal(true), command: z.string(), deviceId: z.string(), result: z.unknown().describe('API response body from SwitchBot'), + verification: z + .object({ + verifiable: z.boolean(), + reason: z.string(), + suggestedFollowup: z.string(), + }) + .optional() + .describe( + 'Present when the target is an IR device. IR is unidirectional — agents should treat the success as "signal sent" not "state changed".', + ), }, }, - async ({ deviceId, command, parameter, commandType, confirm }) => { + async ({ deviceId, command, parameter, commandType, confirm, idempotencyKey }) => { const effectiveType = commandType ?? 'command'; // Resolve the device's catalog type via cache or a fresh lookup so we @@ -283,8 +346,42 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`, ); } - const result = await executeCommand(deviceId, command, parameter, effectiveType); - const structured = { ok: true as const, command, deviceId, result }; + let result: unknown; + try { + result = await executeCommand(deviceId, command, parameter, effectiveType, undefined, { + idempotencyKey, + }); + } catch (err) { + if (err instanceof Error && err.name === 'IdempotencyConflictError') { + return mcpError('guard', 2, err.message, { + hint: 'Use a fresh idempotencyKey, or wait for the prior key to expire (60s TTL).', + context: { + existingShape: (err as { existingShape?: string }).existingShape, + newShape: (err as { newShape?: string }).newShape, + }, + }); + } + throw err; + } + const isIr = getCachedDevice(deviceId)?.category === 'ir'; + const structured: { + ok: true; + command: string; + deviceId: string; + result: unknown; + verification?: { + verifiable: boolean; + reason: string; + suggestedFollowup: string; + }; + } = { ok: true as const, command, deviceId, result }; + if (isIr) { + structured.verification = { + verifiable: false, + reason: 'IR transmission is unidirectional; no receipt acknowledgment is possible.', + suggestedFollowup: 'Confirm visible change manually or via a paired state sensor.', + }; + } return { content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }], structuredContent: structured, diff --git a/src/commands/quota.ts b/src/commands/quota.ts index 0053820..ac9a58f 100644 --- a/src/commands/quota.ts +++ b/src/commands/quota.ts @@ -18,18 +18,20 @@ is a best-effort mirror of the SwitchBot 10,000/day limit — it does not include requests made outside this CLI (mobile app, other scripts). Subcommands: - status Show today's usage and the last 7 days + status Show today's usage and the last 7 days (alias: show) reset Delete the local counter file Examples: $ switchbot quota status + $ switchbot quota show # alias of 'status' $ switchbot quota status --json $ switchbot quota reset `); quota .command('status') - .description("Show today's usage and the last 7 days") + .alias('show') + .description("Show today's usage and the last 7 days (alias: show)") .action(() => { const usage = todayUsage(); const history = loadQuota(); diff --git a/src/commands/schema.ts b/src/commands/schema.ts index eb0674f..0a0d12d 100644 --- a/src/commands/schema.ts +++ b/src/commands/schema.ts @@ -2,6 +2,7 @@ import { Command } from 'commander'; import { enumArg, stringArg } from '../utils/arg-parsers.js'; import { printJson } from '../utils/output.js'; import { getEffectiveCatalog, type CommandSpec, type DeviceCatalogEntry } from '../devices/catalog.js'; +import { loadCache } from '../devices/cache.js'; interface SchemaEntry { type: string; @@ -22,6 +23,21 @@ interface SchemaEntry { statusFields: string[]; } +interface CompactSchemaEntry { + type: string; + category: 'physical' | 'ir'; + role: string | null; + readOnly: boolean; + commands: Array<{ + command: string; + parameter: string; + commandType: 'command' | 'customize'; + idempotent: boolean; + destructive: boolean; + }>; + statusFields: string[]; +} + function toSchemaEntry(e: DeviceCatalogEntry): SchemaEntry { return { type: e.type, @@ -47,6 +63,31 @@ function toSchemaCommand(c: CommandSpec) { }; } +function toCompactEntry(e: DeviceCatalogEntry): CompactSchemaEntry { + return { + type: e.type, + category: e.category, + role: e.role ?? null, + readOnly: e.readOnly ?? false, + commands: e.commands.map((c) => ({ + command: c.command, + parameter: c.parameter, + commandType: (c.commandType ?? 'command') as 'command' | 'customize', + idempotent: Boolean(c.idempotent), + destructive: Boolean(c.destructive), + })), + statusFields: e.statusFields ?? [], + }; +} + +function projectFields>(entry: T, fields: string[]): Partial { + const out: Partial = {}; + for (const f of fields) { + if (f in entry) (out as Record)[f] = entry[f]; + } + return out; +} + export function registerSchemaCommand(program: Command): void { const ROLES = ['lighting', 'security', 'sensor', 'climate', 'media', 'cleaning', 'curtain', 'fan', 'power', 'hub', 'other'] as const; const CATEGORIES = ['physical', 'ir'] as const; @@ -56,24 +97,46 @@ export function registerSchemaCommand(program: Command): void { schema .command('export') - .description('Print the full catalog as structured JSON (one object per type)') + .description('Print the catalog as structured JSON (one object per type)') .option('--type ', 'Restrict to a single device type (e.g. "Strip Light")', stringArg('--type')) + .option('--types ', 'Restrict to multiple device types (comma-separated)', stringArg('--types')) .option('--role ', 'Restrict to a functional role: lighting, security, sensor, climate, media, cleaning, curtain, fan, power, hub, other', enumArg('--role', ROLES)) .option('--category ', 'Restrict to "physical" or "ir"', enumArg('--category', CATEGORIES)) + .option('--compact', 'Drop descriptions/aliases/example params — emit ~60% smaller payload. Useful for agent prompts.') + .option('--used', 'Restrict to device types present in the local devices cache (run "devices list" first)') + .option('--project ', 'Project per-type fields (e.g. --project type,commands,statusFields)', stringArg('--project')) .addHelpText('after', ` Output is always JSON (this command ignores --format). The output is a catalog export — not a formal JSON Schema standard document — suitable for pre-baking LLM prompts or regenerating docs when the catalog changes. +Size tips: + --compact --used Smallest realistic payload for a given account + (< 15 KB on most accounts). + --fields type,commands Strip statusFields / role / etc. when only + commands are needed. + --type + --compact Inspect one type with minimum footprint. + +Common top-level fields: + schemaVersion CLI schema version (stable for agent contracts) + data.version Catalog schema version + data.types Array of SchemaEntry (or CompactSchemaEntry with --compact) + data._fetchedAt CLI-added; present on live-query responses ('devices status'), + not on this offline export. + Examples: $ switchbot schema export > catalog.json - $ switchbot schema export --type Bot | jq '.types[0].commands' - $ switchbot schema export --role lighting | jq '[.types[].type]' + $ switchbot schema export --compact --used | wc -c # small prompt-ready payload + $ switchbot schema export --type Bot | jq '.data.types[0].commands' + $ switchbot schema export --types "Bot,Curtain,Color Bulb" + $ switchbot schema export --role lighting | jq '[.data.types[].type]' $ switchbot schema export --role security --category physical + $ switchbot schema export --project type,commands,statusFields `) - .action((options: { type?: string; role?: string; category?: string }) => { + .action((options: { type?: string; types?: string; role?: string; category?: string; compact?: boolean; used?: boolean; project?: string }) => { const catalog = getEffectiveCatalog(); let filtered = catalog; + if (options.type) { const q = options.type.toLowerCase(); filtered = filtered.filter((e) => @@ -81,6 +144,13 @@ Examples: (e.aliases ?? []).some((a) => a.toLowerCase() === q), ); } + if (options.types) { + const set = new Set(options.types.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean)); + filtered = filtered.filter((e) => + set.has(e.type.toLowerCase()) || + (e.aliases ?? []).some((a) => set.has(a.toLowerCase())), + ); + } if (options.role) { const q = options.role.toLowerCase(); filtered = filtered.filter((e) => (e.role ?? 'other') === q); @@ -89,11 +159,61 @@ Examples: const q = options.category.toLowerCase(); filtered = filtered.filter((e) => e.category === q); } - const payload = { + if (options.used) { + const cache = loadCache(); + if (cache) { + const usedTypes = new Set( + Object.values(cache.devices).map((d) => d.type.toLowerCase()), + ); + filtered = filtered.filter((e) => + usedTypes.has(e.type.toLowerCase()) || + (e.aliases ?? []).some((a) => usedTypes.has(a.toLowerCase())), + ); + } else { + filtered = []; + } + } + + const mapped = options.compact + ? filtered.map(toCompactEntry) + : filtered.map(toSchemaEntry); + + const projected = options.project + ? mapped.map((e) => + projectFields(e as unknown as Record, options.project!.split(',').map((s) => s.trim()).filter(Boolean)), + ) + : mapped; + + const payload: Record = { version: '1.0', - generatedAt: new Date().toISOString(), - types: filtered.map(toSchemaEntry), + types: projected, }; + if (!options.compact) { + payload.generatedAt = new Date().toISOString(); + payload.cliAddedFields = [ + { + field: '_fetchedAt', + appliesTo: ['devices status', 'devices describe'], + type: 'string (ISO-8601)', + description: + 'CLI-synthesized timestamp indicating when this status response was fetched or served from the cache. Not part of the upstream SwitchBot API.', + }, + { + field: 'replayed', + appliesTo: ['devices command (with --idempotency-key)'], + type: 'boolean', + description: + 'CLI-synthesized flag — true when the response was served from the idempotency cache instead of re-executing the command.', + }, + { + field: 'verification', + appliesTo: ['devices command'], + type: 'object', + description: + 'CLI-synthesized receipt-acknowledgment metadata. For IR devices, verifiable:false signals that no device-side confirmation is possible.', + }, + ]; + } printJson(payload); }); } diff --git a/src/config.ts b/src/config.ts index 596b8a0..86cfe66 100644 --- a/src/config.ts +++ b/src/config.ts @@ -7,6 +7,16 @@ import { getActiveProfile } from './lib/request-context.js'; export interface SwitchBotConfig { token: string; secret: string; + label?: string; + description?: string; + limits?: { dailyCap?: number }; + defaults?: { flags?: string[] }; +} + +function sanitizeOptionalString(v: unknown): string | undefined { + if (typeof v !== 'string') return undefined; + const trimmed = v.trim(); + return trimmed ? trimmed : undefined; } /** @@ -92,16 +102,74 @@ export function tryLoadConfig(): SwitchBotConfig | null { } } -export function saveConfig(token: string, secret: string): void { +export function saveConfig(token: string, secret: string, extras?: Partial): void { const file = configFilePath(); const dir = path.dirname(file); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } - const cfg: SwitchBotConfig = { token, secret }; + + // Merge with existing file so label/limits/defaults aren't dropped when the + // user just rotates the token. + let existing: Partial = {}; + if (fs.existsSync(file)) { + try { + existing = JSON.parse(fs.readFileSync(file, 'utf-8')) as Partial; + } catch { + existing = {}; + } + } + + const cfg: SwitchBotConfig = { + token, + secret, + ...(existing.label ? { label: existing.label } : {}), + ...(existing.description ? { description: existing.description } : {}), + ...(existing.limits ? { limits: existing.limits } : {}), + ...(existing.defaults ? { defaults: existing.defaults } : {}), + }; + if (extras) { + const label = sanitizeOptionalString(extras.label); + const description = sanitizeOptionalString(extras.description); + if (label !== undefined) cfg.label = label; + if (description !== undefined) cfg.description = description; + if (extras.limits) cfg.limits = { ...(cfg.limits ?? {}), ...extras.limits }; + if (extras.defaults) cfg.defaults = { ...(cfg.defaults ?? {}), ...extras.defaults }; + } + fs.writeFileSync(file, JSON.stringify(cfg, null, 2), { mode: 0o600 }); } +/** + * Read a profile's metadata (label / description / limits / defaults) without + * exposing the token/secret. Returns null when the file is missing or invalid. + */ +export function readProfileMeta(profile?: string): { + label?: string; + description?: string; + limits?: { dailyCap?: number }; + defaults?: { flags?: string[] }; + path: string; +} | null { + const file = profile + ? profileFilePath(profile) + : path.join(os.homedir(), '.switchbot', 'config.json'); + if (!fs.existsSync(file)) return null; + try { + const raw = fs.readFileSync(file, 'utf-8'); + const cfg = JSON.parse(raw) as SwitchBotConfig; + return { + label: cfg.label, + description: cfg.description, + limits: cfg.limits, + defaults: cfg.defaults, + path: file, + }; + } catch { + return null; + } +} + export function showConfig(): void { const envToken = process.env.SWITCHBOT_TOKEN; const envSecret = process.env.SWITCHBOT_SECRET; @@ -123,8 +191,12 @@ export function showConfig(): void { const raw = fs.readFileSync(file, 'utf-8'); const cfg = JSON.parse(raw) as SwitchBotConfig; console.log(`Credential source: ${file}`); + if (cfg.label) console.log(`label : ${cfg.label}`); + if (cfg.description) console.log(`desc : ${cfg.description}`); console.log(`token : ${maskCredential(cfg.token)}`); console.log(`secret: ${maskSecret(cfg.secret)}`); + if (cfg.limits?.dailyCap) console.log(`limits: dailyCap=${cfg.limits.dailyCap}`); + if (cfg.defaults?.flags?.length) console.log(`defaults: ${cfg.defaults.flags.join(' ')}`); } catch { console.error('Failed to read config file'); } diff --git a/src/devices/history-query.ts b/src/devices/history-query.ts new file mode 100644 index 0000000..d1cc75c --- /dev/null +++ b/src/devices/history-query.ts @@ -0,0 +1,205 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import readline from 'node:readline'; + +export interface HistoryRecord { + t: string; + topic: string; + deviceType?: string; + payload: unknown; +} + +export interface QueryOptions { + since?: string; + from?: string; + to?: string; + fields?: string[]; + limit?: number; +} + +export interface HistoryStats { + deviceId: string; + fileCount: number; + totalBytes: number; + recordCount: number; + oldest?: string; + newest?: string; + jsonlFiles: string[]; + historyDir: string; +} + +const DEFAULT_LIMIT = 1000; + +function historyDir(): string { + return path.join(os.homedir(), '.switchbot', 'device-history'); +} + +/** + * Parse a duration shortcut like "7d", "12h", "30m", "45s" into milliseconds. + * Returns null on malformed input (caller throws UsageError). + */ +export function parseDurationToMs(spec: string): number | null { + const m = spec.trim().match(/^(\d+)(ms|s|m|h|d)$/i); + if (!m) return null; + const n = Number(m[1]); + const unit = m[2].toLowerCase(); + const factor = unit === 'ms' ? 1 : unit === 's' ? 1_000 : unit === 'm' ? 60_000 : unit === 'h' ? 3_600_000 : 86_400_000; + return n * factor; +} + +/** Parse ISO-8601 (with Z or offset) or Date-parseable string → ms, else null. */ +export function parseInstantToMs(spec: string): number | null { + const ms = Date.parse(spec); + return Number.isFinite(ms) ? ms : null; +} + +function resolveRange(opts: QueryOptions): { fromMs: number; toMs: number } { + let fromMs = Number.NEGATIVE_INFINITY; + let toMs = Number.POSITIVE_INFINITY; + + if (opts.since && (opts.from || opts.to)) { + throw new Error('--since is mutually exclusive with --from/--to.'); + } + + if (opts.since) { + const durMs = parseDurationToMs(opts.since); + if (durMs === null) { + throw new Error(`Invalid --since value "${opts.since}". Expected e.g. "30s", "15m", "1h", "7d".`); + } + fromMs = Date.now() - durMs; + } else { + if (opts.from) { + const parsed = parseInstantToMs(opts.from); + if (parsed === null) throw new Error(`Invalid --from value "${opts.from}". Expected ISO-8601 timestamp.`); + fromMs = parsed; + } + if (opts.to) { + const parsed = parseInstantToMs(opts.to); + if (parsed === null) throw new Error(`Invalid --to value "${opts.to}". Expected ISO-8601 timestamp.`); + toMs = parsed; + } + if (fromMs > toMs) throw new Error('--from must be <= --to.'); + } + + return { fromMs, toMs }; +} + +/** Return jsonl candidate files for a device, oldest-first. */ +export function jsonlFilesForDevice(deviceId: string, baseDir = historyDir()): string[] { + const out: string[] = []; + if (!fs.existsSync(baseDir)) return out; + // Oldest-first so range walks can bail early once a line overshoots `toMs`. + for (let i = 3; i >= 1; i--) { + const p = path.join(baseDir, `${deviceId}.jsonl.${i}`); + if (fs.existsSync(p)) out.push(p); + } + const current = path.join(baseDir, `${deviceId}.jsonl`); + if (fs.existsSync(current)) out.push(current); + return out; +} + +function projectFields(record: HistoryRecord, fields: string[]): HistoryRecord { + if (fields.length === 0) return record; + const projected: Record = {}; + const payload = (record.payload ?? {}) as Record; + for (const f of fields) { + if (f in payload) projected[f] = payload[f]; + } + return { t: record.t, topic: record.topic, deviceType: record.deviceType, payload: projected }; +} + +/** + * Stream-read the JSONL rotation files for `deviceId` and return records + * within [fromMs, toMs]. Parse failures are silently dropped (best-effort). + * + * Files whose mtime < fromMs are skipped whole (coarse but sound: the newest + * record in the file is <= mtime, so nothing in it can match). + */ +export async function queryDeviceHistory( + deviceId: string, + opts: QueryOptions = {}, +): Promise { + const { fromMs, toMs } = resolveRange(opts); + const limit = Math.max(0, opts.limit ?? DEFAULT_LIMIT); + const fields = opts.fields ?? []; + const files = jsonlFilesForDevice(deviceId); + const out: HistoryRecord[] = []; + + for (const file of files) { + try { + const stat = fs.statSync(file); + if (stat.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)) continue; + if (tMs < fromMs || tMs > toMs) continue; + out.push(projectFields(rec, fields)); + if (out.length >= limit) { + rl.close(); + stream.destroy(); + return out; + } + } + } + return out; +} + +export function queryDeviceHistoryStats(deviceId: string): HistoryStats { + const dir = historyDir(); + const files = jsonlFilesForDevice(deviceId); + let totalBytes = 0; + let oldest: number | null = null; + let newest: number | null = null; + let count = 0; + + for (const file of files) { + try { + totalBytes += fs.statSync(file).size; + } catch { /* */ } + } + + // Walk the oldest file's head + current file's tail for oldest/newest + count. + // Counting is O(records) here, acceptable for "stats" which isn't a hot path. + for (const file of files) { + try { + const lines = fs.readFileSync(file, 'utf-8').split('\n'); + for (const line of lines) { + if (!line) continue; + count += 1; + try { + const rec = JSON.parse(line) as HistoryRecord; + const tMs = Date.parse(rec.t); + if (Number.isFinite(tMs)) { + if (oldest === null || tMs < oldest) oldest = tMs; + if (newest === null || tMs > newest) newest = tMs; + } + } catch { /* */ } + } + } catch { /* */ } + } + + return { + deviceId, + fileCount: files.length, + totalBytes, + recordCount: count, + oldest: oldest !== null ? new Date(oldest).toISOString() : undefined, + newest: newest !== null ? new Date(newest).toISOString() : undefined, + jsonlFiles: files.map((f) => path.basename(f)), + historyDir: dir, + }; +} diff --git a/src/index.ts b/src/index.ts index f1e8a45..d6fe24e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,7 @@ import { registerSchemaCommand } from './commands/schema.js'; import { registerHistoryCommand } from './commands/history.js'; import { registerPlanCommand } from './commands/plan.js'; import { registerCapabilitiesCommand } from './commands/capabilities.js'; +import { registerAgentBootstrapCommand } from './commands/agent-bootstrap.js'; const require = createRequire(import.meta.url); const { version: pkgVersion } = require('../package.json') as { version: string }; @@ -29,7 +30,7 @@ const program = new Command(); const TOP_LEVEL_COMMANDS = [ 'config', 'devices', 'scenes', 'webhook', 'completion', 'mcp', 'quota', 'catalog', 'cache', 'events', 'doctor', 'schema', - 'history', 'plan', 'capabilities', + 'history', 'plan', 'capabilities', 'agent-bootstrap', ] as const; const cacheModeArg = (value: string): string => { @@ -51,8 +52,9 @@ program .description('Command-line tool for SwitchBot API v1.1') .version(pkgVersion) .option('--json', 'Output raw JSON response (disables tables; useful for pipes/scripts)') - .option('--format ', 'Output format: table (default), json, jsonl, tsv, yaml, id', enumArg('--format', ['table', 'json', 'jsonl', 'tsv', 'yaml', 'id'])) + .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 })) + .option('--table-style