Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ 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).

## [Unreleased]

### Fixed

- notify templates now expand nested event fields — dotted paths like `{{ event.context.deviceMac }}` and array indexes like `{{ event.list.0 }}` resolve instead of rendering literally.
- `channel: file` notify actions reject relative paths (lint code `notify-relative-path`, also enforced at runtime); `~` is not expanded.
- LLM rule-suggest prompt now embeds the `trigger` and `triggerWebhook` schema defs so `--trigger webhook` flows no longer produce dangling-`$ref` outputs.

## [3.3.2] - 2026-04-26

### Fixed
Expand Down
61 changes: 56 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,24 @@ Supported conditions: `time_between` (quiet hours) and `device_state`
`~/.switchbot/audit.log`. `rules run` is long-running; use
`daemon start` / `daemon reload` for the managed background mode.

**Actions** — each rule's `then` array accepts two action types:

- `type: command` (default, no `type` field required) — sends a device command, e.g. `devices command <id> turnOn`
- `type: notify` — delivers a payload to an external channel after the rule fires:
- `channel: webhook` — HTTP POST to a URL (only `http://` and `https://` schemes are accepted; `rules lint` rejects others)
- `channel: file` — appends a JSONL line to a local file. `to` must be an absolute path; relative or `~`-prefixed paths are rejected by `rules lint` (code `notify-relative-path`) and at runtime
- `channel: openclaw` — HTTP POST to an OpenClaw endpoint (same protocol restriction)
- Optional `template` field supports `{{ rule.name }}`, `{{ event.* }}`, `{{ device.id }}` placeholders. Nested fields use dot paths, e.g. `{{ event.context.deviceMac }}`; arrays index numerically, e.g. `{{ event.list.0 }}`

```yaml
then:
- command: devices command AC_001 turnOn
- type: notify
channel: webhook
to: https://your.host/hook
template: '{"rule":"{{ rule.name }}","fired":"{{ rule.fired_at }}"}'
```

```bash
# 1. Author rules under `automation.rules`. See examples/policies/automation.yaml
# for a walkthrough covering the three trigger sources.
Expand Down Expand Up @@ -308,8 +326,29 @@ switchbot rules last-fired -n 20 # 20 most recent fire entries
switchbot rules conflicts # opposing actions, high-frequency MQTT,
# destructive commands, quiet-hours gaps
switchbot rules doctor --json # lint + conflicts combined; exit 0 when clean

# 8. Scaffold a new rule from natural language (heuristic or LLM-backed).
switchbot rules suggest --intent "turn off AC at 11pm"
switchbot rules suggest --intent "if door opens and temp below 20 turn on heater" \
--llm auto # routes complex intents to LLM automatically
switchbot rules suggest --intent "..." --llm openai # explicit backend
# Set OPENAI_API_KEY or ANTHROPIC_API_KEY; auto mode falls back to heuristic on failure
```

`rules suggest` enforces several guardrails on LLM output so a model can't quietly arm
something unsafe:

- **`dry_run` is forced to `true`** on every LLM-generated rule. Review the output and
flip it yourself before running the engine without `--dry-run`.
- **Explicit overrides always win.** If you pass `--trigger`, the LLM's answer must match;
a mismatch fails fast. Within the same trigger, mismatched `--event` / `--schedule` /
`--days` / `--webhook-path` are rewritten to your value with a warning.
- **`--llm` is enum-validated at the CLI** (`auto | openai | anthropic`) — junk values
exit non-zero instead of falling through.
- **Notify URLs must be `http://` or `https://`.** `rules lint` and the runtime both
reject `file://`, `ftp://`, etc., so a generated webhook can't smuggle in a non-HTTP
scheme.

When `quiet_hours` is configured in `policy.yaml`, `rules conflicts` additionally flags event-driven (MQTT / webhook) rules that lack a `time_between` condition — they would fire uninhibited during the quiet window. The hint in each finding includes a ready-to-paste `time_between` condition to add.

Webhook trigger token management:
Expand Down Expand Up @@ -842,8 +881,12 @@ Exposes MCP tools (`list_devices`, `describe_device`, `get_device_status`,
`send_command`, `list_scenes`, `run_scene`, `search_catalog`,
`account_overview`, `plan_suggest`, `plan_run`, `audit_query`,
`audit_stats`, `policy_diff`, `policy_validate`, `policy_new`,
`policy_migrate`) plus a `switchbot://events` resource for real-time
shadow updates.
`policy_migrate`, `rules_suggest`, `rule_notifications`) plus a
`switchbot://events` resource for real-time shadow updates.
`rules_suggest` accepts an optional `llm` parameter (`openai | anthropic | auto`)
to generate YAML for complex intents via an LLM backend.
`rule_notifications` returns `rule-notify` audit entries, filterable by rule
name, time range, channel, and result.
See [`docs/agent-guide.md`](./docs/agent-guide.md) for the full tool reference and safety rules (destructive-command guard).

### `doctor` — self-check
Expand All @@ -853,7 +896,7 @@ switchbot doctor
switchbot doctor --json
```

Runs local checks (Node version, credentials, profiles, catalog, cache, quota, clock, MQTT, policy, MCP) and exits 1 if any check fails. `warn` results exit 0. The MQTT check reports `ok` when REST credentials are configured (auto-provisioned on first use). Use this to diagnose connectivity or config issues before running automation.
Runs local checks (Node version, credentials, profiles, catalog, cache, quota, clock, MQTT, policy, MCP, notify-connectivity) and exits 1 if any check fails. `warn` results exit 0. The MQTT check reports `ok` when REST credentials are configured (auto-provisioned on first use). The `notify-connectivity` check probes webhook URLs declared in `type: notify` actions. Use this to diagnose connectivity or config issues before running automation.

`--json` output includes `maturityScore` (0–100) and `maturityLabel` (`production-ready` / `mostly-ready` / `needs-work` / `not-ready`) to give an at-a-glance readiness rating:

Expand Down Expand Up @@ -1165,8 +1208,16 @@ src/
│ ├── audit-query.ts # Audit log filtering + aggregation
│ ├── conflict-analyzer.ts # Static conflict detection (opposing actions,
│ │ # high-freq MQTT, destructive cmds, quiet-hours gaps)
│ ├── suggest.ts # Heuristic-based rule YAML generation
│ └── types.ts # Shared rule/trigger/condition/action types
│ ├── suggest.ts # Heuristic + LLM-backed rule YAML generation
│ ├── notify.ts # notify action executor (webhook / file / openclaw)
│ └── types.ts # Shared rule/trigger/condition/action types (CommandAction | NotifyAction)
├── llm/
│ ├── index.ts # createLLMProvider factory + LLM_AUTO_THRESHOLD
│ ├── complexity.ts # Intent complexity scorer (0–10) for auto-routing
│ ├── rule-prompt.ts # System prompt builder (embeds v0.2 schema snippet)
│ └── providers/
│ ├── openai.ts # OpenAI-compatible provider (uses Node.js https)
│ └── anthropic.ts # Anthropic provider
├── status-sync/
│ └── manager.ts # Spawn/stop logic, state file, OpenClaw bridge
├── lib/
Expand Down
17 changes: 16 additions & 1 deletion docs/design/roadmap.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Roadmap — Phase 1 through Phase 4

> **Status as of 2026-04-23:** Phase 1 complete, Phase 2 complete,
> **Status as of 2026-05-06:** Phase 1 complete, Phase 2 complete,
> Phase 3A complete (keychain + install library + built-in CLI install
> command), Phase 3B tracked in the separate companion skill repo,
> Phase 4 shipped at v0.2 (rules engine with MQTT + cron +
Expand All @@ -9,6 +9,8 @@
> v2.14.0 extends MCP with `plan_run`, `audit_query`, `audit_stats`,
> and `policy_diff`; v2.15.0 flips `policy new` default schema to v0.2
> and starts the v0.1 deprecation window.
> Tracks θ (notify actions) and η (LLM-backed rule suggestion)
> shipped in v3.0.
> Note: Track γ is a runtime capability increment on the v0.2 rule
> model, not a separate policy schema version.

Expand Down Expand Up @@ -181,6 +183,19 @@ the skill's `manifest.json` `roadmap` block, which points back here.
- **Track ε — cross-OS CI matrix for keychain *(shipped, v2.11.0)*.**
GitHub Actions matrix: macOS (temp keychain), Linux (D-Bus +
gnome-keyring), Windows (native Credential Manager).
- **Track θ — notify actions *(shipped, v3.0)*.**
New `type: notify` action alongside `type: command` in the v0.2
schema. Rules can POST to webhooks, append JSONL to a file, or push
to OpenClaw after firing, closing the feedback loop for AI agents.
`lintRules` validates URL syntax and required fields; engine dispatches
to `executeNotifyAction`; audit gains `rule-notify` kind; MCP gains
`rule_notifications` tool; `doctor` gains `notify-connectivity` check.
- **Track η — LLM-backed rule suggestion *(shipped, v3.0)*.**
`rules suggest --llm <backend>` routes complex intents (complexity
score ≥ 4) to OpenAI or Anthropic, falls back to heuristic with a
warning on provider failure. `rules_suggest` MCP tool gains a `llm`
parameter. All LLM calls are written to the audit log as
`kind: llm-suggest` with backend, model, and latency fields.

## Next execution queue (ordered)

Expand Down
36 changes: 36 additions & 0 deletions src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -922,6 +922,41 @@ function checkReleaseNotes(): Check {
};
}

function checkNotifyConnectivity(): Check {
const policyPath = resolvePolicyPath();
let loaded: { data: unknown };
try {
loaded = loadPolicyFile(policyPath);
} catch (err) {
if (err instanceof PolicyFileNotFoundError) {
return { name: 'notify-connectivity', status: 'ok', detail: { present: false, message: 'no policy file configured' } };
}
return { name: 'notify-connectivity', status: 'ok', detail: { present: false, message: 'policy file could not be loaded' } };
}

const policy = loaded.data as { automation?: { rules?: Array<{ then?: Array<{ type?: string; channel?: string; to?: string }> }> } } | null;
const rules = policy?.automation?.rules ?? [];
const webhookUrls: string[] = [];
for (const rule of rules) {
for (const action of rule.then ?? []) {
if (action.type === 'notify' && (action.channel === 'webhook' || action.channel === 'openclaw') && action.to) {
webhookUrls.push(action.to);
}
}
}

if (webhookUrls.length === 0) {
return { name: 'notify-connectivity', status: 'ok', detail: { webhookCount: 0, message: 'no webhook notify actions in policy' } };
}

return {
name: 'notify-connectivity',
status: 'ok',
detail: { webhookCount: webhookUrls.length, message: `${webhookUrls.length} webhook URL(s) configured (live probe not run — use --probe to test connectivity)` },
};
}


interface CheckDef {
name: string;
description: string;
Expand Down Expand Up @@ -955,6 +990,7 @@ const CHECK_REGISTRY: CheckDef[] = [
{ name: 'audit', description: 'recent command errors (last 24h)', run: () => checkAudit() },
{ name: 'daemon', description: 'daemon state file + runtime status', run: () => checkDaemon() },
{ name: 'health', description: 'health endpoint availability (daemon --healthz-port)', run: () => checkHealthEndpoint() },
{ name: 'notify-connectivity', description: 'webhook URLs from notify actions in policy.yaml', run: () => checkNotifyConnectivity() },
];

interface FixResult {
Expand Down
54 changes: 51 additions & 3 deletions src/commands/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1874,14 +1874,59 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
},
);

// ---- rule_notifications ---------------------------------------------------
server.registerTool(
'rule_notifications',
{
title: 'Query rule notification delivery history',
description:
'Returns audit entries for notify actions (kind: rule-notify). ' +
'Useful for confirming whether a notification rule fired and whether delivery succeeded. ' +
'Filter by rule name, time range, result, or channel.',
_meta: { agentSafetyTier: 'read' },
inputSchema: z.object({
file: z.string().optional().describe('Optional audit log path; defaults to ~/.switchbot/audit.log.'),
rule: z.string().optional().describe('Filter by rule name (exact match).'),
since: z.string().optional().describe('Relative window ending now (e.g. "30m", "24h").'),
from: z.string().optional().describe('Range start (ISO-8601).'),
to: z.string().optional().describe('Range end (ISO-8601).'),
result: z.enum(['ok', 'error']).optional().describe('Filter by delivery result.'),
channel: z.enum(['webhook', 'openclaw', 'file']).optional().describe('Filter by notify channel.'),
limit: z.number().int().min(1).max(500).default(100).describe('Max entries to return (newest first).'),
}).strict(),
outputSchema: {
entries: z.array(z.unknown()).describe('Matching audit entries, newest first.'),
total: z.number().int().describe('Count after filtering.'),
},
},
({ file, rule: ruleName, since, from, to, result: resultFilter, channel, limit }) => {
const filePath = file ?? DEFAULT_AUDIT_LOG_FILE;
let entries = readAudit(filePath).filter(e => e.kind === 'rule-notify');
if (ruleName) entries = entries.filter(e => e.rule?.name === ruleName);
if (resultFilter) entries = entries.filter(e => e.result === resultFilter);
if (channel) entries = entries.filter(e => e.notifyChannel === channel);
try {
entries = filterAuditEntries(entries, { since, from, to });
} catch (err) {
return mcpError('usage', 2, err instanceof Error ? err.message : 'invalid filter options');
}
const bounded = entries.slice(-limit).reverse();
const out = { entries: bounded, total: entries.length };
return {
content: [{ type: 'text' as const, text: JSON.stringify(out, null, 2) }],
structuredContent: out,
};
},
);

// ---- rules_suggest --------------------------------------------------------
server.registerTool(
'rules_suggest',
{
title: 'Draft a SwitchBot automation rule from intent',
description:
'Generate a candidate automation rule YAML from a natural language intent. ' +
'Uses keyword heuristics (no LLM) to infer trigger, schedule, and command. ' +
'Uses keyword heuristics by default; pass llm to use an LLM backend (auto | openai | anthropic). ' +
'Always emits dry_run: true — the rule must be reviewed before arming. ' +
'Pass the returned rule_yaml to policy_add_rule to inject it into policy.yaml.',
_meta: { agentSafetyTier: 'read' },
Expand All @@ -1893,27 +1938,29 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
schedule: z.string().optional().describe('5-field cron expression override (e.g. "0 22 * * *").'),
days: z.array(z.string()).optional().describe('Weekday filter (e.g. ["mon","tue","wed","thu","fri"]).'),
webhook_path: z.string().optional().describe('Webhook path override (default /action).'),
llm: z.enum(['auto', 'openai', 'anthropic']).optional().describe('LLM backend (auto | openai | anthropic). Omit to use keyword heuristics.'),
}).strict(),
outputSchema: {
rule: z.unknown().describe('Rule object matching the v0.2 policy schema.'),
rule_yaml: z.string().describe('YAML string ready to pipe to policy_add_rule.'),
warnings: z.array(z.string()).describe('Informational warnings (e.g. unrecognized intent defaulted).'),
},
},
({ intent, trigger, device_ids, event, schedule, days, webhook_path }) => {
async ({ intent, trigger, device_ids, event, schedule, days, webhook_path, llm }) => {
const devices = (device_ids ?? []).map((id) => {
const cached = getCachedDevice(id);
return { id, name: cached?.name, type: cached?.type };
});
try {
const { rule, ruleYaml, warnings } = suggestRule({
const { rule, ruleYaml, warnings } = await suggestRule({
intent,
trigger,
devices,
event,
schedule,
days,
webhookPath: webhook_path,
llm,
});
return {
content: [{ type: 'text' as const, text: ruleYaml }],
Expand Down Expand Up @@ -2039,6 +2086,7 @@ export function registerMcpCommand(program: Command): void {
- plan_run validate + execute a Plan JSON document
- audit_query filter audit log entries by time/device/rule/result
- audit_stats aggregate audit counts by kind/result/device/rule
- rule_notifications query rule notify action delivery history
- rules_suggest draft an automation rule YAML from intent (heuristic, no LLM)
- policy_add_rule append a rule into automation.rules[] in policy.yaml

Expand Down
Loading
Loading