Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 127 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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 <csv>`, `--used`, `--fields
<csv>`, `--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/<deviceId>.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 <deviceId>`** — time-windowed query with `--since 7d` /
`--from <iso>` / `--to <iso>`, payload-field projection via repeatable
`--field <name>`, `--limit <n>` (default 1000). Uses streaming
`readline` so even 50 MB files never load into memory.
- **`history stats <deviceId>`** — 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 <N>`,
`--default-flags "<csv>"`. 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 <unicode|ascii|simple|markdown>`** + `--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 <name> --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.
76 changes: 76 additions & 0 deletions docs/audit-log.md
Original file line number Diff line number Diff line change
@@ -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 <file>`
- **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.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
31 changes: 30 additions & 1 deletion src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <N>`,
);
this.name = 'DailyCapExceededError';
}
}

const API_ERROR_MESSAGES: Record<number, string> = {
151: 'Device type does not support this command',
Expand Down Expand Up @@ -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',
Expand All @@ -51,14 +67,27 @@ 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);

const method = (config.method ?? 'get').toUpperCase();
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<string, unknown>);
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`));
}
Expand Down
Loading
Loading