diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ca1c4407..faeeca68 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -35,13 +35,16 @@ jobs: CLI_VERSION=$(node -p "require('./package.json').version") CODEX_VERSION=$(node -p "require('./packages/codex-plugin/package.json').version") OPENCLAW_VERSION=$(node -p "require('./packages/openclaw-skill/package.json').version") - echo "Root CLI: @switchbot/openapi-cli@$CLI_VERSION" - echo "Codex plugin: @switchbot/codex-plugin@$CODEX_VERSION" - echo "OpenClaw plugin: @switchbot/openclaw-skill@$OPENCLAW_VERSION" + CLAUDE_CODE_VERSION=$(node -p "require('./packages/claude-code-plugin/package.json').version") + echo "Root CLI: @switchbot/openapi-cli@$CLI_VERSION" + echo "Codex plugin: @switchbot/codex-plugin@$CODEX_VERSION" + echo "OpenClaw plugin: @switchbot/openclaw-skill@$OPENCLAW_VERSION" + echo "Claude Code plugin: @switchbot/claude-code-plugin@$CLAUDE_CODE_VERSION" { echo "cli_version=$CLI_VERSION" echo "codex_version=$CODEX_VERSION" echo "openclaw_version=$OPENCLAW_VERSION" + echo "claude_code_version=$CLAUDE_CODE_VERSION" } >> "$GITHUB_OUTPUT" id: versions @@ -51,6 +54,7 @@ jobs: CLI_VERSION: ${{ steps.versions.outputs.cli_version }} CODEX_VERSION: ${{ steps.versions.outputs.codex_version }} OPENCLAW_VERSION: ${{ steps.versions.outputs.openclaw_version }} + CLAUDE_CODE_VERSION: ${{ steps.versions.outputs.claude_code_version }} run: | # For each package, query npm; if the exact version is already published, skip. check_unpublished() { @@ -67,13 +71,16 @@ jobs: CLI_PUBLISH=$(check_unpublished "@switchbot/openapi-cli" "$CLI_VERSION") CODEX_PUBLISH=$(check_unpublished "@switchbot/codex-plugin" "$CODEX_VERSION") OPENCLAW_PUBLISH=$(check_unpublished "@switchbot/openclaw-skill" "$OPENCLAW_VERSION") + CLAUDE_CODE_PUBLISH=$(check_unpublished "@switchbot/claude-code-plugin" "$CLAUDE_CODE_VERSION") echo "cli_publish=$CLI_PUBLISH" echo "codex_publish=$CODEX_PUBLISH" echo "openclaw_publish=$OPENCLAW_PUBLISH" + echo "claude_code_publish=$CLAUDE_CODE_PUBLISH" { echo "cli_publish=$CLI_PUBLISH" echo "codex_publish=$CODEX_PUBLISH" echo "openclaw_publish=$OPENCLAW_PUBLISH" + echo "claude_code_publish=$CLAUDE_CODE_PUBLISH" } >> "$GITHUB_OUTPUT" - name: Verify codex-plugin tarball peerDep is a concrete range @@ -106,6 +113,21 @@ jobs: echo "OK: openclaw-skill peerDep = '$PEER'" rm -f "/tmp/$TARBALL" + - name: Verify claude-code-plugin tarball peerDep is a concrete range + if: steps.detect.outputs.claude_code_publish == 'true' + run: | + TARBALL=$(npm pack -w @switchbot/claude-code-plugin --pack-destination /tmp/ 2>&1 | tail -1) + PEER=$(tar -xOzf "/tmp/$TARBALL" package/package.json | node -e " + const p = JSON.parse(require('fs').readFileSync(0, 'utf8')); + console.log(p.peerDependencies?.['@switchbot/openapi-cli'] || ''); + ") + if [ -z "$PEER" ] || echo "$PEER" | grep -q "workspace:"; then + echo "FAIL: claude-code-plugin peerDep is missing or unrewritten workspace:* — got: '$PEER'" + exit 1 + fi + echo "OK: claude-code-plugin peerDep = '$PEER'" + rm -f "/tmp/$TARBALL" + - name: Publish root CLI to npm if: steps.detect.outputs.cli_publish == 'true' run: npm publish --provenance --access public @@ -128,6 +150,14 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Publish claude-code-plugin to npm + id: publish_claude_code + if: steps.detect.outputs.claude_code_publish == 'true' + continue-on-error: true + run: npm publish -w @switchbot/claude-code-plugin --provenance --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Annotate plugin publish failures if: steps.detect.outputs.codex_publish == 'true' && steps.publish_codex.outcome == 'failure' run: | @@ -137,3 +167,8 @@ jobs: if: steps.detect.outputs.openclaw_publish == 'true' && steps.publish_openclaw.outcome == 'failure' run: | echo "::warning::openclaw-skill publish step failed; root CLI promotion is unaffected. Investigate before next release." + + - name: Annotate Claude Code plugin publish failures + if: steps.detect.outputs.claude_code_publish == 'true' && steps.publish_claude_code.outcome == 'failure' + run: | + echo "::warning::claude-code-plugin publish step failed; root CLI promotion is unaffected. Investigate before next release." diff --git a/README.md b/README.md index 43a03616..85085439 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,33 @@ switchbot auth login # browser OAuth — saves to OS keychain --- +## Claude Code integration + +The Claude Code plugin is published to npm as [`@switchbot/claude-code-plugin`](https://www.npmjs.com/package/@switchbot/claude-code-plugin). + +**Recommended — paste into Claude Code chat:** + +``` +Please set up the SwitchBot integration for me by running: +npm install -g @switchbot/claude-code-plugin +claude plugins add @switchbot/claude-code-plugin +Then restart Claude Code and confirm it's working. +``` + +**Or install directly:** + +```bash +npm install -g @switchbot/claude-code-plugin +claude plugins add @switchbot/claude-code-plugin # registers the MCP server and skill +switchbot auth login # browser OAuth — saves to OS keychain +``` + +`claude plugins add` runs the `onInstall` hook automatically. If SwitchBot credentials are not yet configured, a browser login window opens. Run `switchbot-claude-auth` at any time to re-authenticate. + +**Note:** The root `marketplace.json` file in this repo is for Codex CLI Route B (git sparse clone) and points to the Codex plugin at `packages/codex-plugin/plugins/switchbot`. Claude Code users install via npm and do not use this file. + +--- + ## Credentials > **Recommended:** use `switchbot auth login` for browser-based OAuth — credentials are stored securely in the OS keychain and never need to be copy-pasted anywhere. diff --git a/docs/superpowers/plans/2026-05-28-test-fixes.md b/docs/superpowers/plans/2026-05-28-test-fixes.md new file mode 100644 index 00000000..6a7e427b --- /dev/null +++ b/docs/superpowers/plans/2026-05-28-test-fixes.md @@ -0,0 +1,405 @@ +# Test Fixes Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix 8 test-quality issues found in the code review of `feat/claude-code-plugin` — all changes are in test files only, no production code touched. + +**Architecture:** Surgical one-file-at-a-time fixes. Each task edits one file, runs its test suite to confirm green, and commits. Order: codex-plugin tests → claude-code-plugin tests → root vitest tests. + +**Tech Stack:** Node.js `node:test` (codex-plugin, claude-code-plugin), Vitest (root tests). + +--- + +## File Map + +| File | Fixes | +|------|-------| +| `packages/codex-plugin/tests/skill-sync.test.js` | #1 (import.meta.dirname), #6 (regex), #7 (SKILL_3) | +| `packages/claude-code-plugin/plugins/switchbot/skills/switchbot/SKILL.md` | #7 companion (add MAINTENANCE comment) | +| `packages/claude-code-plugin/tests/hooks.test.js` | #2 (args.length), #8 (ENOENT cascade) | +| `tests/readme-route-b.test.ts` | #3 (indexOf), #4 ('not for') | +| `packages/codex-plugin/tests/marketplace-schema.test.js` | #5 (Object.keys) | + +--- + +## Task 1: Fix `skill-sync.test.js` (fixes #1, #6, #7) + +**Files:** +- Modify: `packages/codex-plugin/tests/skill-sync.test.js` +- Modify: `packages/claude-code-plugin/plugins/switchbot/skills/switchbot/SKILL.md` (MAINTENANCE comment) + +**Context — why SKILL_3 is not added to the identity assertion:** +The `claude-code-plugin` SKILL.md diverges intentionally at line 37 (Claude Code network setup vs Codex network setup). Adding it to the content-equality assertion would cause an immediate false failure. Fix #7 therefore adds SKILL_3 only to the "exists" and "has MAINTENANCE comment" sub-tests. + +- [ ] **Step 1: Verify current tests pass** + +``` +cd packages/codex-plugin && npm test +``` + +Expected: `# pass 56 # fail 0` + +- [ ] **Step 2: Add MAINTENANCE comment to the third SKILL.md** + +Append one line to `packages/claude-code-plugin/plugins/switchbot/skills/switchbot/SKILL.md` (currently 203 lines, ends after the Version section): + +```markdown + + +``` + +- [ ] **Step 3: Rewrite `skill-sync.test.js`** + +Replace the entire file with: + +```js +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const SKILL_1 = path.join(__dirname, '../skills/switchbot/SKILL.md'); +const SKILL_2 = path.join(__dirname, '../plugins/switchbot/skills/switchbot/SKILL.md'); +// claude-code-plugin copy — intentionally different content at line 37 (plugin-specific network setup), +// but must still exist on disk and carry a MAINTENANCE comment. +const SKILL_3 = path.join(__dirname, '../../claude-code-plugin/plugins/switchbot/skills/switchbot/SKILL.md'); + +test('SKILL.md files have maintenance comments and identical content', async (t) => { + await t.test('all SKILL.md files exist', () => { + assert.ok(fs.existsSync(SKILL_1), `${SKILL_1} should exist`); + assert.ok(fs.existsSync(SKILL_2), `${SKILL_2} should exist`); + assert.ok(fs.existsSync(SKILL_3), `${SKILL_3} should exist`); + }); + + await t.test('all SKILL.md files contain MAINTENANCE comment', () => { + const content1 = fs.readFileSync(SKILL_1, 'utf8'); + const content2 = fs.readFileSync(SKILL_2, 'utf8'); + const content3 = fs.readFileSync(SKILL_3, 'utf8'); + + assert.ok( + content1.includes('\s*$/, ''); + }; + + const normalized1 = removeMaintenanceComment(content1); + const normalized2 = removeMaintenanceComment(content2); + + assert.equal( + normalized1, + normalized2, + 'SKILL.md files (1 and 2) should have identical content except for maintenance comments' + ); + }); +}); +``` + +Key changes vs original: +- Line 5: added `import { fileURLToPath } from 'node:url';` +- Line 7: `path.dirname(fileURLToPath(import.meta.url))` replaces `import.meta.dirname` (fix #1) +- Line 10-11: `path.join(__dirname, ...)` now works correctly +- Line 13-15: added `SKILL_3` constant +- Sub-test "exists": now checks all three files +- Sub-test "MAINTENANCE comment": now checks all three files +- Sub-test "identical content": renamed to "codex-plugin SKILL.md files…", still checks only SKILL_1 vs SKILL_2 +- Regex: `/\n\s*$/` replaces `/\n\s*$/` (fix #6) + +- [ ] **Step 4: Run tests** + +``` +cd packages/codex-plugin && npm test +``` + +Expected: all tests pass, count increases by 1 (new `SKILL_3` assertions in the exist/MAINTENANCE sub-tests). + +- [ ] **Step 5: Commit** + +``` +git add packages/codex-plugin/tests/skill-sync.test.js +git add packages/claude-code-plugin/plugins/switchbot/skills/switchbot/SKILL.md +git commit -m "fix(tests): replace import.meta.dirname, fix regex, add SKILL_3 guard" +``` + +--- + +## Task 2: Fix `hooks.test.js` (fixes #2, #8) + +**Files:** +- Modify: `packages/claude-code-plugin/tests/hooks.test.js` + +- [ ] **Step 1: Verify current tests pass** + +``` +cd packages/claude-code-plugin && npm test +``` + +Expected: all tests pass. + +- [ ] **Step 2: Rewrite `hooks.test.js`** + +Replace the entire file with: + +```js +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, existsSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const pkgRoot = resolve(__dirname, '..'); + +const HOOKS_FILES = [ + { + label: '.claude-plugin/hooks.json (root)', + path: resolve(pkgRoot, '.claude-plugin', 'hooks.json'), + }, + { + label: 'plugins/switchbot/.claude-plugin/hooks.json', + path: resolve(pkgRoot, 'plugins', 'switchbot', '.claude-plugin', 'hooks.json'), + }, +]; + +describe('hooks.json files', () => { + for (const { label, path: hooksPath } of HOOKS_FILES) { + describe(label, () => { + it('exists on disk', () => { + assert.ok(existsSync(hooksPath), `Missing: ${hooksPath}`); + }); + + if (existsSync(hooksPath)) { + it('is valid JSON', () => { + const raw = readFileSync(hooksPath, 'utf8'); + assert.doesNotThrow(() => JSON.parse(raw), `Invalid JSON in ${hooksPath}`); + }); + + it('has onInstall.command === "node"', () => { + const hooks = JSON.parse(readFileSync(hooksPath, 'utf8')); + assert.equal(hooks?.onInstall?.command, 'node', + `Expected onInstall.command to be "node" in ${hooksPath}`); + }); + + it('onInstall.args[0] resolves to an existing file', () => { + const hooks = JSON.parse(readFileSync(hooksPath, 'utf8')); + const relPath = hooks?.onInstall?.args?.[0]; + assert.ok(typeof relPath === 'string', `onInstall.args[0] must be a string in ${hooksPath}`); + const resolved = resolve(dirname(hooksPath), relPath); + assert.ok(existsSync(resolved), + `onInstall.args[0] "${relPath}" resolves to "${resolved}" which does not exist`); + }); + + it('onInstall.args has exactly one element', () => { + const hooks = JSON.parse(readFileSync(hooksPath, 'utf8')); + const args = hooks?.onInstall?.args; + assert.ok(Array.isArray(args), `onInstall.args must be an array in ${hooksPath}`); + assert.equal(args.length, 1, `onInstall.args should have exactly one element in ${hooksPath}`); + }); + } + }); + } +}); +``` + +Key changes vs original: +- Lines 27-54: the four `it` blocks are now wrapped in `if (existsSync(hooksPath))` — if the file is missing, only "exists on disk" runs and fails with a clear message (fix #8). `describe` callbacks are synchronous in `node:test`, so this registration-time guard works correctly. +- Line 51: added `assert.ok(Array.isArray(args), ...)` before `args.length` (fix #2). +- Line 52: updated message to include `hooksPath` for clarity. + +- [ ] **Step 3: Run tests** + +``` +cd packages/claude-code-plugin && npm test +``` + +Expected: all tests pass. + +- [ ] **Step 4: Commit** + +``` +git add packages/claude-code-plugin/tests/hooks.test.js +git commit -m "fix(tests): guard args.length undefined and wrap ENOENT-prone it blocks" +``` + +--- + +## Task 3: Fix `readme-route-b.test.ts` (fixes #3, #4) + +**Files:** +- Modify: `tests/readme-route-b.test.ts` + +- [ ] **Step 1: Verify current tests pass** + +``` +npm test -- tests/readme-route-b.test.ts +``` + +Expected: 3 tests pass. + +- [ ] **Step 2: Rewrite `readme-route-b.test.ts`** + +Replace the entire file with: + +```ts +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const readmeContent = readFileSync( + path.join(here, '..', 'README.md'), + 'utf-8', +); + +describe('README.md — Route B documentation', () => { + it('mentions "Route B" to explain marketplace.json purpose', () => { + expect(readmeContent.toLowerCase()).toMatch(/route\s+b/i); + }); + + it('explains marketplace.json in context of Codex or Route B', () => { + expect(readmeContent).toContain('marketplace.json'); + + // Collect all offsets where 'marketplace.json' appears (fix #3: indexOf only finds first) + const term = 'marketplace.json'; + const offsets: number[] = []; + let pos = readmeContent.indexOf(term); + while (pos !== -1) { + offsets.push(pos); + pos = readmeContent.indexOf(term, pos + 1); + } + + const hasContext = offsets.some((offset) => { + const window = readmeContent + .slice(Math.max(0, offset - 300), offset + 300) + .toLowerCase(); + return window.includes('codex') || window.includes('route'); + }); + expect(hasContext).toBe(true); + }); + + it('clarifies that root marketplace.json is not for Claude Code users', () => { + // fix #4: removed the broad 'not for' fallback; now requires 'claude code' near the term + const term = 'marketplace.json'; + let pos = readmeContent.indexOf(term); + let found = false; + while (pos !== -1) { + const window = readmeContent + .slice(Math.max(0, pos - 300), pos + 300) + .toLowerCase(); + if (window.includes('claude code')) { + found = true; + break; + } + pos = readmeContent.indexOf(term, pos + 1); + } + expect(found).toBe(true); + }); +}); +``` + +Key changes vs original: +- Test 2 (lines 17-30): replaced single `indexOf` + slice with a `while` loop collecting all offsets, then `some()` — passes if any occurrence is near context keywords (fix #3). +- Test 3 (lines 32-38): replaced `includes('claude code') || includes('not for')` with the same multi-occurrence loop requiring `'claude code'` in the window around each `marketplace.json` occurrence (fix #4). + +- [ ] **Step 3: Run tests** + +``` +npm test -- tests/readme-route-b.test.ts +``` + +Expected: 3 tests pass. + +- [ ] **Step 4: Commit** + +``` +git add tests/readme-route-b.test.ts +git commit -m "fix(tests): use all-occurrences search and tighten Claude Code guard" +``` + +--- + +## Task 4: Fix `marketplace-schema.test.js` (fix #5) + +**Files:** +- Modify: `packages/codex-plugin/tests/marketplace-schema.test.js` + +- [ ] **Step 1: Verify current tests pass** + +``` +cd packages/codex-plugin && npm test +``` + +Expected: all tests pass. + +- [ ] **Step 2: Replace the `Object.keys` ordering assertion** + +In `packages/codex-plugin/tests/marketplace-schema.test.js`, replace lines 38-43: + +```js + it('$schema is the first field in codex-plugin marketplace.json', () => { + const rawContent = readFileSync(codexPluginMarketplacePath, 'utf8'); + const parsed = JSON.parse(rawContent); + const firstKey = Object.keys(parsed)[0]; + assert.equal(firstKey, '$schema', 'first field should be $schema'); + }); +``` + +with: + +```js + it('$schema is the first field in codex-plugin marketplace.json', () => { + const rawContent = readFileSync(codexPluginMarketplacePath, 'utf8'); + assert.ok( + rawContent.indexOf('"$schema"') < rawContent.indexOf('"name"'), + '$schema should appear before "name" in the raw JSON text', + ); + }); +``` + +This compares raw string positions instead of relying on `Object.keys()` insertion-order preservation (a V8 implementation detail, not a JSON-spec guarantee). + +- [ ] **Step 3: Run tests** + +``` +cd packages/codex-plugin && npm test +``` + +Expected: all tests pass. + +- [ ] **Step 4: Commit** + +``` +git add packages/codex-plugin/tests/marketplace-schema.test.js +git commit -m "fix(tests): replace Object.keys ordering with raw string position check" +``` + +--- + +## Verification + +After all four tasks, run the full test suite from the repo root to confirm nothing regressed: + +``` +npm run test:workspaces +``` + +Expected: all workspace test suites pass with 0 failures. diff --git a/docs/superpowers/specs/2026-05-28-test-fixes-design.md b/docs/superpowers/specs/2026-05-28-test-fixes-design.md new file mode 100644 index 00000000..70998370 --- /dev/null +++ b/docs/superpowers/specs/2026-05-28-test-fixes-design.md @@ -0,0 +1,130 @@ +# Test Fixes Design — 2026-05-28 + +## Background + +A code review of the `feat/claude-code-plugin` branch identified 8 issues, all in test files. +No production code changes are required. All fixes follow Approach A (surgical, minimal diff). + +--- + +## Fix #1 — `import.meta.dirname` crash on Node < 21.2 + +**File:** `packages/codex-plugin/tests/skill-sync.test.js` line 6 + +**Problem:** `import.meta.dirname` was introduced in Node 21.2. The package declares +`"engines": { "node": ">=18" }`, so Node 18/19/20/21.0-21.1 throw `TypeError` at startup, +aborting the entire test file. + +**Fix:** Replace `import.meta.dirname` with `dirname(fileURLToPath(import.meta.url))`. +Add `dirname` to the `node:path` import and `fileURLToPath` from `node:url`. + +--- + +## Fix #2 — `args.length` TypeError on undefined + +**File:** `packages/claude-code-plugin/tests/hooks.test.js` lines 50-51 + +**Problem:** `hooks?.onInstall?.args` can be `undefined`; calling `.length` on it throws +`TypeError` instead of producing the custom `AssertionError` message. + +**Fix:** Add `assert.ok(Array.isArray(args), 'onInstall.args must be an array in ...')` before +the `assert.equal(args.length, 1, ...)` call. + +--- + +## Fix #3 — `indexOf` only checks first occurrence of `marketplace.json` + +**File:** `tests/readme-route-b.test.ts` lines 20-29 + +**Problem:** `readmeContent.indexOf('marketplace.json')` returns the first occurrence only. If +`marketplace.json` appears earlier in an unrelated context, the ±300-char window never overlaps +the Route B explanation. + +**Fix:** Collect all occurrence offsets with a `matchAll` loop (or `while indexOf`), then use +`Array.prototype.some()`: pass if any occurrence has `codex` or `route` within ±300 chars. + +--- + +## Fix #4 — `'not for'` guard too broad + +**File:** `tests/readme-route-b.test.ts` lines 34-37 + +**Problem:** `content.includes('not for')` matches any two-word phrase, making the assertion +trivially satisfiable regardless of whether the actual disclaimer exists. + +**Fix:** Replace the OR logic with a single, specific check: find the `marketplace.json` +occurrence in context and assert that the same ±300-char window also contains `claude code`. + +--- + +## Fix #5 — `Object.keys` ordering relies on V8 implementation detail + +**File:** `packages/codex-plugin/tests/marketplace-schema.test.js` lines 41-43 + +**Problem:** `Object.keys(JSON.parse(rawContent))[0]` relies on V8's key-insertion-order +preservation, which is not guaranteed by the JSON specification. + +**Fix:** Replace the parsed-object check with a raw-string position comparison: +`rawContent.indexOf('"$schema"') < rawContent.indexOf('"name"')`. + +--- + +## Fix #6 — Regex flag missing for multiline MAINTENANCE comments + +**File:** `packages/codex-plugin/tests/skill-sync.test.js` line 35 + +**Problem:** `/\n\s*$/` — `.` does not match `\n` by default. A +multi-line comment body is not stripped, leaving different comment texts in the comparison and +causing false failures. + +**Fix:** Change the regex to `/\n\s*$/` so `[\s\S]*?` matches +across newlines. Anchor remains `$`. + +--- + +## Fix #7 — Sync test misses third SKILL.md copy + +**File:** `packages/codex-plugin/tests/skill-sync.test.js` lines 6-7 + +**Problem:** `SKILL_1` and `SKILL_2` both live inside `packages/codex-plugin/`. The third copy +at `packages/claude-code-plugin/plugins/switchbot/skills/switchbot/SKILL.md` has no MAINTENANCE +comment and is not covered by any sync guard. + +**Fix (two parts):** +1. Add `SKILL_3` constant pointing to the `claude-code-plugin` copy. +2. Extend the test to assert all three files have identical content (after stripping MAINTENANCE + comments) and each contains a MAINTENANCE comment. +3. Add a MAINTENANCE comment to `SKILL_3` (the claude-code-plugin copy) so the comment-existence + assertion passes. + +--- + +## Fix #8 — ENOENT cascade when hooks file is missing + +**File:** `packages/claude-code-plugin/tests/hooks.test.js` + +**Problem:** If a hooks.json file does not exist, all `it()` blocks after `'exists on disk'` +throw raw `ENOENT` system errors, obscuring the single root cause. + +**Fix:** At the top of each inner `describe(label, ...)` callback, after the HOOKS_FILES loop +variable is bound, add a guard: `if (!existsSync(hooksPath)) { /* remaining its will skip */ }`. +In `node:test`, skipping is done with `it.skip()` or by returning early inside the `describe` +callback so the `it` declarations are never registered. + +Concretely: wrap the four `it` blocks (lines 29-54) inside `if (existsSync(hooksPath))`. +The `'exists on disk'` assertion still runs unconditionally and provides the clear failure +message. + +--- + +## Scope + +All 8 changes are in test files only: +- `packages/codex-plugin/tests/skill-sync.test.js` (fixes #1, #6, #7) +- `packages/claude-code-plugin/tests/hooks.test.js` (fixes #2, #8) +- `tests/readme-route-b.test.ts` (fixes #3, #4) +- `packages/codex-plugin/tests/marketplace-schema.test.js` (fix #5) +- `packages/claude-code-plugin/plugins/switchbot/skills/switchbot/SKILL.md` (companion to #7) + +No production code, no `package.json` engines field changes (fix #1 removes the offending API +call rather than raising the minimum version). diff --git a/marketplace.json b/marketplace.json new file mode 100644 index 00000000..c31215e8 --- /dev/null +++ b/marketplace.json @@ -0,0 +1,9 @@ +{ + "name": "switchbot", + "plugins": [ + { + "name": "switchbot", + "source": "./packages/codex-plugin/plugins/switchbot" + } + ] +} diff --git a/package-lock.json b/package-lock.json index 0a31626f..9760e30d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1121,6 +1121,10 @@ "win32" ] }, + "node_modules/@switchbot/claude-code-plugin": { + "resolved": "packages/claude-code-plugin", + "link": true + }, "node_modules/@switchbot/codex-plugin": { "resolved": "packages/codex-plugin", "link": true @@ -6606,6 +6610,25 @@ "zod": "^3.25.28 || ^4" } }, + "packages/claude-code-plugin": { + "name": "@switchbot/claude-code-plugin", + "version": "0.1.0", + "license": "MIT", + "bin": { + "switchbot-claude-auth": "bin/auth.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@switchbot/openapi-cli": ">=3.7.1" + }, + "peerDependenciesMeta": { + "@switchbot/openapi-cli": { + "optional": true + } + } + }, "packages/codex-plugin": { "name": "@switchbot/codex-plugin", "version": "0.1.3", diff --git a/packages/claude-code-plugin/.claude-plugin/hooks.json b/packages/claude-code-plugin/.claude-plugin/hooks.json new file mode 100644 index 00000000..8b1b5a9d --- /dev/null +++ b/packages/claude-code-plugin/.claude-plugin/hooks.json @@ -0,0 +1,6 @@ +{ + "onInstall": { + "command": "node", + "args": ["../bin/auth.js"] + } +} diff --git a/packages/claude-code-plugin/.claude-plugin/marketplace.json b/packages/claude-code-plugin/.claude-plugin/marketplace.json new file mode 100644 index 00000000..61c3709d --- /dev/null +++ b/packages/claude-code-plugin/.claude-plugin/marketplace.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", + "name": "switchbot", + "description": "SwitchBot smart-home plugin for Claude Code — MCP server with 24 tools for controlling devices and scenes", + "owner": { + "name": "OpenWonderLabs", + "email": "developer@wondertechlabs.com" + }, + "plugins": [ + { + "name": "switchbot", + "description": "Control SwitchBot smart-home devices from Claude Code via MCP.", + "version": "0.1.0", + "author": { + "name": "OpenWonderLabs" + }, + "source": "./plugins/switchbot", + "category": "productivity", + "homepage": "https://github.com/OpenWonderLabs/switchbot-openapi-cli" + } + ] +} diff --git a/packages/claude-code-plugin/README.md b/packages/claude-code-plugin/README.md new file mode 100644 index 00000000..c6dd1d6c --- /dev/null +++ b/packages/claude-code-plugin/README.md @@ -0,0 +1,46 @@ +# @switchbot/claude-code-plugin + +SwitchBot plugin for [Claude Code](https://claude.ai/claude-code) — wires Claude Code to the SwitchBot OpenAPI CLI MCP server, exposing 24 smart-home tools with policy-based safety gates. + +## Installation + +```bash +npm install -g @switchbot/claude-code-plugin +``` + +Then register as a Claude Code Marketplace source: + +```bash +claude plugins add @switchbot/claude-code-plugin +``` + +Claude Code will run the `onInstall` hook automatically. If SwitchBot credentials are not configured, a browser login window will open. + +## Manual auth setup + +```bash +switchbot-claude-auth +``` + +Or via the CLI directly: + +```bash +switchbot auth login +switchbot doctor +``` + +## Requirements + +- Node.js ≥ 18 +- `@switchbot/openapi-cli` ≥ 3.7.1 (installed globally or as a peer) +- Claude Code ≥ 1.x + +## What it does + +Registers the `switchbot` MCP server (`switchbot mcp serve --tools all`) with Claude Code. The skill document (`plugins/switchbot/skills/switchbot/SKILL.md`) guides Claude Code in safely controlling devices, reading sensors, running scenes, and respecting policy-based safety tiers. + +## Related packages + +- [`@switchbot/openapi-cli`](https://www.npmjs.com/package/@switchbot/openapi-cli) — the CLI and MCP server +- [`@switchbot/codex-plugin`](https://www.npmjs.com/package/@switchbot/codex-plugin) — OpenAI Codex CLI variant +- [`@switchbot/openclaw-skill`](https://www.npmjs.com/package/@switchbot/openclaw-skill) — OpenClaw / ClawhHub variant diff --git a/packages/claude-code-plugin/bin/auth.js b/packages/claude-code-plugin/bin/auth.js new file mode 100644 index 00000000..732d0cfc --- /dev/null +++ b/packages/claude-code-plugin/bin/auth.js @@ -0,0 +1,62 @@ +#!/usr/bin/env node +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; +import { checkCli as defaultCheckCli } from '../setup/check-cli.js'; +import { checkCredentials as defaultCheckCredentials } from '../setup/check-credentials.js'; +import { formatError } from '../lib/error-messages.js'; + +function defaultRunInherit(cmd, args) { + return new Promise((resolve) => { + const p = spawn(cmd, args, { stdio: 'inherit', shell: process.platform === 'win32' }); + p.on('close', code => resolve(code ?? 0)); + p.on('error', () => resolve(127)); + }); +} + +export function makeRunOnInstall({ checkCli, checkCredentials, runInherit }) { + return async function runOnInstall() { + const cliCheck = await checkCli(); + if (!cliCheck.ok) { + process.stderr.write(`[switchbot-claude] ${cliCheck.message}\n`); + return 1; + } + process.stderr.write(`[switchbot-claude] CLI ${cliCheck.version} detected.\n`); + + const credCheck = await checkCredentials(); + if (credCheck.ok) { + process.stderr.write(`[switchbot-claude] Credentials present (${credCheck.source}). Setup complete.\n`); + return 0; + } + + process.stderr.write('[switchbot-claude] SwitchBot credentials not found. Opening browser login...\n'); + const loginCode = await runInherit('switchbot', ['auth', 'login']); + if (loginCode !== 0) { + process.stderr.write(`[switchbot-claude] ${formatError('auth-login-failed')}\n`); + return loginCode; + } + + const postCheck = await checkCredentials(); + if (!postCheck.ok) { + process.stderr.write(`[switchbot-claude] ${postCheck.message ?? formatError(postCheck.errorKey ?? 'auth-login-failed')}\n`); + return 1; + } + + process.stderr.write('[switchbot-claude] Setup complete.\n'); + return 0; + }; +} + +const isMain = !!process.argv[1] && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (isMain) { + const run = makeRunOnInstall({ + checkCli: defaultCheckCli, + checkCredentials: defaultCheckCredentials, + runInherit: defaultRunInherit, + }); + run().then(code => process.exit(code)).catch(err => { + process.stderr.write(`[switchbot-claude] Fatal: ${err instanceof Error ? err.message : String(err)}\n`); + process.exit(1); + }); +} diff --git a/packages/claude-code-plugin/lib/error-messages.js b/packages/claude-code-plugin/lib/error-messages.js new file mode 100644 index 00000000..9290d1f6 --- /dev/null +++ b/packages/claude-code-plugin/lib/error-messages.js @@ -0,0 +1,47 @@ +export const ERRORS = { + 'auth-not-configured': { + reason: 'SwitchBot credentials are not configured.', + fix: 'switchbot auth login', + hint: 'Run the fix command, then restart your MCP client.', + }, + 'auth-login-failed': { + reason: 'Login failed — the CLI returned a non-zero exit code.', + fix: 'switchbot auth login', + hint: 'Check your network connection and try again.', + }, + 'token-expired': { + reason: 'Credentials exist but doctor check failed — token may be expired.', + fix: 'switchbot auth logout && switchbot auth login', + hint: 'After re-login, run `switchbot doctor` to verify.', + }, + 'credentials-invalid': { + reason: 'Credentials were found, but SwitchBot rejected them.', + fix: 'switchbot auth logout && switchbot auth login', + hint: 'Use this when install succeeded but the browser login did not complete cleanly, or the token was rotated.', + }, + 'doctor-check-failed': { + reason: 'The CLI could not complete the post-login health check.', + fix: 'switchbot doctor', + hint: 'Inspect the doctor output for network, API, or proxy failures before retrying login.', + }, + 'cli-not-installed': { + reason: 'switchbot CLI is not installed or not in PATH.', + fix: 'npm install -g @switchbot/openapi-cli', + hint: 'After install, run `switchbot doctor` to confirm.', + }, + 'cli-version-too-low': { + reason: 'switchbot CLI version is below the required minimum (3.7.1).', + fix: 'npm install -g @switchbot/openapi-cli@latest', + hint: 'After upgrade, re-run setup.', + }, +}; + +export function formatError(key) { + const e = ERRORS[key]; + if (!e) throw new Error(`unknown error key: ${key}`); + return [ + `Error: ${e.reason}`, + ` Fix: ${e.fix}`, + ` Hint: ${e.hint}`, + ].join('\n'); +} diff --git a/packages/claude-code-plugin/package.json b/packages/claude-code-plugin/package.json new file mode 100644 index 00000000..252f2277 --- /dev/null +++ b/packages/claude-code-plugin/package.json @@ -0,0 +1,50 @@ +{ + "name": "@switchbot/claude-code-plugin", + "version": "0.1.0", + "type": "module", + "description": "SwitchBot Claude Code plugin — wires Claude Code to the SwitchBot CLI MCP server (24 tools, zero Node.js dependencies)", + "homepage": "https://github.com/OpenWonderLabs/switchbot-openapi-cli/tree/main/packages/claude-code-plugin", + "repository": { + "type": "git", + "url": "git+https://github.com/OpenWonderLabs/switchbot-openapi-cli.git", + "directory": "packages/claude-code-plugin" + }, + "bugs": "https://github.com/OpenWonderLabs/switchbot-openapi-cli/issues", + "license": "MIT", + "keywords": [ + "claude-code", + "switchbot", + "smart-home", + "iot", + "mcp" + ], + "engines": { + "node": ">=18" + }, + "bin": { + "switchbot-claude-auth": "./bin/auth.js" + }, + "files": [ + "bin/", + "lib/", + "setup/", + ".claude-plugin/", + "plugins/", + "README.md" + ], + "peerDependencies": { + "@switchbot/openapi-cli": ">=3.7.1" + }, + "peerDependenciesMeta": { + "@switchbot/openapi-cli": { + "optional": true + } + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "test": "node --test", + "typecheck": "node --check bin/auth.js" + } +} diff --git a/packages/claude-code-plugin/plugins/switchbot/.claude-plugin/hooks.json b/packages/claude-code-plugin/plugins/switchbot/.claude-plugin/hooks.json new file mode 100644 index 00000000..fc77e3e5 --- /dev/null +++ b/packages/claude-code-plugin/plugins/switchbot/.claude-plugin/hooks.json @@ -0,0 +1,6 @@ +{ + "onInstall": { + "command": "node", + "args": ["../../../bin/auth.js"] + } +} diff --git a/packages/claude-code-plugin/plugins/switchbot/.claude-plugin/plugin.json b/packages/claude-code-plugin/plugins/switchbot/.claude-plugin/plugin.json new file mode 100644 index 00000000..dc96f09b --- /dev/null +++ b/packages/claude-code-plugin/plugins/switchbot/.claude-plugin/plugin.json @@ -0,0 +1,9 @@ +{ + "name": "switchbot", + "version": "0.1.0", + "description": "Control SwitchBot smart-home devices from Claude Code via MCP.", + "author": { + "name": "OpenWonderLabs" + }, + "homepage": "https://github.com/OpenWonderLabs/switchbot-openapi-cli" +} diff --git a/packages/claude-code-plugin/plugins/switchbot/.mcp.json b/packages/claude-code-plugin/plugins/switchbot/.mcp.json new file mode 100644 index 00000000..1737be66 --- /dev/null +++ b/packages/claude-code-plugin/plugins/switchbot/.mcp.json @@ -0,0 +1,6 @@ +{ + "switchbot": { + "command": "switchbot", + "args": ["mcp", "serve", "--tools", "all"] + } +} diff --git a/packages/claude-code-plugin/plugins/switchbot/skills/switchbot/SKILL.md b/packages/claude-code-plugin/plugins/switchbot/skills/switchbot/SKILL.md new file mode 100644 index 00000000..9df2692e --- /dev/null +++ b/packages/claude-code-plugin/plugins/switchbot/skills/switchbot/SKILL.md @@ -0,0 +1,205 @@ +--- +name: switchbot +description: Use when the user mentions SwitchBot devices, smart-home automation, or asks about controlling lights, locks, curtains, sensors, plugs, or IR appliances (TV/AC/fan). Teaches the agent how to drive the authoritative `switchbot` CLI safely, read user preferences from `policy.yaml`, and respect safety tiers. +--- + +# SwitchBot skill + +Drive the user's SwitchBot smart home through the `switchbot` CLI. Always query the CLI for ground truth — never guess commands, deviceIds, or parameter values. + +--- + +## Authority chain + +| Question | Authoritative command | +|---|---| +| What can I do (cold start)? | `switchbot agent-bootstrap --compact --json` | +| What commands exist? | `switchbot capabilities --json` | +| What flags does this command take? | `switchbot --help --json` | +| What devices does the user have? | `switchbot devices list --json` | +| What's this device doing right now? | `switchbot devices status --json` | +| What can I do with this specific device type? | `switchbot devices describe --json` | +| What scenes are configured? | `switchbot scenes list --json` | +| What's in the user's `policy.yaml`? | `cat ~/.config/openclaw/switchbot/policy.yaml` | +| Is my quota OK? | `switchbot quota status --json` | +| Is the setup healthy? | `switchbot doctor --json` | +| What automation rules are configured? | `switchbot rules list --json` | +| Are the rules valid? | `switchbot rules lint` | +| Draft an execution plan from intent | `switchbot plan suggest --intent "..." --device ` | +| Run a plan with per-step approval | `switchbot plan run --require-approval` | +| Draft an automation rule from intent | `switchbot rules suggest --intent "..." --device ` | +| Inject a rule into policy.yaml | `switchbot policy add-rule [--dry-run] [--enable]` (reads YAML from stdin) | + +--- + +## Network requirements + +Claude Code configures the SwitchBot MCP server automatically via `.mcp.json` — no manual setup required. The MCP server needs outbound HTTPS to `api.switch-bot.com`. If connection errors appear, see `references/claude-code-network.md`. + +--- + +## Required bootstrap + +Before any action, run: + +```bash +switchbot agent-bootstrap --compact +``` + +The response contains: `cliVersion`, `safetyTiers`, `nameStrategies`, `profile`, `quota`, `devices[]` (cached, with `deviceId`/`type`/`name`/`category`/`roomName`), `catalog`, and `hints[]`. + +If devices look stale (user just added one), refresh with `switchbot devices list --json`. + +Then read the user's policy: + +```bash +cat ~/.config/openclaw/switchbot/policy.yaml 2>/dev/null +``` + +If the file doesn't exist, proceed with default safety tiers and tell the user once they can create one with `switchbot policy new`. + +--- + +## Resolving a name to a device + +When the user says "bedroom light", resolve in this order: + +1. **alias** — `policy.yaml` alias map → ``. Most reliable. +2. **exact** — device `name == "bedroom light"` (case-insensitive). +3. **prefix** — name starts with the phrase. +4. **substring** — name contains the phrase. +5. **fuzzy** — Levenshtein distance ≤ 2. +6. **require-unique** — multiple matches at same tier → **stop and ask**. Never pick silently. + +--- + +## Safety gates + +| Tier | Examples | Behaviour | +|---|---|---| +| `read` | status, list, quota | Run freely. | +| `ir-fire-forget` | IR power/AC/TV via Hub | Run; warn there is no device-side confirmation. | +| `mutation` | turnOn/Off, setBrightness, setColor | Run. Append to audit log. | +| `destructive` | lock, unlock, delete scenes/webhooks | **Refuse by default.** Confirm explicitly; prefer `--dry-run` first. | +| `maintenance` | (reserved) | Always confirm. | + +Policy overrides: `confirmations.always_confirm` forces confirmation; `confirmations.never_confirm` pre-approves (never add `destructive` actions). `quiet_hours` requires confirmation even for `mutation`. + +--- + +## Policy compliance + +1. Call `policy_validate` (with `live: true`) once per device-control session. +2. Honour `quiet_hours`, `always_confirm`, and `never_confirm` from the validated policy. +3. No policy file → proceed with default tiers. + +Never write to `policy.yaml` without showing a diff and getting explicit approval. + +--- + +## Audit logging + +Use `audit_query` and `audit_stats` MCP tools to review past activity. For a full audit trail with CLI, use `switchbot --audit-log devices command `. + +--- + +## Output modes + +Always use `--json` when parsing output. Use `--format=markdown` for user-facing summaries. Never parse markdown or human tables programmatically — re-run with `--json`. + +--- + +## Credentials + +First-time login: `switchbot auth login` (opens browser). Headless: add `--no-open`. Inspect the active keychain backend: `switchbot auth keychain describe --json`. Reset cache without touching credentials: `switchbot reset [--all]`. Never run `auth login` or `auth keychain set` on the user's behalf. + +--- + +## Declarative automations (CLI ≥ 3.7.1) + +When the user wants "when X, do Y", author a rule in `policy.yaml` instead of a shell loop. Check schema version first (`head -1 policy.yaml`, must be `"0.2"`; if `"0.1"` run `switchbot policy migrate`). + +Start with `dry_run: true`: + +```yaml +automation: + enabled: true + rules: + - name: "hallway motion at night" + when: { source: mqtt, event: motion.detected, device: "hallway sensor" } + conditions: + - time_between: ["22:00", "07:00"] + then: + - { command: "devices command turnOn", device: "hallway lamp" } + throttle: { max_per: "10m" } + dry_run: true +``` + +Trigger kinds: `source: mqtt` (shadow events), `source: cron` (schedule + optional `days:`), `source: webhook` (bearer-token HTTP). Conditions: `time_between`, `{device, field, op, value}`, `all:`, `any:`, `not:`. + +The validator rejects any rule with a `destructive` action in `then[]`. Always start dry, confirm firings via `switchbot rules tail --follow`, then remove `dry_run`. + +```bash +switchbot policy validate +switchbot rules lint && switchbot rules reload +``` + +--- + +## Semi-autonomous workflow — `plan suggest` + `--require-approval` + +```bash +switchbot plan suggest --intent "turn off all lights" --device --device +# Review/edit the generated JSON +switchbot plan run plan.json --require-approval +``` + +Non-destructive steps run automatically; destructive steps prompt once. Via MCP: call `plan_suggest`, then have the user run `--require-approval` in a TTY session. + +--- + +## Common pitfalls + +1. **Don't parse help text.** Always `--help --json`. +2. **Don't rely on `--name` picking one hit.** Resolve the name yourself; pass `deviceId` directly. +3. **Check `commands[]` before calling a command.** `switchbot devices describe --json` — not every device supports every command. +4. **Quota counts attempts, not successes.** Above 80%, slow down and batch. +5. **`--json` envelope** — every response is `{"schemaVersion":"1.1","data":...}` or `{"error":{...}}`. Read `.data`, check `.error` first. Parsers that read top-level fields silently get `undefined`. + +--- + +## Error handling + +```json +{ "error": { "kind": "usage|auth|quota|network|upstream|internal", "message": "...", "hint": "..." } } +``` + +- `usage` → you called something wrong; re-read help and retry. +- `auth` → run `switchbot doctor --section credentials`. +- `quota` → stop; resets at midnight UTC. +- `network` → retry once, then surface. +- `upstream` → relay verbatim. +- `internal` → ask user to run `switchbot doctor --json` and file an issue. + +Never retry `destructive` actions automatically. For `mutation` retries, use a local fingerprint `{deviceId, command, args, minute-bucket}` as an idempotency gate. + +--- + +## Things to never do + +- Ask the user for their SwitchBot token or secret. +- Suggest flags that bypass safety tiers (`--skip-confirmation`, `--force`) unless the user named them explicitly. +- Claim IR actions "succeeded" — IR is open-loop; say the signal was sent. +- Write to `policy.yaml` without showing a diff and getting explicit approval. +- Generate a rule with a destructive command in `then[]`. +- Arm a rule (`dry_run: false`) on first author without the user confirming firings. +- Set `automation.enabled: true` without explicitly informing the user. +- Run `switchbot doctor --fix --yes` without the user asking. + +--- + +## Version + +Targets `@switchbot/openapi-cli` ≥ 3.7.1. If `switchbot --version` is older: `npm update -g @switchbot/openapi-cli`. + + diff --git a/packages/claude-code-plugin/plugins/switchbot/skills/switchbot/references/claude-code-network.md b/packages/claude-code-plugin/plugins/switchbot/skills/switchbot/references/claude-code-network.md new file mode 100644 index 00000000..7a203195 --- /dev/null +++ b/packages/claude-code-plugin/plugins/switchbot/skills/switchbot/references/claude-code-network.md @@ -0,0 +1,21 @@ +# Claude Code network access for SwitchBot + +Claude Code automatically manages the SwitchBot MCP server process and its +network access via the `.mcp.json` file bundled with this plugin. No manual +configuration file changes are required. + +## If you see network errors in the MCP server output + +The MCP server requires outbound HTTPS to `api.switch-bot.com`. Check: + +1. **CLI installed:** `switchbot --version` — should print `3.7.1` or later +2. **Credentials configured:** `switchbot doctor` — should exit 0 +3. **Network connectivity:** outbound HTTPS to `api.switch-bot.com` must be allowed + +If credentials are missing, re-run the setup: + +```bash +switchbot auth login +``` + +Then reload Claude Code to restart the MCP server. diff --git a/packages/claude-code-plugin/setup/check-cli.js b/packages/claude-code-plugin/setup/check-cli.js new file mode 100644 index 00000000..e85b17a5 --- /dev/null +++ b/packages/claude-code-plugin/setup/check-cli.js @@ -0,0 +1,59 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const MIN_VERSION = '3.7.1'; + +function versionAtLeast(have, need) { + const a = have.split('.').map(n => parseInt(n, 10) || 0); + const b = need.split('.').map(n => parseInt(n, 10) || 0); + for (let i = 0; i < Math.max(a.length, b.length); i++) { + const ai = a[i] ?? 0; + const bi = b[i] ?? 0; + if (ai > bi) return true; + if (ai < bi) return false; + } + return true; +} + +export function makeCheckCli(exec) { + return async function checkCli() { + let version; + try { + const { stdout } = await exec('switchbot', ['--version'], { timeout: 8000 }); + const m = stdout.trim().match(/\d+\.\d+\.\d+/); + version = m ? m[0] : null; + } catch (err) { + if (err?.code === 'ENOENT') { + return { + ok: false, + message: 'switchbot CLI not found. Install with: npm install -g @switchbot/openapi-cli@latest', + }; + } + return { + ok: false, + message: `Failed to run switchbot --version: ${err instanceof Error ? err.message : String(err)}`, + }; + } + + if (!version) { + return { + ok: false, + message: `Could not parse CLI version string. Upgrade with: npm install -g @switchbot/openapi-cli@latest`, + }; + } + + if (!versionAtLeast(version, MIN_VERSION)) { + return { + ok: false, + message: `CLI version ${version} is below the required minimum ${MIN_VERSION}. Upgrade with: npm install -g @switchbot/openapi-cli@latest`, + }; + } + + return { ok: true, version }; + }; +} + +const _execFile = promisify(execFile); +const defaultExec = (cmd, args, opts) => + _execFile(cmd, args, { ...opts, shell: process.platform === 'win32' }); +export const checkCli = makeCheckCli(defaultExec); diff --git a/packages/claude-code-plugin/setup/check-credentials.js b/packages/claude-code-plugin/setup/check-credentials.js new file mode 100644 index 00000000..6b9ca624 --- /dev/null +++ b/packages/claude-code-plugin/setup/check-credentials.js @@ -0,0 +1,117 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { formatError } from '../lib/error-messages.js'; + +const AUTH_FAILURE_PATTERNS = [ + /\b401\b/i, + /\b403\b/i, + /\bunauthorized\b/i, + /\bforbidden\b/i, + /\bauth(?:entication)? failed\b/i, + /\btoken\b.*\bexpired\b/i, + /\bexpired\b.*\btoken\b/i, + /\binvalid\b.*\b(token|secret|credential|credentials)\b/i, + /\b(credentials?|login)\b.*\b(required|invalid|expired|missing|failed)\b/i, +]; + +const NOT_CONFIGURED_PATTERNS = [ + /\bnot configured\b/i, + /\bnot logged in\b/i, + /\bno credentials?\b/i, + /\blogin required\b/i, + /\bmissing credentials?\b/i, +]; + +const NETWORK_FAILURE_PATTERNS = [ + /\bnetwork\b/i, + /\bfetch failed\b/i, + /\bdns\b/i, + /\btimed? out\b/i, + /\btimeout\b/i, + /\betimedout\b/i, + /\beconnreset\b/i, + /\beconnrefused\b/i, + /\benotfound\b/i, + /\behostunreach\b/i, +]; + +function normalizeErrorText(err) { + return [err?.stdout, err?.stderr, err?.message] + .filter(Boolean) + .map(value => Buffer.isBuffer(value) ? value.toString('utf8') : String(value)) + .join('\n'); +} + +function matchesAny(text, patterns) { + return patterns.some(pattern => pattern.test(text)); +} + +function classifyDoctorFailure(text, hasKeychain) { + if (matchesAny(text, NOT_CONFIGURED_PATTERNS)) { + return hasKeychain ? 'credentials-invalid' : 'auth-not-configured'; + } + if (matchesAny(text, AUTH_FAILURE_PATTERNS)) { + return hasKeychain ? 'credentials-invalid' : 'auth-not-configured'; + } + if (matchesAny(text, NETWORK_FAILURE_PATTERNS)) { + return 'doctor-check-failed'; + } + return hasKeychain ? 'doctor-check-failed' : 'auth-not-configured'; +} + +async function tryDoctor(exec) { + try { + const { stdout } = await exec('switchbot', ['doctor', '--json'], { timeout: 10000 }); + const parsed = JSON.parse(stdout); + const data = parsed?.data ?? parsed; + return data?.credentials?.configured === true + ? { ok: true } + : { ok: false, reason: 'not-configured' }; + } catch (err) { + if (err?.code === 'ENOENT') throw err; + return { ok: false, reason: 'doctor-failed', detail: normalizeErrorText(err) }; + } +} + +async function tryKeychainGet(exec) { + try { + const { stdout } = await exec('switchbot', ['auth', 'keychain', 'get', '--json'], { timeout: 8000 }); + const parsed = JSON.parse(stdout); + const data = parsed?.data ?? parsed; + return data?.present === true; + } catch { + return false; + } +} + +export function makeCheckCredentials(exec) { + return async function checkCredentials() { + let doctorResult = null; + try { + doctorResult = await tryDoctor(exec); + if (doctorResult.ok) return { ok: true, source: 'doctor' }; + } catch { + // CLI missing — fall through to keychain + } + + const hasKeychainCredentials = await tryKeychainGet(exec); + + if (doctorResult?.reason === 'doctor-failed') { + const errorKey = classifyDoctorFailure(doctorResult.detail ?? '', hasKeychainCredentials); + return { ok: false, errorKey, message: formatError(errorKey) }; + } + + if (hasKeychainCredentials) return { ok: true, source: 'keychain' }; + + return { + ok: false, + errorKey: 'auth-not-configured', + message: formatError('auth-not-configured'), + }; + }; +} + +const _execFile = promisify(execFile); +const defaultExec = (cmd, args, opts) => + _execFile(cmd, args, { ...opts, shell: process.platform === 'win32' }); +export const checkCredentials = makeCheckCredentials(defaultExec); diff --git a/packages/claude-code-plugin/tests/auth.test.js b/packages/claude-code-plugin/tests/auth.test.js new file mode 100644 index 00000000..25b180f7 --- /dev/null +++ b/packages/claude-code-plugin/tests/auth.test.js @@ -0,0 +1,121 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { makeRunOnInstall } from '../bin/auth.js'; + +function makeOkCliCheck(version = '3.7.1') { + return async () => ({ ok: true, version }); +} +function makeFailCliCheck(msg = 'switchbot CLI not found. Install with: npm install -g @switchbot/openapi-cli@latest') { + return async () => ({ ok: false, message: msg }); +} +function makeOkCredCheck(source = 'keychain') { + return async () => ({ ok: true, source }); +} +function makeFailCredCheck(errorKey = 'auth-not-configured') { + return async () => ({ ok: false, errorKey, message: `Error: no creds (${errorKey})` }); +} +function makeSpawn(exitCode = 0) { + const calls = []; + const spawn = (cmd, args) => { + calls.push({ cmd, args }); + return Promise.resolve(exitCode); + }; + return { spawn, calls }; +} + +async function captureStderr(fn) { + const originalWrite = process.stderr.write; + let output = ''; + process.stderr.write = ((chunk, encoding, callback) => { + output += String(chunk); + if (typeof encoding === 'function') encoding(); + if (typeof callback === 'function') callback(); + return true; + }); + try { + return { code: await fn(), output }; + } finally { + process.stderr.write = originalWrite; + } +} + +describe('runOnInstall', () => { + it('exits 1 when CLI is missing', async () => { + const { spawn, calls } = makeSpawn(0); + const run = makeRunOnInstall({ + checkCli: makeFailCliCheck(), + checkCredentials: makeOkCredCheck(), + runInherit: spawn, + }); + const result = await captureStderr(() => run()); + assert.equal(result.code, 1); + assert.equal(calls.length, 0); + assert.match(result.output, /npm install -g @switchbot\/openapi-cli/); + }); + + it('exits 0 when credentials are already present', async () => { + const { spawn, calls } = makeSpawn(0); + const run = makeRunOnInstall({ + checkCli: makeOkCliCheck(), + checkCredentials: makeOkCredCheck('doctor'), + runInherit: spawn, + }); + const result = await captureStderr(() => run()); + assert.equal(result.code, 0); + assert.equal(calls.length, 0); + assert.match(result.output, /Setup complete/); + }); + + it('runs auth login and exits 0 when credentials missing but login succeeds', async () => { + const { spawn, calls } = makeSpawn(0); + let credCallCount = 0; + const checkCredentials = async () => { + credCallCount++; + if (credCallCount === 1) return { ok: false, errorKey: 'auth-not-configured', message: 'not configured' }; + return { ok: true, source: 'keychain' }; + }; + const run = makeRunOnInstall({ + checkCli: makeOkCliCheck(), + checkCredentials, + runInherit: spawn, + }); + const result = await captureStderr(() => run()); + assert.equal(result.code, 0); + assert.equal(calls.length, 1); + assert.deepEqual(calls[0], { cmd: 'switchbot', args: ['auth', 'login'] }); + assert.equal(credCallCount, 2); + assert.match(result.output, /Setup complete/); + }); + + it('exits with login exit code when auth login fails', async () => { + const { spawn, calls } = makeSpawn(1); + const run = makeRunOnInstall({ + checkCli: makeOkCliCheck(), + checkCredentials: makeFailCredCheck(), + runInherit: spawn, + }); + const result = await captureStderr(() => run()); + assert.equal(result.code, 1); + assert.equal(calls.length, 1); + assert.deepEqual(calls[0], { cmd: 'switchbot', args: ['auth', 'login'] }); + assert.match(result.output, /Login failed|auth-login-failed/i); + }); + + it('exits 1 when post-login credential check fails', async () => { + const { spawn } = makeSpawn(0); + let credCallCount = 0; + const checkCredentials = async () => { + credCallCount++; + return { ok: false, errorKey: 'auth-not-configured', message: 'Error: still not configured' }; + }; + const run = makeRunOnInstall({ + checkCli: makeOkCliCheck(), + checkCredentials, + runInherit: spawn, + }); + const result = await captureStderr(() => run()); + assert.equal(result.code, 1); + assert.equal(credCallCount, 2); + assert.match(result.output, /still not configured/); + }); +}); diff --git a/packages/claude-code-plugin/tests/hooks.test.js b/packages/claude-code-plugin/tests/hooks.test.js new file mode 100644 index 00000000..737ebeed --- /dev/null +++ b/packages/claude-code-plugin/tests/hooks.test.js @@ -0,0 +1,58 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, existsSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const pkgRoot = resolve(__dirname, '..'); + +const HOOKS_FILES = [ + { + label: '.claude-plugin/hooks.json (root)', + path: resolve(pkgRoot, '.claude-plugin', 'hooks.json'), + }, + { + label: 'plugins/switchbot/.claude-plugin/hooks.json', + path: resolve(pkgRoot, 'plugins', 'switchbot', '.claude-plugin', 'hooks.json'), + }, +]; + +describe('hooks.json files', () => { + for (const { label, path: hooksPath } of HOOKS_FILES) { + describe(label, () => { + it('exists on disk', () => { + assert.ok(existsSync(hooksPath), `Missing: ${hooksPath}`); + }); + + if (existsSync(hooksPath)) { + it('is valid JSON', () => { + const raw = readFileSync(hooksPath, 'utf8'); + assert.doesNotThrow(() => JSON.parse(raw), `Invalid JSON in ${hooksPath}`); + }); + + it('has onInstall.command === "node"', () => { + const hooks = JSON.parse(readFileSync(hooksPath, 'utf8')); + assert.equal(hooks?.onInstall?.command, 'node', + `Expected onInstall.command to be "node" in ${hooksPath}`); + }); + + it('onInstall.args[0] resolves to an existing file', () => { + const hooks = JSON.parse(readFileSync(hooksPath, 'utf8')); + const relPath = hooks?.onInstall?.args?.[0]; + assert.ok(typeof relPath === 'string', `onInstall.args[0] must be a string in ${hooksPath}`); + const resolved = resolve(dirname(hooksPath), relPath); + assert.ok(existsSync(resolved), + `onInstall.args[0] "${relPath}" resolves to "${resolved}" which does not exist`); + }); + + it('onInstall.args has exactly one element', () => { + const hooks = JSON.parse(readFileSync(hooksPath, 'utf8')); + const args = hooks?.onInstall?.args; + assert.ok(Array.isArray(args), `onInstall.args must be an array in ${hooksPath}`); + assert.equal(args.length, 1, `onInstall.args should have exactly one element in ${hooksPath}`); + }); + } + }); + } +}); diff --git a/packages/claude-code-plugin/tests/setup.test.js b/packages/claude-code-plugin/tests/setup.test.js new file mode 100644 index 00000000..2831e488 --- /dev/null +++ b/packages/claude-code-plugin/tests/setup.test.js @@ -0,0 +1,145 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { makeCheckCli } from '../setup/check-cli.js'; + +describe('checkCli', () => { + it('returns ok:true when CLI is >= 3.7.1', async () => { + const fakeExec = async () => ({ stdout: '3.7.1\n' }); + const checkCli = makeCheckCli(fakeExec); + const result = await checkCli(); + assert.deepEqual(result, { ok: true, version: '3.7.1' }); + }); + + it('returns ok:false when CLI is below minimum', async () => { + const fakeExec = async () => ({ stdout: '3.2.9\n' }); + const checkCli = makeCheckCli(fakeExec); + const result = await checkCli(); + assert.equal(result.ok, false); + assert.match(result.message, /3\.2\.9/); + assert.match(result.message, /3\.7\.1/); + }); + + it('returns ok:false when CLI is missing (ENOENT)', async () => { + const err = Object.assign(new Error('not found'), { code: 'ENOENT' }); + const fakeExec = async () => { throw err; }; + const checkCli = makeCheckCli(fakeExec); + const result = await checkCli(); + assert.equal(result.ok, false); + assert.match(result.message, /not found/i); + }); + + it('returns ok:false when version string is unparseable', async () => { + const fakeExec = async () => ({ stdout: 'development\n' }); + const checkCli = makeCheckCli(fakeExec); + const result = await checkCli(); + assert.equal(result.ok, false); + assert.match(result.message, /Upgrade/); + }); + + it('returns ok:true when CLI is above minimum (e.g. 5.0.0)', async () => { + const fakeExec = async () => ({ stdout: '5.0.0\n' }); + const checkCli = makeCheckCli(fakeExec); + const result = await checkCli(); + assert.deepEqual(result, { ok: true, version: '5.0.0' }); + }); + + it('returns ok:false on non-ENOENT exec error', async () => { + const fakeExec = async () => { throw new Error('permission denied'); }; + const checkCli = makeCheckCli(fakeExec); + const result = await checkCli(); + assert.equal(result.ok, false); + assert.match(result.message, /permission denied/); + }); +}); + +import { makeCheckCredentials } from '../setup/check-credentials.js'; + +describe('checkCredentials', () => { + it('returns ok:true source:doctor when doctor reports credentials.configured:true', async () => { + const fakeExec = async (cmd, args) => { + if (args.includes('doctor')) { + return { stdout: JSON.stringify({ data: { credentials: { configured: true } } }) }; + } + throw new Error('unexpected call'); + }; + const check = makeCheckCredentials(fakeExec); + const result = await check(); + assert.deepEqual(result, { ok: true, source: 'doctor' }); + }); + + it('returns credentials-invalid when doctor reports an auth failure and keychain credentials exist', async () => { + const fakeExec = async (cmd, args) => { + if (args.includes('doctor')) { + const err = new Error('401 Unauthorized'); + err.stderr = 'HTTP 401 unauthorized'; + throw err; + } + if (args.includes('get')) return { stdout: JSON.stringify({ data: { present: true } }) }; + throw new Error('unexpected'); + }; + const check = makeCheckCredentials(fakeExec); + const result = await check(); + assert.equal(result.ok, false); + assert.equal(result.errorKey, 'credentials-invalid'); + assert.match(result.message, /rejected/i); + assert.match(result.message, /switchbot auth logout/); + }); + + it('returns doctor-check-failed when doctor errors look like network failures', async () => { + const fakeExec = async (cmd, args) => { + if (args.includes('doctor')) { + const err = new Error('ETIMEDOUT'); + err.stderr = 'connect ETIMEDOUT api.switch-bot.com'; + throw err; + } + if (args.includes('get')) return { stdout: JSON.stringify({ data: { present: true } }) }; + throw new Error('unexpected'); + }; + const check = makeCheckCredentials(fakeExec); + const result = await check(); + assert.equal(result.ok, false); + assert.equal(result.errorKey, 'doctor-check-failed'); + assert.match(result.message, /health check/i); + assert.match(result.message, /switchbot doctor/); + }); + + it('returns ok:false when both doctor and keychain describe fail', async () => { + const fakeExec = async () => { throw new Error('all fail'); }; + const check = makeCheckCredentials(fakeExec); + const result = await check(); + assert.equal(result.ok, false); + assert.equal(result.errorKey, 'auth-not-configured'); + assert.match(result.message, /switchbot auth login/); + }); + + it('never passes token or secret values to exec', async () => { + const passedArgs = []; + const fakeExec = async (cmd, args) => { + passedArgs.push(...args); + if (args.includes('doctor')) { + return { stdout: JSON.stringify({ data: { credentials: { configured: true } } }) }; + } + throw new Error('unexpected'); + }; + const check = makeCheckCredentials(fakeExec); + await check(); + const sensitive = passedArgs.filter( + (a) => typeof a === 'string' && (a.includes('token') || a.includes('secret')) + ); + assert.deepEqual(sensitive, [], `Sensitive args leaked: ${sensitive.join(', ')}`); + }); + + it('falls back to keychain when doctor returns credentials.configured:false', async () => { + const fakeExec = async (cmd, args) => { + if (args.includes('doctor')) { + return { stdout: JSON.stringify({ data: { credentials: { configured: false } } }) }; + } + if (args.includes('get')) return { stdout: JSON.stringify({ data: { present: true } }) }; + throw new Error('unexpected'); + }; + const check = makeCheckCredentials(fakeExec); + const result = await check(); + assert.deepEqual(result, { ok: true, source: 'keychain' }); + }); +}); diff --git a/packages/codex-plugin/.agents/plugins/marketplace.json b/packages/codex-plugin/.agents/plugins/marketplace.json new file mode 100644 index 00000000..72c38b30 --- /dev/null +++ b/packages/codex-plugin/.agents/plugins/marketplace.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", + "name": "switchbot", + "plugins": [ + { + "name": "switchbot", + "source": "./plugins/switchbot" + } + ] +} diff --git a/packages/codex-plugin/.claude-plugin/marketplace.json b/packages/codex-plugin/.claude-plugin/marketplace.json deleted file mode 100644 index 48844e43..00000000 --- a/packages/codex-plugin/.claude-plugin/marketplace.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", - "name": "switchbot", - "plugins": [ - { - "name": "switchbot", - "source": "./plugins/switchbot", - "policy": { - "installation": "AVAILABLE", - "authentication": "ON_INSTALL" - }, - "category": "Productivity" - } - ] -} diff --git a/packages/codex-plugin/bin/install.js b/packages/codex-plugin/bin/install.js index 85322f3d..63cfddfb 100644 --- a/packages/codex-plugin/bin/install.js +++ b/packages/codex-plugin/bin/install.js @@ -27,9 +27,8 @@ export function resolvePluginIdentifier(packageRoot) { // Sequential independent if blocks allow fallbacks to work even if earlier // files exist but have invalid JSON (e.g., interrupted write). const manifestPaths = [ - join(packageRoot, '.claude-plugin', 'marketplace.json'), - join(packageRoot, 'marketplace.json'), join(packageRoot, '.agents', 'plugins', 'marketplace.json'), + join(packageRoot, 'marketplace.json'), ]; for (const p of manifestPaths) { if (existsSync(p)) { diff --git a/packages/codex-plugin/marketplace.json b/packages/codex-plugin/marketplace.json index a3aa32aa..841c2dbb 100644 --- a/packages/codex-plugin/marketplace.json +++ b/packages/codex-plugin/marketplace.json @@ -1,20 +1,9 @@ { "name": "switchbot", - "interface": { - "displayName": "SwitchBot" - }, "plugins": [ { "name": "switchbot", - "source": { - "source": "local", - "path": "./plugins/switchbot" - }, - "policy": { - "installation": "AVAILABLE", - "authentication": "ON_INSTALL" - }, - "category": "Productivity" + "source": "./plugins/switchbot" } ] } diff --git a/packages/codex-plugin/package.json b/packages/codex-plugin/package.json index a9366aa3..1606d674 100644 --- a/packages/codex-plugin/package.json +++ b/packages/codex-plugin/package.json @@ -31,8 +31,9 @@ "setup/", "skills/", ".codex-plugin/", - ".claude-plugin/", + ".agents/plugins/", "plugins/", + "marketplace.json", ".mcp.json", "README.md" ], @@ -48,6 +49,7 @@ "access": "public" }, "scripts": { + "prepare": "node scripts/sync-skill.mjs", "test": "node --test", "typecheck": "node --check bin/auth.js && node --check bin/install.js" } diff --git a/packages/codex-plugin/plugins/switchbot/skills/switchbot/SKILL.md b/packages/codex-plugin/plugins/switchbot/skills/switchbot/SKILL.md index 3986b84f..ab084d34 100644 --- a/packages/codex-plugin/plugins/switchbot/skills/switchbot/SKILL.md +++ b/packages/codex-plugin/plugins/switchbot/skills/switchbot/SKILL.md @@ -5,19 +5,12 @@ description: Use when the user mentions SwitchBot devices, smart-home automation # SwitchBot skill -You are helping the user control their SwitchBot smart home through the -`switchbot` CLI. This skill tells you **how** to do that safely. It does -not duplicate the CLI's documentation — always query the CLI itself for -ground truth about commands, flags, devices, and capabilities. +Drive the user's SwitchBot smart home through the `switchbot` CLI. Always query the CLI for ground truth — never guess commands, deviceIds, or parameter values. --- ## Authority chain -The `switchbot` CLI is the single source of truth. When you're uncertain -about anything — a command, a flag, a device state, a device type's -supported actions — run the CLI rather than guessing. - | Question | Authoritative command | |---|---| | What can I do (cold start)? | `switchbot agent-bootstrap --compact --json` | @@ -27,238 +20,106 @@ supported actions — run the CLI rather than guessing. | What's this device doing right now? | `switchbot devices status --json` | | What can I do with this specific device type? | `switchbot devices describe --json` | | What scenes are configured? | `switchbot scenes list --json` | -| What's in the user's `policy.yaml`? | `cat ~/.config/openclaw/switchbot/policy.yaml` (or the Windows equivalent) | -| Is my quota OK? | `switchbot --json quota status` | +| What's in the user's `policy.yaml`? | `cat ~/.config/openclaw/switchbot/policy.yaml` | +| Is my quota OK? | `switchbot quota status --json` | | Is the setup healthy? | `switchbot doctor --json` | -| What automation rules does the user have? | `switchbot rules list --json` | +| What automation rules are configured? | `switchbot rules list --json` | | Are the rules valid? | `switchbot rules lint` | -| Is the rules engine running? | `switchbot rules tail --follow` (or `rules list --json` for static state) | -| What past events match a rule? | `switchbot rules replay --since --dry-run` | -| Where do credentials live? | `switchbot auth keychain describe --json` | -| Move credentials into the OS keychain | `switchbot auth keychain migrate` (the user runs this; you don't) | -| Sign in for the first time (browser) | `switchbot auth login` (the user runs this; you don't) | -| Clear local cache / quota / history | `switchbot reset [--all]` (safe — does not delete credentials) | -| Draft an execution plan from intent | `switchbot plan suggest --intent "..." --device [--device …]` | +| Draft an execution plan from intent | `switchbot plan suggest --intent "..." --device ` | | Run a plan with per-step approval | `switchbot plan run --require-approval` | -| Draft an automation rule from intent | `switchbot rules suggest --intent "..." [--trigger mqtt|cron|webhook] [--device …]` | -| Inject a rule into policy.yaml | `switchbot policy add-rule [--dry-run] [--enable]` (reads rule YAML from stdin) | -| Why did a rule fire or get blocked? | `switchbot rules trace-explain --rule --last` (or `--fire-id `) | -| Pre-validate rule effect against history | `switchbot rules simulate --since 7d` | - -Never invent a deviceId, a command name, or a parameter value. If the -CLI doesn't know about it, refuse and explain — don't paper over it. +| Draft an automation rule from intent | `switchbot rules suggest --intent "..." --device ` | +| Inject a rule into policy.yaml | `switchbot policy add-rule [--dry-run] [--enable]` (reads YAML from stdin) | --- ## Network requirements -`codex setup` requires outbound internet access (npm registry + GitHub). Codex workspaces are offline by default. If setup reports a network error or the `check-network` step warns: - -→ Read `references/codex-network.md` for the exact `~/.codex/config.toml` fix. +`switchbot codex setup` requires outbound internet (npm registry + GitHub). If it fails with a network error, read `references/codex-network.md` for the `~/.codex/config.toml` fix. --- -## Required bootstrap (run this first, every session) +## Required bootstrap -Before you take any action, establish context: +Before any action, run: ```bash switchbot agent-bootstrap --compact ``` -(The output is always JSON; `--json` is redundant here.) - -The response is `{ "schemaVersion": "1.1", "data": { ... } }`, and -`data` carries everything you need to orient yourself without burning -quota: - -- `cliVersion` — confirm it matches the skill's `authority.cli` range -- `identity` — product, vendor, API version, documentation URL -- `quickReference` — which commands to reach for in common tasks -- `safetyTiers` — the 5-tier enum (see Safety gates below) -- `nameStrategies` — how to resolve a user's spoken name ("bedroom light") - to a deviceId (ordered list: `["exact", "prefix", "substring", "fuzzy", "first", "require-unique"]`) -- `profile` — which CLI profile is active -- `quota` — today's usage + remaining budget -- `devices[]` — cached devices with `deviceId`, `type`, `name`, `category`, `roomName` -- `catalog` — summary of device types present in the account, with - safety tiers and supported commands -- `hints[]` — advisory messages the CLI wants the agent to see (possibly empty array; never null) - -If `devices[]` looks stale (e.g. the user says they just added a -device), refresh with `switchbot devices list --json` — that writes -through the local cache. +The response contains: `cliVersion`, `safetyTiers`, `nameStrategies`, `profile`, `quota`, `devices[]` (cached, with `deviceId`/`type`/`name`/`category`/`roomName`), `catalog`, and `hints[]`. -Then read the user's policy: - -```bash -cat ~/.config/openclaw/switchbot/policy.yaml 2>/dev/null || \ -cat "$HOME/.config/openclaw/switchbot/policy.yaml" 2>/dev/null || \ -cat "$USERPROFILE/.config/openclaw/switchbot/policy.yaml" 2>/dev/null -``` +If devices look stale (user just added one), refresh with `switchbot devices list --json`. -If the file doesn't exist, proceed with defaults from the safety section -below — but tell the user once that they don't have a policy yet and -point them at `switchbot policy new` (requires CLI ≥ 3.7.1). - -If the user asks whether their policy file is correct, run: +Then read the user's policy: ```bash -switchbot policy validate +cat ~/.config/openclaw/switchbot/policy.yaml 2>/dev/null ``` -Exit 0 means the file is valid; any other code means the CLI printed -line-accurate errors — relay those errors to the user rather than -trying to read the YAML yourself. +If the file doesn't exist, proceed with default safety tiers and tell the user once they can create one with `switchbot policy new`. --- ## Resolving a name to a device -When the user says "turn on the bedroom light", resolve the name in this -order (this is what `agent-bootstrap` means by `nameStrategies`): +When the user says "bedroom light", resolve in this order: -1. **alias** — if `policy.yaml` maps `"bedroom light"` → ``, use that. **This is the most reliable path.** -2. **exact** — if a device has `name == "bedroom light"` (case-insensitive), use that. -3. **prefix** — one device whose name starts with the phrase. -4. **substring** — one device whose name contains the phrase. +1. **alias** — `policy.yaml` alias map → ``. Most reliable. +2. **exact** — device `name == "bedroom light"` (case-insensitive). +3. **prefix** — name starts with the phrase. +4. **substring** — name contains the phrase. 5. **fuzzy** — Levenshtein distance ≤ 2. -6. **require-unique** — if more than one device matches at the same tier, **stop and ask** which one the user meant. Do not pick. - -If the user's phrase resolves to multiple devices at the same tier, list -them (name + room + type) and ask. Do not pick the first one and -proceed — this is a known CLI footgun (the `--name` flag used to match -the first result silently; don't rely on that behaviour). +6. **require-unique** — multiple matches at same tier → **stop and ask**. Never pick silently. --- ## Safety gates -Every action carries a `safetyTier`, surfaced by -`switchbot capabilities --json` and per-device by -`switchbot devices describe --json`. Honour these tiers: - | Tier | Examples | Behaviour | |---|---|---| -| `read` | `devices status`, `devices list`, `quota`, `scenes list` | Run freely. | -| `ir-fire-forget` | IR `power`, IR `setAll`, AC/TV/fan via Hub | Run, but tell the user there is no device-side confirmation — you have to trust the IR signal was received. | -| `mutation` | `turnOn`, `turnOff`, `setBrightness`, `setColor` | Run. Append to the audit log (see below). | -| `destructive` | `lock`, `unlock`, deleting scenes/webhooks, anything the user can't trivially undo | **Refuse by default.** Ask the user to confirm explicitly. Even then, run with `--dry-run` first if the CLI supports it for that action. | -| `maintenance` | (reserved — no action uses it today) | Always confirm. | - -The user's `policy.yaml` can override this: - -- `confirmations.always_confirm: ["lock", "unlock", ...]` — forces - confirmation even for tiers that would normally auto-run. -- `confirmations.never_confirm: ["turnOn", "turnOff"]` — loosens - confirmation for non-destructive actions. **Never add a `destructive` - action to `never_confirm`**, even if the user asks in passing — push - back and ask them to say so explicitly in the policy file. -- `quiet_hours: { start, end }` — during quiet hours, even `mutation` - actions need confirmation. +| `read` | status, list, quota | Run freely. | +| `ir-fire-forget` | IR power/AC/TV via Hub | Run; warn there is no device-side confirmation. | +| `mutation` | turnOn/Off, setBrightness, setColor | Run. Append to audit log. | +| `destructive` | lock, unlock, delete scenes/webhooks | **Refuse by default.** Confirm explicitly; prefer `--dry-run` first. | +| `maintenance` | (reserved) | Always confirm. | + +Policy overrides: `confirmations.always_confirm` forces confirmation; `confirmations.never_confirm` pre-approves (never add `destructive` actions). `quiet_hours` requires confirmation even for `mutation`. --- ## Policy compliance -Before executing any mutation or destructive action, check whether the user -has a policy file: +1. Call `policy_validate` (with `live: true`) once per device-control session. +2. Honour `quiet_hours`, `always_confirm`, and `never_confirm` from the validated policy. +3. No policy file → proceed with default tiers. -1. Call `policy_validate` (with `live: true`) at the start of each session - that will involve device control — not on every single command. -2. If `policy_validate` returns a valid policy, honour these fields: - - `quiet_hours` — during the window, ask the user for explicit confirmation - before any mutation, even if the tier would normally auto-run. - - `confirmations.always_confirm` — treat listed commands as destructive - (require explicit user confirmation). - - `confirmations.never_confirm` — treat listed commands as pre-approved - by the user; skip the confirmation prompt. -3. If no policy file exists (`ENOENT` or `present: false`), proceed with the - default safety tiers — no additional prompt needed. - -Never write to policy.yaml without showing the user a diff and getting -explicit approval first. +Never write to `policy.yaml` without showing a diff and getting explicit approval. --- ## Audit logging -When operating through the MCP tools (`send_command`, `run_scene`), the CLI -does not automatically write an audit log entry. To review past activity use -the built-in audit tools: - -- `audit_query` — filter audit log entries by time range, device, or result. -- `audit_stats` — summarise counts by command, device, and result. - -If the user asks for a full audit trail, advise them to run mutation commands -directly via the CLI with `--audit-log`: - -```bash -switchbot --audit-log devices command turnOn -``` +Use `audit_query` and `audit_stats` MCP tools to review past activity. For a full audit trail with CLI, use `switchbot --audit-log devices command `. --- ## Output modes -The CLI supports `--format=json|yaml|tsv|id|markdown`. `--json` is an -alias for `--format=json`. Always use JSON when you're going to parse -the output; use `markdown` when you're summarising for the user as chat -output. - -Never parse `markdown` or human tables programmatically — they're not -stable. If you find yourself regex-extracting from a table, stop and -re-run with `--json`. +Always use `--json` when parsing output. Use `--format=markdown` for user-facing summaries. Never parse markdown or human tables programmatically — re-run with `--json`. --- -## Streaming events - -If the user wants real-time reactions (motion, door contact, button -press), start the MQTT stream: - -```bash -switchbot events mqtt-tail --json -``` +## Credentials -Every line is one event in the unified envelope: - -```json -{"schemaVersion":"1.1","t":"2026-04-22T...","source":"mqtt", - "deviceId":"...","topic":"...","type":"device.shadow","payload":{...}} -``` - -The first line is a stream header with `{"stream":true, "eventKind":..., "cadence":...}` — consume it, then iterate. - -If the user is running this inside an OpenClaw-aware setup, the CLI has -an `--sink openclaw` mode that POSTs events to a local gateway directly; -check `switchbot events mqtt-tail --help` for current flags rather than -assuming. +First-time login: `switchbot auth login` (opens browser). Headless: add `--no-open`. Inspect the active keychain backend: `switchbot auth keychain describe --json`. Reset cache without touching credentials: `switchbot reset [--all]`. Never run `auth login` or `auth keychain set` on the user's behalf. --- -## Declarative automations (CLI ≥ 3.7.1, policy v0.2) - -When the user wants "when X happens, do Y" rather than one-shot commands, -author a rule in the `automation:` block of `policy.yaml` instead of -spawning a shell loop. This repo requires `@switchbot/openapi-cli` 3.7.1+ -and runs the rules engine in the same process that reads the policy. - -Before you touch `policy.yaml`, check the schema version: +## Declarative automations (CLI ≥ 3.7.1) -```bash -cat ~/.config/openclaw/switchbot/policy.yaml | head -1 # version: "0.2" ? -switchbot policy validate # exit 0 means good -``` - -If the user is on `version: "0.1"`, they need `switchbot policy migrate` -first — do **not** hand-edit the version line. +When the user wants "when X, do Y", author a rule in `policy.yaml` instead of a shell loop. Check schema version first (`head -1 policy.yaml`, must be `"0.2"`; if `"0.1"` run `switchbot policy migrate`). -### Authoring a rule - -Keep the first rule tiny and start with `dry_run: true`. The engine -will log firings to the audit log without touching the device, so the -user can verify before arming: +Start with `dry_run: true`: ```yaml automation: @@ -274,345 +135,71 @@ automation: dry_run: true ``` -Show the user the diff before writing. After they approve, validate + -reload: +Trigger kinds: `source: mqtt` (shadow events), `source: cron` (schedule + optional `days:`), `source: webhook` (bearer-token HTTP). Conditions: `time_between`, `{device, field, op, value}`, `all:`, `any:`, `not:`. + +The validator rejects any rule with a `destructive` action in `then[]`. Always start dry, confirm firings via `switchbot rules tail --follow`, then remove `dry_run`. ```bash switchbot policy validate -switchbot rules lint # catches cron typos, unknown aliases -switchbot rules reload # SIGHUP on Unix / pid-file on Windows -switchbot rules tail --follow # watch fires arrive (dry-run fires too) +switchbot rules lint && switchbot rules reload ``` -### Trigger kinds - -- `source: mqtt` — reacts to shadow events. `event` is - `motion.detected`, `contact.open`, etc. (check the device's - `describe --json` for the exact event names it emits.) -- `source: cron` — `schedule: "0 8 * * *"` style expressions in local - time. Optional `days: [mon, wed, fri]` list (weekday names `mon`–`sun` - or full names, case-insensitive) applied *after* the cron fires — - firings on unlisted days are suppressed without writing throttle or - audit entries. -- `source: webhook` — bearer-token HTTP ingest on a configurable port. - The token lives in the OS keychain (`switchbot auth keychain set`), - **never** in `policy.yaml`. - -### Conditions - -Top-level `conditions[]` entries are AND-joined. Each entry is one of: - -- `time_between: ["22:00", "07:00"]` — local time; midnight-crossing - is supported. -- `{ device, field, op, value }` — per-tick cached device status - lookup; e.g. `{ device: "front lock", field: "online", op: "==", value: true }`. - Operators: `==`, `!=`, `<`, `>`, `<=`, `>=`. -- `all: [condition, ...]` — all sub-conditions must pass (logical AND - over a sub-list). -- `any: [condition, ...]` — at least one sub-condition must pass (OR). -- `not: condition` — inverts a single condition. - -Composites nest arbitrarily via `$ref`. Example: `[A, { any: [B, C] }]` -evaluates as `A AND (B OR C)`. - -### Rules the engine will refuse to accept - -The validator rejects any rule whose `then.command` would fire a -destructive action (`unlock`, `garage-door open`, `keypad createKey`, -etc.). The rejection is a schema error at `policy validate` time — not -a runtime surprise. If the user asks for "auto-unlock when I arrive -home", push back and explain: destructive actions must be driven by a -human, not a rule. - -### When to recommend a rule vs. a shell loop - -Recommend a rule when: -- The logic is declarative (one trigger + one-or-two conditions + one - command). -- The user wants it to survive a reboot (pair with the systemd unit in - the CLI repo's `examples/quickstart/mqtt-tail.service.example` and - a similar `switchbot rules run --audit-log` unit). - -Recommend a shell loop when: -- The logic needs multi-step branching you'd build with `jq` + `if`. -- The user wants a one-off transient thing that doesn't live in policy. - --- -## Credentials in the keychain (CLI ≥ 3.7.1) - -**First-time login (recommended path):** -`switchbot auth login` opens a browser window to the SwitchBot login page. After the user signs in, the CLI stores `token` and `secret` directly in the OS keychain and verifies them automatically. The skill never needs to be involved — the user runs this once. - -If the browser cannot open (CI, headless, or SSH), pass `--no-open`: -```bash -switchbot auth login --no-open -``` -The CLI prints a URL; the user opens it in any browser on any machine. - -**Moving existing credentials into the keychain:** -If the user already has credentials in `~/.switchbot/config.json`, point them at `switchbot auth keychain migrate` — it moves token + secret to the OS keychain (macOS `security(1)`, Windows `CredRead`/`CredWrite`, Linux `secret-tool`) and deletes the plain-text file on success. +## Semi-autonomous workflow — `plan suggest` + `--require-approval` -**Inspecting the active backend:** ```bash -switchbot auth keychain describe --json -``` -Relay the `backend` and `writable` fields verbatim — downstream troubleshooting steps depend on knowing which backend is active. - -**Resetting local state without touching credentials:** -```bash -switchbot reset # clears device cache, quota counter, history -switchbot reset --all # also clears audit log and device metadata +switchbot plan suggest --intent "turn off all lights" --device --device +# Review/edit the generated JSON +switchbot plan run plan.json --require-approval ``` -`reset` never touches keychain entries. Suggest it when the user reports stale device state or a corrupted cache, **before** suggesting re-login. - -The skill does **not** run `auth login`, `auth keychain set`, or `migrate` on the user's behalf — the user always runs credential commands. You may run `auth keychain describe --json` to diagnose which backend is active. - ---- -## Common pitfalls (from CLI audit) - -Read these once and avoid them: - -1. **Don't parse help output as text.** Always `--help --json`. The - text version is for humans and changes between releases. -2. **Don't rely on `name` matching first hit.** Resolve the name - yourself (see "Resolving a name to a device"), or pass `deviceId` - directly. -3. **Don't assume a command exists on every device.** Before calling - `setBrightness`, check `switchbot devices describe --json` - and confirm `commands[]` includes `setBrightness`. Not every bulb - supports every command. -4. **Quota counts attempts, not successes.** A burst of failed calls - still eats the daily 10 000 budget. If `switchbot quota --json` - shows you're above 80%, slow down and batch. -5. **`--json` envelope — read `.data`, check `.error` first.** Every - `--json` response is wrapped: `{"schemaVersion":"1.1","data":...}` on - success, `{"schemaVersion":"1.1","error":{...}}` on failure. This was - a breaking envelope change — parsers that reach for top-level fields - (e.g. `obj.devices` instead of `obj.data.devices`) silently get - `undefined`. -6. **Some fields are deprecated.** Prefer `safetyTier` over - `destructive:boolean`; prefer `statusQueries` over `statusFields`. - The old fields still appear in CLI 2.7.x output but are removed in - v3.0. Bootstrap payload already uses the new names. -7. **Cold-start the cache when the user adds a device.** The cache - doesn't auto-refresh; when a user says "I just added a new - sensor", run `switchbot devices list --json` first. - -8. **Force `--no-cache` on batch/long-lived reads** *(temporary — remove - when upstream cache bug is fixed)*. Loops, fan-outs, and reads after - long idle hit a cache bug returning stale state. Don't substitute by - lowering `cli.cache_ttl` — that's durable config; `--no-cache` is a - per-call flag. See `troubleshooting.md` § *Batch or long-lived calls return stale device state*. -9. **Validate deviceId shape yourself before writing rules.** The - policy schema patterns only the `aliases` map - (`^[A-Z0-9]{2,}-[A-Z0-9-]+$`); `device:` on triggers, conditions, and - actions is a plain string. `switchbot policy validate` will accept - `01-abc` and fail at runtime. Before authoring a rule, if the value - is not a known alias key from `policy.yaml`, match it against the - same regex yourself and reject on mismatch. +Non-destructive steps run automatically; destructive steps prompt once. Via MCP: call `plan_suggest`, then have the user run `--require-approval` in a TTY session. --- -## Things to never do +## Common pitfalls -- Never ask the user for their SwitchBot token or secret. If - `switchbot config show` fails because credentials are missing, tell - the user to run `switchbot config set` themselves — they input the - credentials into the CLI, not into you. -- Never suggest commands that bypass safety tiers - (`--skip-confirmation`, `--force`, etc.) unless the CLI documents - them and the user asked for them by name. -- Never claim an IR action "succeeded" in the sense of device - confirmation — IR is open-loop. Say the signal was sent; if the user - cares whether the TV actually turned on, they need a sensor loop. -- Never write to `policy.yaml` without showing the user the diff and - getting an explicit yes. -- Never generate a rule with a destructive command in `then[]` (e.g. `unlock`, - `deleteScene`, `factoryReset`). The CLI's lint step will reject it, but - the skill must not attempt it in the first place. -- Never arm a rule (`dry_run: false`) on first author — always start dry, - confirm firings via `switchbot rules tail --follow`, then transition. -- Never set `automation.enabled: true` without explicitly informing the user. -- Never run `switchbot doctor --fix --yes` without the user asking for - it. `--fix` mutates state (clears caches, rewrites config); it needs - intent. +1. **Don't parse help text.** Always `--help --json`. +2. **Don't rely on `--name` picking one hit.** Resolve the name yourself; pass `deviceId` directly. +3. **Check `commands[]` before calling a command.** `switchbot devices describe --json` — not every device supports every command. +4. **Quota counts attempts, not successes.** Above 80%, slow down and batch. +5. **`--json` envelope** — every response is `{"schemaVersion":"1.1","data":...}` or `{"error":{...}}`. Read `.data`, check `.error` first. Parsers that read top-level fields silently get `undefined`. --- -## If the CLI returns an error - -The envelope looks like: +## Error handling ```json -{ - "schemaVersion": "1.1", - "error": { - "kind": "usage" | "auth" | "quota" | "network" | "upstream" | "internal", - "message": "...", - "hint": "..." - } -} -``` - -- `kind: "usage"` — you (the agent) called something wrong. Re-read the - help for that subcommand and retry. -- `kind: "auth"` — token is missing/invalid/expired. Tell the user to - run `switchbot doctor --section credentials`. -- `kind: "quota"` — daily 10 000 calls exceeded. Stop, tell the user - when it resets (midnight UTC). -- `kind: "network"` — transient. Retry once, then surface the error. -- `kind: "upstream"` — SwitchBot cloud is unhappy. Surface the message - verbatim; don't paraphrase. -- `kind: "internal"` — CLI bug. Ask the user to run - `switchbot doctor --json` and file an issue. - -Never retry `destructive` actions automatically — that's how you unlock -a door twice. - - -For `mutation` retries, gate with your own idempotency layer — a local -fingerprint (e.g. `{deviceId, command, args, minute-bucket}`) + short -TTL. Do **not** rely on `--idempotency-key` for dedupe *(temporary — -revisit when CLI idempotency is documented as reliable)*; a retry after -a `network` or `internal` error can double-fire without a local gate. - ---- - -## Semi-autonomous workflow — `plan suggest` + `--require-approval` (CLI ≥ 3.7.1) - -When the user wants to review each dangerous step rather than confirm -each command interactively, use the Plan workflow: - -```bash -# 1. Draft a plan from intent -switchbot plan suggest \ - --intent "turn off all lights" \ - --device --device - -# 2. Inspect the generated JSON; edit if needed -# 3. Run with per-step approval -switchbot plan run plan.json --require-approval +{ "error": { "kind": "usage|auth|quota|network|upstream|internal", "message": "...", "hint": "..." } } ``` -`plan suggest` uses keyword heuristics (on/off/press/lock/open/close/pause) -to pick the right command for each device. If the intent is ambiguous, -it defaults to `turnOn` with a warning on stderr — edit the plan before -running. +- `usage` → you called something wrong; re-read help and retry. +- `auth` → run `switchbot doctor --section credentials`. +- `quota` → stop; resets at midnight UTC. +- `network` → retry once, then surface. +- `upstream` → relay verbatim. +- `internal` → ask user to run `switchbot doctor --json` and file an issue. -`plan run --require-approval` prompts once per destructive step: - -``` - Approve step 1 — unlock on ? [y/N] -``` - -Non-destructive steps run without prompting. A rejected step is logged -as `decision: "rejected"` and skipped; the remaining steps continue -(unless `--continue-on-error` is unset, in which case the run halts). - -When used via MCP, call the `plan_suggest` tool (safety tier `read`) to -produce the draft plan JSON, then have the user run it interactively -with `--require-approval` in a TTY session. - -**Constraints:** - -- `--require-approval` is mutually exclusive with `--json`. -- `--yes` overrides `--require-approval` — blanket approval, no prompts. -- In non-TTY environments (CI, pipes), all destructive steps auto-reject. +Never retry `destructive` actions automatically. For `mutation` retries, use a local fingerprint `{deviceId, command, args, minute-bucket}` as an idempotency gate. --- -## L3 · Proactive rule authoring (CLI ≥ 3.7.1) - -### When to proactively suggest a rule - -- User says "every time X happens, do Y" → prefer a rule over a one-shot command. -- User has run the same command manually three or more times → offer to automate it. -- User describes a time-based habit → offer a cron rule. -- **Do NOT** suggest a rule for a one-off action or when the user explicitly asks for a single command. - -### Authoring + approval workflow - -```bash -# Step 1: Generate rule YAML (no side effects) -switchbot rules suggest \ - --intent "turn on hallway light when motion detected at night" \ - --trigger mqtt \ - --device "hallway sensor" --device "hallway lamp" - -# Step 2: Dry-run diff — ALWAYS show this to the user before writing -switchbot rules suggest --intent "..." | switchbot policy add-rule --dry-run - -# Step 3: After user approves, inject and reload -switchbot rules suggest --intent "..." | switchbot policy add-rule [--enable] -switchbot rules lint # must exit 0 before proceeding -switchbot rules reload -``` - -When using MCP (no shell access), substitute `rules_suggest` and `policy_add_rule` tools: - -1. Call `rules_suggest` to get the rule YAML. -2. Call `policy_add_rule` with `dry_run: true` — show the diff to the user. -3. After user approves, call `policy_add_rule` with `dry_run: false`. - -When investigating why a rule fired or was blocked, use `rules_explain`: - -- `rules_explain` with `rule_name` + `last: true` → most recent evaluation -- `rules_explain` with `fire_id` → specific evaluation by ID -- Returns per-condition ✓/✗ trace, decision, and timing - -To pre-validate a new or modified rule against historical events before arming: - -- `rules_simulate` with `rule_yaml` + `since: "7d"` → replay last 7 days -- Returns `wouldFire`, `blockedByCondition`, `throttled`, `topBlockReason` -- Always simulate before removing `dry_run: true` from a rule - -### Dry-run → arm transition - -Rules start as `dry_run: true`; the engine logs firings without touching devices. -After injection, direct the user to run: - -```bash -switchbot rules tail --follow -``` - -Confirm that firings look correct for at least one real event. Only after the -user confirms: edit `dry_run: true` → remove the field (or set to `false`) in -policy.yaml, show diff, wait for approval, reload: - -```bash -switchbot rules lint && switchbot rules reload -``` +## Things to never do -Run `switchbot rules replay --since 24h --json` regularly to surface misfires. +- Ask the user for their SwitchBot token or secret. +- Suggest flags that bypass safety tiers (`--skip-confirmation`, `--force`) unless the user named them explicitly. +- Claim IR actions "succeeded" — IR is open-loop; say the signal was sent. +- Write to `policy.yaml` without showing a diff and getting explicit approval. +- Generate a rule with a destructive command in `then[]`. +- Arm a rule (`dry_run: false`) on first author without the user confirming firings. +- Set `automation.enabled: true` without explicitly informing the user. +- Run `switchbot doctor --fix --yes` without the user asking. --- -## Version pinning - -This skill targets `@switchbot/openapi-cli` **≥ 3.7.1** and has been -validated against `3.7.x`. - -This repo standardizes on CLI 3.7.1+ for all installation, upgrade, and -support paths. Earlier 3.x versions (3.0.0–3.6.x) silently return the -wrong envelope shape, have a known cache bug on batch/long-lived reads, -and accept malformed policy files — the four pitfalls §5–§9 below all -assume 3.7.1 behavior. - -If `switchbot --version` prints an older version, tell the user to run: - -```bash -npm update -g @switchbot/openapi-cli -``` +## Version -The skill already expects the v3 capability schema. If you see examples in the -wild that still rely on old 2.x fields, prefer the current `switchbot -capabilities --json` output over those examples. +Targets `@switchbot/openapi-cli` ≥ 3.7.1. If `switchbot --version` is older: `npm update -g @switchbot/openapi-cli`. -This skill declares `autonomyLevel: "L2"` in its `manifest.json`. -L2 means the skill can draft a plan from intent and run it with -per-step approval (`plan suggest` + `plan run --require-approval`). -Rules authored by the skill default to `dry_run: true` until the user -flips them on. L3 (fully autonomous inside policy envelope) remains -out of scope for this skill version. + diff --git a/packages/codex-plugin/scripts/sync-skill.mjs b/packages/codex-plugin/scripts/sync-skill.mjs new file mode 100644 index 00000000..5b842b81 --- /dev/null +++ b/packages/codex-plugin/scripts/sync-skill.mjs @@ -0,0 +1,9 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const src = path.resolve(__dirname, '../skills/switchbot/SKILL.md'); +const dest = path.resolve(__dirname, '../plugins/switchbot/skills/switchbot/SKILL.md'); +fs.copyFileSync(src, dest); diff --git a/packages/codex-plugin/skills/switchbot/SKILL.md b/packages/codex-plugin/skills/switchbot/SKILL.md index 3986b84f..ab084d34 100644 --- a/packages/codex-plugin/skills/switchbot/SKILL.md +++ b/packages/codex-plugin/skills/switchbot/SKILL.md @@ -5,19 +5,12 @@ description: Use when the user mentions SwitchBot devices, smart-home automation # SwitchBot skill -You are helping the user control their SwitchBot smart home through the -`switchbot` CLI. This skill tells you **how** to do that safely. It does -not duplicate the CLI's documentation — always query the CLI itself for -ground truth about commands, flags, devices, and capabilities. +Drive the user's SwitchBot smart home through the `switchbot` CLI. Always query the CLI for ground truth — never guess commands, deviceIds, or parameter values. --- ## Authority chain -The `switchbot` CLI is the single source of truth. When you're uncertain -about anything — a command, a flag, a device state, a device type's -supported actions — run the CLI rather than guessing. - | Question | Authoritative command | |---|---| | What can I do (cold start)? | `switchbot agent-bootstrap --compact --json` | @@ -27,238 +20,106 @@ supported actions — run the CLI rather than guessing. | What's this device doing right now? | `switchbot devices status --json` | | What can I do with this specific device type? | `switchbot devices describe --json` | | What scenes are configured? | `switchbot scenes list --json` | -| What's in the user's `policy.yaml`? | `cat ~/.config/openclaw/switchbot/policy.yaml` (or the Windows equivalent) | -| Is my quota OK? | `switchbot --json quota status` | +| What's in the user's `policy.yaml`? | `cat ~/.config/openclaw/switchbot/policy.yaml` | +| Is my quota OK? | `switchbot quota status --json` | | Is the setup healthy? | `switchbot doctor --json` | -| What automation rules does the user have? | `switchbot rules list --json` | +| What automation rules are configured? | `switchbot rules list --json` | | Are the rules valid? | `switchbot rules lint` | -| Is the rules engine running? | `switchbot rules tail --follow` (or `rules list --json` for static state) | -| What past events match a rule? | `switchbot rules replay --since --dry-run` | -| Where do credentials live? | `switchbot auth keychain describe --json` | -| Move credentials into the OS keychain | `switchbot auth keychain migrate` (the user runs this; you don't) | -| Sign in for the first time (browser) | `switchbot auth login` (the user runs this; you don't) | -| Clear local cache / quota / history | `switchbot reset [--all]` (safe — does not delete credentials) | -| Draft an execution plan from intent | `switchbot plan suggest --intent "..." --device [--device …]` | +| Draft an execution plan from intent | `switchbot plan suggest --intent "..." --device ` | | Run a plan with per-step approval | `switchbot plan run --require-approval` | -| Draft an automation rule from intent | `switchbot rules suggest --intent "..." [--trigger mqtt|cron|webhook] [--device …]` | -| Inject a rule into policy.yaml | `switchbot policy add-rule [--dry-run] [--enable]` (reads rule YAML from stdin) | -| Why did a rule fire or get blocked? | `switchbot rules trace-explain --rule --last` (or `--fire-id `) | -| Pre-validate rule effect against history | `switchbot rules simulate --since 7d` | - -Never invent a deviceId, a command name, or a parameter value. If the -CLI doesn't know about it, refuse and explain — don't paper over it. +| Draft an automation rule from intent | `switchbot rules suggest --intent "..." --device ` | +| Inject a rule into policy.yaml | `switchbot policy add-rule [--dry-run] [--enable]` (reads YAML from stdin) | --- ## Network requirements -`codex setup` requires outbound internet access (npm registry + GitHub). Codex workspaces are offline by default. If setup reports a network error or the `check-network` step warns: - -→ Read `references/codex-network.md` for the exact `~/.codex/config.toml` fix. +`switchbot codex setup` requires outbound internet (npm registry + GitHub). If it fails with a network error, read `references/codex-network.md` for the `~/.codex/config.toml` fix. --- -## Required bootstrap (run this first, every session) +## Required bootstrap -Before you take any action, establish context: +Before any action, run: ```bash switchbot agent-bootstrap --compact ``` -(The output is always JSON; `--json` is redundant here.) - -The response is `{ "schemaVersion": "1.1", "data": { ... } }`, and -`data` carries everything you need to orient yourself without burning -quota: - -- `cliVersion` — confirm it matches the skill's `authority.cli` range -- `identity` — product, vendor, API version, documentation URL -- `quickReference` — which commands to reach for in common tasks -- `safetyTiers` — the 5-tier enum (see Safety gates below) -- `nameStrategies` — how to resolve a user's spoken name ("bedroom light") - to a deviceId (ordered list: `["exact", "prefix", "substring", "fuzzy", "first", "require-unique"]`) -- `profile` — which CLI profile is active -- `quota` — today's usage + remaining budget -- `devices[]` — cached devices with `deviceId`, `type`, `name`, `category`, `roomName` -- `catalog` — summary of device types present in the account, with - safety tiers and supported commands -- `hints[]` — advisory messages the CLI wants the agent to see (possibly empty array; never null) - -If `devices[]` looks stale (e.g. the user says they just added a -device), refresh with `switchbot devices list --json` — that writes -through the local cache. +The response contains: `cliVersion`, `safetyTiers`, `nameStrategies`, `profile`, `quota`, `devices[]` (cached, with `deviceId`/`type`/`name`/`category`/`roomName`), `catalog`, and `hints[]`. -Then read the user's policy: - -```bash -cat ~/.config/openclaw/switchbot/policy.yaml 2>/dev/null || \ -cat "$HOME/.config/openclaw/switchbot/policy.yaml" 2>/dev/null || \ -cat "$USERPROFILE/.config/openclaw/switchbot/policy.yaml" 2>/dev/null -``` +If devices look stale (user just added one), refresh with `switchbot devices list --json`. -If the file doesn't exist, proceed with defaults from the safety section -below — but tell the user once that they don't have a policy yet and -point them at `switchbot policy new` (requires CLI ≥ 3.7.1). - -If the user asks whether their policy file is correct, run: +Then read the user's policy: ```bash -switchbot policy validate +cat ~/.config/openclaw/switchbot/policy.yaml 2>/dev/null ``` -Exit 0 means the file is valid; any other code means the CLI printed -line-accurate errors — relay those errors to the user rather than -trying to read the YAML yourself. +If the file doesn't exist, proceed with default safety tiers and tell the user once they can create one with `switchbot policy new`. --- ## Resolving a name to a device -When the user says "turn on the bedroom light", resolve the name in this -order (this is what `agent-bootstrap` means by `nameStrategies`): +When the user says "bedroom light", resolve in this order: -1. **alias** — if `policy.yaml` maps `"bedroom light"` → ``, use that. **This is the most reliable path.** -2. **exact** — if a device has `name == "bedroom light"` (case-insensitive), use that. -3. **prefix** — one device whose name starts with the phrase. -4. **substring** — one device whose name contains the phrase. +1. **alias** — `policy.yaml` alias map → ``. Most reliable. +2. **exact** — device `name == "bedroom light"` (case-insensitive). +3. **prefix** — name starts with the phrase. +4. **substring** — name contains the phrase. 5. **fuzzy** — Levenshtein distance ≤ 2. -6. **require-unique** — if more than one device matches at the same tier, **stop and ask** which one the user meant. Do not pick. - -If the user's phrase resolves to multiple devices at the same tier, list -them (name + room + type) and ask. Do not pick the first one and -proceed — this is a known CLI footgun (the `--name` flag used to match -the first result silently; don't rely on that behaviour). +6. **require-unique** — multiple matches at same tier → **stop and ask**. Never pick silently. --- ## Safety gates -Every action carries a `safetyTier`, surfaced by -`switchbot capabilities --json` and per-device by -`switchbot devices describe --json`. Honour these tiers: - | Tier | Examples | Behaviour | |---|---|---| -| `read` | `devices status`, `devices list`, `quota`, `scenes list` | Run freely. | -| `ir-fire-forget` | IR `power`, IR `setAll`, AC/TV/fan via Hub | Run, but tell the user there is no device-side confirmation — you have to trust the IR signal was received. | -| `mutation` | `turnOn`, `turnOff`, `setBrightness`, `setColor` | Run. Append to the audit log (see below). | -| `destructive` | `lock`, `unlock`, deleting scenes/webhooks, anything the user can't trivially undo | **Refuse by default.** Ask the user to confirm explicitly. Even then, run with `--dry-run` first if the CLI supports it for that action. | -| `maintenance` | (reserved — no action uses it today) | Always confirm. | - -The user's `policy.yaml` can override this: - -- `confirmations.always_confirm: ["lock", "unlock", ...]` — forces - confirmation even for tiers that would normally auto-run. -- `confirmations.never_confirm: ["turnOn", "turnOff"]` — loosens - confirmation for non-destructive actions. **Never add a `destructive` - action to `never_confirm`**, even if the user asks in passing — push - back and ask them to say so explicitly in the policy file. -- `quiet_hours: { start, end }` — during quiet hours, even `mutation` - actions need confirmation. +| `read` | status, list, quota | Run freely. | +| `ir-fire-forget` | IR power/AC/TV via Hub | Run; warn there is no device-side confirmation. | +| `mutation` | turnOn/Off, setBrightness, setColor | Run. Append to audit log. | +| `destructive` | lock, unlock, delete scenes/webhooks | **Refuse by default.** Confirm explicitly; prefer `--dry-run` first. | +| `maintenance` | (reserved) | Always confirm. | + +Policy overrides: `confirmations.always_confirm` forces confirmation; `confirmations.never_confirm` pre-approves (never add `destructive` actions). `quiet_hours` requires confirmation even for `mutation`. --- ## Policy compliance -Before executing any mutation or destructive action, check whether the user -has a policy file: +1. Call `policy_validate` (with `live: true`) once per device-control session. +2. Honour `quiet_hours`, `always_confirm`, and `never_confirm` from the validated policy. +3. No policy file → proceed with default tiers. -1. Call `policy_validate` (with `live: true`) at the start of each session - that will involve device control — not on every single command. -2. If `policy_validate` returns a valid policy, honour these fields: - - `quiet_hours` — during the window, ask the user for explicit confirmation - before any mutation, even if the tier would normally auto-run. - - `confirmations.always_confirm` — treat listed commands as destructive - (require explicit user confirmation). - - `confirmations.never_confirm` — treat listed commands as pre-approved - by the user; skip the confirmation prompt. -3. If no policy file exists (`ENOENT` or `present: false`), proceed with the - default safety tiers — no additional prompt needed. - -Never write to policy.yaml without showing the user a diff and getting -explicit approval first. +Never write to `policy.yaml` without showing a diff and getting explicit approval. --- ## Audit logging -When operating through the MCP tools (`send_command`, `run_scene`), the CLI -does not automatically write an audit log entry. To review past activity use -the built-in audit tools: - -- `audit_query` — filter audit log entries by time range, device, or result. -- `audit_stats` — summarise counts by command, device, and result. - -If the user asks for a full audit trail, advise them to run mutation commands -directly via the CLI with `--audit-log`: - -```bash -switchbot --audit-log devices command turnOn -``` +Use `audit_query` and `audit_stats` MCP tools to review past activity. For a full audit trail with CLI, use `switchbot --audit-log devices command `. --- ## Output modes -The CLI supports `--format=json|yaml|tsv|id|markdown`. `--json` is an -alias for `--format=json`. Always use JSON when you're going to parse -the output; use `markdown` when you're summarising for the user as chat -output. - -Never parse `markdown` or human tables programmatically — they're not -stable. If you find yourself regex-extracting from a table, stop and -re-run with `--json`. +Always use `--json` when parsing output. Use `--format=markdown` for user-facing summaries. Never parse markdown or human tables programmatically — re-run with `--json`. --- -## Streaming events - -If the user wants real-time reactions (motion, door contact, button -press), start the MQTT stream: - -```bash -switchbot events mqtt-tail --json -``` +## Credentials -Every line is one event in the unified envelope: - -```json -{"schemaVersion":"1.1","t":"2026-04-22T...","source":"mqtt", - "deviceId":"...","topic":"...","type":"device.shadow","payload":{...}} -``` - -The first line is a stream header with `{"stream":true, "eventKind":..., "cadence":...}` — consume it, then iterate. - -If the user is running this inside an OpenClaw-aware setup, the CLI has -an `--sink openclaw` mode that POSTs events to a local gateway directly; -check `switchbot events mqtt-tail --help` for current flags rather than -assuming. +First-time login: `switchbot auth login` (opens browser). Headless: add `--no-open`. Inspect the active keychain backend: `switchbot auth keychain describe --json`. Reset cache without touching credentials: `switchbot reset [--all]`. Never run `auth login` or `auth keychain set` on the user's behalf. --- -## Declarative automations (CLI ≥ 3.7.1, policy v0.2) - -When the user wants "when X happens, do Y" rather than one-shot commands, -author a rule in the `automation:` block of `policy.yaml` instead of -spawning a shell loop. This repo requires `@switchbot/openapi-cli` 3.7.1+ -and runs the rules engine in the same process that reads the policy. - -Before you touch `policy.yaml`, check the schema version: +## Declarative automations (CLI ≥ 3.7.1) -```bash -cat ~/.config/openclaw/switchbot/policy.yaml | head -1 # version: "0.2" ? -switchbot policy validate # exit 0 means good -``` - -If the user is on `version: "0.1"`, they need `switchbot policy migrate` -first — do **not** hand-edit the version line. +When the user wants "when X, do Y", author a rule in `policy.yaml` instead of a shell loop. Check schema version first (`head -1 policy.yaml`, must be `"0.2"`; if `"0.1"` run `switchbot policy migrate`). -### Authoring a rule - -Keep the first rule tiny and start with `dry_run: true`. The engine -will log firings to the audit log without touching the device, so the -user can verify before arming: +Start with `dry_run: true`: ```yaml automation: @@ -274,345 +135,71 @@ automation: dry_run: true ``` -Show the user the diff before writing. After they approve, validate + -reload: +Trigger kinds: `source: mqtt` (shadow events), `source: cron` (schedule + optional `days:`), `source: webhook` (bearer-token HTTP). Conditions: `time_between`, `{device, field, op, value}`, `all:`, `any:`, `not:`. + +The validator rejects any rule with a `destructive` action in `then[]`. Always start dry, confirm firings via `switchbot rules tail --follow`, then remove `dry_run`. ```bash switchbot policy validate -switchbot rules lint # catches cron typos, unknown aliases -switchbot rules reload # SIGHUP on Unix / pid-file on Windows -switchbot rules tail --follow # watch fires arrive (dry-run fires too) +switchbot rules lint && switchbot rules reload ``` -### Trigger kinds - -- `source: mqtt` — reacts to shadow events. `event` is - `motion.detected`, `contact.open`, etc. (check the device's - `describe --json` for the exact event names it emits.) -- `source: cron` — `schedule: "0 8 * * *"` style expressions in local - time. Optional `days: [mon, wed, fri]` list (weekday names `mon`–`sun` - or full names, case-insensitive) applied *after* the cron fires — - firings on unlisted days are suppressed without writing throttle or - audit entries. -- `source: webhook` — bearer-token HTTP ingest on a configurable port. - The token lives in the OS keychain (`switchbot auth keychain set`), - **never** in `policy.yaml`. - -### Conditions - -Top-level `conditions[]` entries are AND-joined. Each entry is one of: - -- `time_between: ["22:00", "07:00"]` — local time; midnight-crossing - is supported. -- `{ device, field, op, value }` — per-tick cached device status - lookup; e.g. `{ device: "front lock", field: "online", op: "==", value: true }`. - Operators: `==`, `!=`, `<`, `>`, `<=`, `>=`. -- `all: [condition, ...]` — all sub-conditions must pass (logical AND - over a sub-list). -- `any: [condition, ...]` — at least one sub-condition must pass (OR). -- `not: condition` — inverts a single condition. - -Composites nest arbitrarily via `$ref`. Example: `[A, { any: [B, C] }]` -evaluates as `A AND (B OR C)`. - -### Rules the engine will refuse to accept - -The validator rejects any rule whose `then.command` would fire a -destructive action (`unlock`, `garage-door open`, `keypad createKey`, -etc.). The rejection is a schema error at `policy validate` time — not -a runtime surprise. If the user asks for "auto-unlock when I arrive -home", push back and explain: destructive actions must be driven by a -human, not a rule. - -### When to recommend a rule vs. a shell loop - -Recommend a rule when: -- The logic is declarative (one trigger + one-or-two conditions + one - command). -- The user wants it to survive a reboot (pair with the systemd unit in - the CLI repo's `examples/quickstart/mqtt-tail.service.example` and - a similar `switchbot rules run --audit-log` unit). - -Recommend a shell loop when: -- The logic needs multi-step branching you'd build with `jq` + `if`. -- The user wants a one-off transient thing that doesn't live in policy. - --- -## Credentials in the keychain (CLI ≥ 3.7.1) - -**First-time login (recommended path):** -`switchbot auth login` opens a browser window to the SwitchBot login page. After the user signs in, the CLI stores `token` and `secret` directly in the OS keychain and verifies them automatically. The skill never needs to be involved — the user runs this once. - -If the browser cannot open (CI, headless, or SSH), pass `--no-open`: -```bash -switchbot auth login --no-open -``` -The CLI prints a URL; the user opens it in any browser on any machine. - -**Moving existing credentials into the keychain:** -If the user already has credentials in `~/.switchbot/config.json`, point them at `switchbot auth keychain migrate` — it moves token + secret to the OS keychain (macOS `security(1)`, Windows `CredRead`/`CredWrite`, Linux `secret-tool`) and deletes the plain-text file on success. +## Semi-autonomous workflow — `plan suggest` + `--require-approval` -**Inspecting the active backend:** ```bash -switchbot auth keychain describe --json -``` -Relay the `backend` and `writable` fields verbatim — downstream troubleshooting steps depend on knowing which backend is active. - -**Resetting local state without touching credentials:** -```bash -switchbot reset # clears device cache, quota counter, history -switchbot reset --all # also clears audit log and device metadata +switchbot plan suggest --intent "turn off all lights" --device --device +# Review/edit the generated JSON +switchbot plan run plan.json --require-approval ``` -`reset` never touches keychain entries. Suggest it when the user reports stale device state or a corrupted cache, **before** suggesting re-login. - -The skill does **not** run `auth login`, `auth keychain set`, or `migrate` on the user's behalf — the user always runs credential commands. You may run `auth keychain describe --json` to diagnose which backend is active. - ---- -## Common pitfalls (from CLI audit) - -Read these once and avoid them: - -1. **Don't parse help output as text.** Always `--help --json`. The - text version is for humans and changes between releases. -2. **Don't rely on `name` matching first hit.** Resolve the name - yourself (see "Resolving a name to a device"), or pass `deviceId` - directly. -3. **Don't assume a command exists on every device.** Before calling - `setBrightness`, check `switchbot devices describe --json` - and confirm `commands[]` includes `setBrightness`. Not every bulb - supports every command. -4. **Quota counts attempts, not successes.** A burst of failed calls - still eats the daily 10 000 budget. If `switchbot quota --json` - shows you're above 80%, slow down and batch. -5. **`--json` envelope — read `.data`, check `.error` first.** Every - `--json` response is wrapped: `{"schemaVersion":"1.1","data":...}` on - success, `{"schemaVersion":"1.1","error":{...}}` on failure. This was - a breaking envelope change — parsers that reach for top-level fields - (e.g. `obj.devices` instead of `obj.data.devices`) silently get - `undefined`. -6. **Some fields are deprecated.** Prefer `safetyTier` over - `destructive:boolean`; prefer `statusQueries` over `statusFields`. - The old fields still appear in CLI 2.7.x output but are removed in - v3.0. Bootstrap payload already uses the new names. -7. **Cold-start the cache when the user adds a device.** The cache - doesn't auto-refresh; when a user says "I just added a new - sensor", run `switchbot devices list --json` first. - -8. **Force `--no-cache` on batch/long-lived reads** *(temporary — remove - when upstream cache bug is fixed)*. Loops, fan-outs, and reads after - long idle hit a cache bug returning stale state. Don't substitute by - lowering `cli.cache_ttl` — that's durable config; `--no-cache` is a - per-call flag. See `troubleshooting.md` § *Batch or long-lived calls return stale device state*. -9. **Validate deviceId shape yourself before writing rules.** The - policy schema patterns only the `aliases` map - (`^[A-Z0-9]{2,}-[A-Z0-9-]+$`); `device:` on triggers, conditions, and - actions is a plain string. `switchbot policy validate` will accept - `01-abc` and fail at runtime. Before authoring a rule, if the value - is not a known alias key from `policy.yaml`, match it against the - same regex yourself and reject on mismatch. +Non-destructive steps run automatically; destructive steps prompt once. Via MCP: call `plan_suggest`, then have the user run `--require-approval` in a TTY session. --- -## Things to never do +## Common pitfalls -- Never ask the user for their SwitchBot token or secret. If - `switchbot config show` fails because credentials are missing, tell - the user to run `switchbot config set` themselves — they input the - credentials into the CLI, not into you. -- Never suggest commands that bypass safety tiers - (`--skip-confirmation`, `--force`, etc.) unless the CLI documents - them and the user asked for them by name. -- Never claim an IR action "succeeded" in the sense of device - confirmation — IR is open-loop. Say the signal was sent; if the user - cares whether the TV actually turned on, they need a sensor loop. -- Never write to `policy.yaml` without showing the user the diff and - getting an explicit yes. -- Never generate a rule with a destructive command in `then[]` (e.g. `unlock`, - `deleteScene`, `factoryReset`). The CLI's lint step will reject it, but - the skill must not attempt it in the first place. -- Never arm a rule (`dry_run: false`) on first author — always start dry, - confirm firings via `switchbot rules tail --follow`, then transition. -- Never set `automation.enabled: true` without explicitly informing the user. -- Never run `switchbot doctor --fix --yes` without the user asking for - it. `--fix` mutates state (clears caches, rewrites config); it needs - intent. +1. **Don't parse help text.** Always `--help --json`. +2. **Don't rely on `--name` picking one hit.** Resolve the name yourself; pass `deviceId` directly. +3. **Check `commands[]` before calling a command.** `switchbot devices describe --json` — not every device supports every command. +4. **Quota counts attempts, not successes.** Above 80%, slow down and batch. +5. **`--json` envelope** — every response is `{"schemaVersion":"1.1","data":...}` or `{"error":{...}}`. Read `.data`, check `.error` first. Parsers that read top-level fields silently get `undefined`. --- -## If the CLI returns an error - -The envelope looks like: +## Error handling ```json -{ - "schemaVersion": "1.1", - "error": { - "kind": "usage" | "auth" | "quota" | "network" | "upstream" | "internal", - "message": "...", - "hint": "..." - } -} -``` - -- `kind: "usage"` — you (the agent) called something wrong. Re-read the - help for that subcommand and retry. -- `kind: "auth"` — token is missing/invalid/expired. Tell the user to - run `switchbot doctor --section credentials`. -- `kind: "quota"` — daily 10 000 calls exceeded. Stop, tell the user - when it resets (midnight UTC). -- `kind: "network"` — transient. Retry once, then surface the error. -- `kind: "upstream"` — SwitchBot cloud is unhappy. Surface the message - verbatim; don't paraphrase. -- `kind: "internal"` — CLI bug. Ask the user to run - `switchbot doctor --json` and file an issue. - -Never retry `destructive` actions automatically — that's how you unlock -a door twice. - - -For `mutation` retries, gate with your own idempotency layer — a local -fingerprint (e.g. `{deviceId, command, args, minute-bucket}`) + short -TTL. Do **not** rely on `--idempotency-key` for dedupe *(temporary — -revisit when CLI idempotency is documented as reliable)*; a retry after -a `network` or `internal` error can double-fire without a local gate. - ---- - -## Semi-autonomous workflow — `plan suggest` + `--require-approval` (CLI ≥ 3.7.1) - -When the user wants to review each dangerous step rather than confirm -each command interactively, use the Plan workflow: - -```bash -# 1. Draft a plan from intent -switchbot plan suggest \ - --intent "turn off all lights" \ - --device --device - -# 2. Inspect the generated JSON; edit if needed -# 3. Run with per-step approval -switchbot plan run plan.json --require-approval +{ "error": { "kind": "usage|auth|quota|network|upstream|internal", "message": "...", "hint": "..." } } ``` -`plan suggest` uses keyword heuristics (on/off/press/lock/open/close/pause) -to pick the right command for each device. If the intent is ambiguous, -it defaults to `turnOn` with a warning on stderr — edit the plan before -running. +- `usage` → you called something wrong; re-read help and retry. +- `auth` → run `switchbot doctor --section credentials`. +- `quota` → stop; resets at midnight UTC. +- `network` → retry once, then surface. +- `upstream` → relay verbatim. +- `internal` → ask user to run `switchbot doctor --json` and file an issue. -`plan run --require-approval` prompts once per destructive step: - -``` - Approve step 1 — unlock on ? [y/N] -``` - -Non-destructive steps run without prompting. A rejected step is logged -as `decision: "rejected"` and skipped; the remaining steps continue -(unless `--continue-on-error` is unset, in which case the run halts). - -When used via MCP, call the `plan_suggest` tool (safety tier `read`) to -produce the draft plan JSON, then have the user run it interactively -with `--require-approval` in a TTY session. - -**Constraints:** - -- `--require-approval` is mutually exclusive with `--json`. -- `--yes` overrides `--require-approval` — blanket approval, no prompts. -- In non-TTY environments (CI, pipes), all destructive steps auto-reject. +Never retry `destructive` actions automatically. For `mutation` retries, use a local fingerprint `{deviceId, command, args, minute-bucket}` as an idempotency gate. --- -## L3 · Proactive rule authoring (CLI ≥ 3.7.1) - -### When to proactively suggest a rule - -- User says "every time X happens, do Y" → prefer a rule over a one-shot command. -- User has run the same command manually three or more times → offer to automate it. -- User describes a time-based habit → offer a cron rule. -- **Do NOT** suggest a rule for a one-off action or when the user explicitly asks for a single command. - -### Authoring + approval workflow - -```bash -# Step 1: Generate rule YAML (no side effects) -switchbot rules suggest \ - --intent "turn on hallway light when motion detected at night" \ - --trigger mqtt \ - --device "hallway sensor" --device "hallway lamp" - -# Step 2: Dry-run diff — ALWAYS show this to the user before writing -switchbot rules suggest --intent "..." | switchbot policy add-rule --dry-run - -# Step 3: After user approves, inject and reload -switchbot rules suggest --intent "..." | switchbot policy add-rule [--enable] -switchbot rules lint # must exit 0 before proceeding -switchbot rules reload -``` - -When using MCP (no shell access), substitute `rules_suggest` and `policy_add_rule` tools: - -1. Call `rules_suggest` to get the rule YAML. -2. Call `policy_add_rule` with `dry_run: true` — show the diff to the user. -3. After user approves, call `policy_add_rule` with `dry_run: false`. - -When investigating why a rule fired or was blocked, use `rules_explain`: - -- `rules_explain` with `rule_name` + `last: true` → most recent evaluation -- `rules_explain` with `fire_id` → specific evaluation by ID -- Returns per-condition ✓/✗ trace, decision, and timing - -To pre-validate a new or modified rule against historical events before arming: - -- `rules_simulate` with `rule_yaml` + `since: "7d"` → replay last 7 days -- Returns `wouldFire`, `blockedByCondition`, `throttled`, `topBlockReason` -- Always simulate before removing `dry_run: true` from a rule - -### Dry-run → arm transition - -Rules start as `dry_run: true`; the engine logs firings without touching devices. -After injection, direct the user to run: - -```bash -switchbot rules tail --follow -``` - -Confirm that firings look correct for at least one real event. Only after the -user confirms: edit `dry_run: true` → remove the field (or set to `false`) in -policy.yaml, show diff, wait for approval, reload: - -```bash -switchbot rules lint && switchbot rules reload -``` +## Things to never do -Run `switchbot rules replay --since 24h --json` regularly to surface misfires. +- Ask the user for their SwitchBot token or secret. +- Suggest flags that bypass safety tiers (`--skip-confirmation`, `--force`) unless the user named them explicitly. +- Claim IR actions "succeeded" — IR is open-loop; say the signal was sent. +- Write to `policy.yaml` without showing a diff and getting explicit approval. +- Generate a rule with a destructive command in `then[]`. +- Arm a rule (`dry_run: false`) on first author without the user confirming firings. +- Set `automation.enabled: true` without explicitly informing the user. +- Run `switchbot doctor --fix --yes` without the user asking. --- -## Version pinning - -This skill targets `@switchbot/openapi-cli` **≥ 3.7.1** and has been -validated against `3.7.x`. - -This repo standardizes on CLI 3.7.1+ for all installation, upgrade, and -support paths. Earlier 3.x versions (3.0.0–3.6.x) silently return the -wrong envelope shape, have a known cache bug on batch/long-lived reads, -and accept malformed policy files — the four pitfalls §5–§9 below all -assume 3.7.1 behavior. - -If `switchbot --version` prints an older version, tell the user to run: - -```bash -npm update -g @switchbot/openapi-cli -``` +## Version -The skill already expects the v3 capability schema. If you see examples in the -wild that still rely on old 2.x fields, prefer the current `switchbot -capabilities --json` output over those examples. +Targets `@switchbot/openapi-cli` ≥ 3.7.1. If `switchbot --version` is older: `npm update -g @switchbot/openapi-cli`. -This skill declares `autonomyLevel: "L2"` in its `manifest.json`. -L2 means the skill can draft a plan from intent and run it with -per-step approval (`plan suggest` + `plan run --require-approval`). -Rules authored by the skill default to `dry_run: true` until the user -flips them on. L3 (fully autonomous inside policy envelope) remains -out of scope for this skill version. + diff --git a/packages/codex-plugin/tests/marketplace-schema.test.js b/packages/codex-plugin/tests/marketplace-schema.test.js new file mode 100644 index 00000000..3aa487ba --- /dev/null +++ b/packages/codex-plugin/tests/marketplace-schema.test.js @@ -0,0 +1,46 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = fileURLToPath(new URL('.', import.meta.url)); + +describe('marketplace.json $schema field', () => { + const codexPluginMarketplacePath = resolve(__dirname, '../.agents/plugins/marketplace.json'); + // claude-code-plugin intentionally keeps .claude-plugin/ — that's Claude Code's naming convention + const claudeCodePluginMarketplacePath = resolve(__dirname, '../../claude-code-plugin/.claude-plugin/marketplace.json'); + + it('codex-plugin marketplace.json contains $schema field', () => { + const content = JSON.parse(readFileSync(codexPluginMarketplacePath, 'utf8')); + assert.ok(content.$schema, 'codex-plugin marketplace.json missing $schema field'); + }); + + it('claude-code-plugin marketplace.json contains $schema field', () => { + const content = JSON.parse(readFileSync(claudeCodePluginMarketplacePath, 'utf8')); + assert.ok(content.$schema, 'claude-code-plugin marketplace.json missing $schema field'); + }); + + it('both marketplace.json files have identical $schema values', () => { + const codexPluginContent = JSON.parse(readFileSync(codexPluginMarketplacePath, 'utf8')); + const claudeCodePluginContent = JSON.parse(readFileSync(claudeCodePluginMarketplacePath, 'utf8')); + assert.equal( + codexPluginContent.$schema, + claudeCodePluginContent.$schema, + 'marketplace.json $schema values do not match' + ); + assert.equal( + codexPluginContent.$schema, + 'https://anthropic.com/claude-code/marketplace.schema.json', + 'unexpected $schema value' + ); + }); + + it('$schema is the first field in codex-plugin marketplace.json', () => { + const rawContent = readFileSync(codexPluginMarketplacePath, 'utf8'); + assert.ok( + rawContent.indexOf('"$schema"') < rawContent.indexOf('"name"'), + '$schema should appear before "name" in the raw JSON text', + ); + }); +}); diff --git a/packages/codex-plugin/tests/skill-sync.test.js b/packages/codex-plugin/tests/skill-sync.test.js new file mode 100644 index 00000000..04ceaa4e --- /dev/null +++ b/packages/codex-plugin/tests/skill-sync.test.js @@ -0,0 +1,58 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const SKILL_1 = path.join(__dirname, '../skills/switchbot/SKILL.md'); +const SKILL_2 = path.join(__dirname, '../plugins/switchbot/skills/switchbot/SKILL.md'); +// claude-code-plugin copy — intentionally different content at line 37 (plugin-specific network setup), +// but must still exist on disk and carry a MAINTENANCE comment. +const SKILL_3 = path.join(__dirname, '../../claude-code-plugin/plugins/switchbot/skills/switchbot/SKILL.md'); + +test('SKILL.md files have maintenance comments and identical content', async (t) => { + await t.test('all SKILL.md files exist', () => { + assert.ok(fs.existsSync(SKILL_1), `${SKILL_1} should exist`); + assert.ok(fs.existsSync(SKILL_2), `${SKILL_2} should exist`); + assert.ok(fs.existsSync(SKILL_3), `${SKILL_3} should exist`); + }); + + await t.test('all SKILL.md files contain MAINTENANCE comment', () => { + const content1 = fs.readFileSync(SKILL_1, 'utf8'); + const content2 = fs.readFileSync(SKILL_2, 'utf8'); + const content3 = fs.readFileSync(SKILL_3, 'utf8'); + + assert.ok( + content1.includes('\s*$/, ''); + }; + + const normalized1 = removeMaintenanceComment(content1); + const normalized2 = removeMaintenanceComment(content2); + + assert.equal( + normalized1, + normalized2, + 'SKILL.md files (1 and 2) should have identical content except for maintenance comments' + ); + }); +}); diff --git a/packages/openclaw-skill/.claude-plugin/plugin.json b/packages/openclaw-skill/.claude-plugin/plugin.json deleted file mode 100644 index 9f8caa3f..00000000 --- a/packages/openclaw-skill/.claude-plugin/plugin.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "switchbot", - "version": "0.1.0", - "description": "Control SwitchBot smart-home devices (lights, locks, curtains, sensors, plugs, IR appliances) from an OpenClaw agent via MCP. Drives @switchbot/openapi-cli >= 3.7.1.", - "homepage": "https://github.com/OpenWonderLabs/switchbot-openapi-cli/tree/main/packages/openclaw-skill", - "repository": "https://github.com/OpenWonderLabs/switchbot-openapi-cli", - "license": "MIT", - "keywords": [ - "switchbot", - "smart-home", - "iot", - "mcp", - "openclaw" - ] -} diff --git a/packages/openclaw-skill/README.md b/packages/openclaw-skill/README.md index 3b74ecec..3b8585dc 100644 --- a/packages/openclaw-skill/README.md +++ b/packages/openclaw-skill/README.md @@ -47,10 +47,7 @@ Full tool reference: `switchbot mcp tools` ## Usage The server communicates over **stdio** (MCP protocol). OpenClaw launches -the MCP server via the declarations in: - -- `.claude-plugin/plugin.json` — bundle identity -- `.mcp.json` — stdio launcher (`node ${pluginDir}/bin/start.js`) +the MCP server via `.mcp.json` — stdio launcher (`node ${pluginDir}/bin/start.js`). **First launch auto-setup**: if `@switchbot/openapi-cli` is not installed, `bin/start.js` installs it automatically. If credentials are missing, it diff --git a/packages/openclaw-skill/bin/policy-edit.js b/packages/openclaw-skill/bin/policy-edit.js index c2e61514..8638ac57 100644 --- a/packages/openclaw-skill/bin/policy-edit.js +++ b/packages/openclaw-skill/bin/policy-edit.js @@ -1,10 +1,6 @@ #!/usr/bin/env node // packages/openclaw-skill/bin/policy-edit.js — invoked as `switchbot-policy-edit` -import { fileURLToPath } from 'node:url'; -import { dirname, join } from 'node:path'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const { startEditorServer } = await import(join(__dirname, '../policy-editor/server.js')); +const { startEditorServer } = await import(new URL('../policy-editor/server.js', import.meta.url)); const server = await startEditorServer({ port: 18799 }); console.log(`Policy editor: http://localhost:${server.port}`); diff --git a/packages/openclaw-skill/package.json b/packages/openclaw-skill/package.json index e98d3e78..ccb90be6 100644 --- a/packages/openclaw-skill/package.json +++ b/packages/openclaw-skill/package.json @@ -21,8 +21,7 @@ "switchbot", "mcp", "smart-home", - "iot", - "claude-plugin" + "iot" ], "license": "MIT", "engines": { @@ -37,7 +36,6 @@ }, "files": [ "index.js", - ".claude-plugin/", ".mcp.json", "bin/", "lib/", diff --git a/scripts/smoke-codex-git-sparse.mjs b/scripts/smoke-codex-git-sparse.mjs index 06d9f0b4..aecc8fd3 100644 --- a/scripts/smoke-codex-git-sparse.mjs +++ b/scripts/smoke-codex-git-sparse.mjs @@ -45,7 +45,7 @@ try { runGit(['-C', stagingDir, 'checkout', ref], { cwd: workDir }); const rootMarketplacePath = path.join(stagingDir, '.claude-plugin', 'marketplace.json'); - const packageMarketplacePath = path.join(stagingDir, 'packages', 'codex-plugin', '.claude-plugin', 'marketplace.json'); + const packageMarketplacePath = path.join(stagingDir, 'packages', 'codex-plugin', '.agents', 'plugins', 'marketplace.json'); const pluginMcpPath = path.join(stagingDir, 'packages', 'codex-plugin', 'plugins', 'switchbot', '.mcp.json'); for (const requiredPath of [rootMarketplacePath, packageMarketplacePath, pluginMcpPath]) { diff --git a/scripts/smoke-codex-pack-install.mjs b/scripts/smoke-codex-pack-install.mjs index c0fc0203..8817b621 100644 --- a/scripts/smoke-codex-pack-install.mjs +++ b/scripts/smoke-codex-pack-install.mjs @@ -63,7 +63,7 @@ try { } for (const requiredPath of [ - '.claude-plugin/marketplace.json', + '.agents/plugins/marketplace.json', '.codex-plugin/plugin.json', '.codex-plugin/hooks.json', '.mcp.json', @@ -82,7 +82,7 @@ try { throw new Error(`plugin displayName must be SwitchBot, got ${pluginManifest?.interface?.displayName ?? ''}`); } - const marketplace = readJson(path.join(pluginRoot, '.claude-plugin', 'marketplace.json')); + const marketplace = readJson(path.join(pluginRoot, '.agents', 'plugins', 'marketplace.json')); if (marketplace?.name !== 'switchbot') { throw new Error(`marketplace name must be switchbot so switchbot@switchbot resolves, got ${marketplace?.name ?? ''}`); } diff --git a/src/commands/codex.ts b/src/commands/codex.ts index 35e0534d..d1d99522 100644 --- a/src/commands/codex.ts +++ b/src/commands/codex.ts @@ -417,6 +417,7 @@ interface SetupContext { codexPluginId?: string; packageRoot?: string | null; nonInteractive: boolean; + upgrade: boolean; } type SetupOutcome = StepOutcome; @@ -466,10 +467,11 @@ function setupStepCheckCodexCli(): SetupOutcome { return { step: 'check-codex-cli', status: 'ok', message: where }; } -function setupStepInstallSwitchbotCli(): SetupOutcome { +function setupStepInstallSwitchbotCli(ctx: SetupContext): SetupOutcome { return setupStepInstallGlobalPackage( 'install-switchbot-cli', SWITCHBOT_CLI_PACKAGE, + { upgrade: ctx.upgrade }, ); } @@ -488,10 +490,7 @@ function resolveInstalledVersion(packageName: string): string | null { } } -function setupStepInstallGlobalPackage(step: string, packageName: string): SetupOutcome { - const { version: latestVersion, fromRegistry } = fetchLatestPublishedVersion(packageName); - const registryNote = fromRegistry ? '' : ' (registry unreachable, used running version as reference)'; - +function setupStepInstallGlobalPackage(step: string, packageName: string, opts: { upgrade: boolean }): SetupOutcome { const list = spawnSync( 'npm', ['list', '-g', '--json', '--depth=0', packageName], { encoding: 'utf-8', shell: process.platform === 'win32', timeout: 15000 }, @@ -504,6 +503,13 @@ function setupStepInstallGlobalPackage(step: string, packageName: string): Setup installedVersion = parsed?.dependencies?.[packageName]?.version ?? null; } catch { /* treat as not installed */ } + if (installedVersion !== null && !opts.upgrade) { + return { step, status: 'ok', message: `already installed (${installedVersion})` }; + } + + const { version: latestVersion, fromRegistry } = fetchLatestPublishedVersion(packageName); + const registryNote = fromRegistry ? '' : ' (registry unreachable, used running version as reference)'; + if (installedVersion !== null) { if (compareVersions(installedVersion, latestVersion) >= 0) { return { step, status: 'ok', message: `already installed (${installedVersion})${registryNote}` }; @@ -582,6 +588,14 @@ async function setupStepDoctorVerify(): Promise { }; } +export async function isAlreadyConfigured(): Promise { + if (checkCodexCli().status !== 'ok') return false; + if (!await credentialsPresent()) return false; + if (checkCodexPluginNpm().status !== 'ok') return false; + if (checkCodexPluginRegistered().status !== 'ok') return false; + return true; +} + async function runSetup( skip: Set, ctx: SetupContext, @@ -608,7 +622,7 @@ async function runSetup( let outcome: SetupOutcome; if (step.name === 'check-codex-cli') outcome = setupStepCheckCodexCli(); else if (step.name === 'check-network') outcome = setupStepCheckNetwork(); - else if (step.name === 'install-switchbot-cli') outcome = setupStepInstallSwitchbotCli(); + else if (step.name === 'install-switchbot-cli') outcome = setupStepInstallSwitchbotCli(ctx); else if (step.name === 'register-plugin') outcome = setupStepRegisterPlugin(ctx); else if (step.name === 'auth') outcome = await setupStepAuth(ctx); else outcome = await setupStepDoctorVerify(); @@ -631,12 +645,13 @@ function registerCodexSetupSubcommand(codex: Command): void { .description('Bootstrap the Codex integration end-to-end: install packages if missing, register plugin, auth, verify') .option('--skip ', 'Comma-separated step names to skip (skippable: "install-switchbot-cli", "auth"; deprecated no-ops: "install-codex-plugin")') .option('--yes', 'Non-interactive mode: do not spawn auth login, fail fast if credentials missing') + .option('--upgrade', 'Upgrade @switchbot/openapi-cli to the latest published version if already installed') .addHelpText('after', ` Environment variables: CODEX_GIT_MARKETPLACE_REF Git ref used when registering via git marketplace (default: main) CODEX_MARKETPLACE_ADD_TIMEOUT Timeout in ms for "codex plugin marketplace add" (default: 60000) `) - .action(async (opts: { skip?: string; yes?: boolean }, command: Command) => { + .action(async (opts: { skip?: string; yes?: boolean; upgrade?: boolean }, command: Command) => { const skip = new Set( (opts.skip ?? '').split(',').map((s) => s.trim()).filter(Boolean), ); @@ -681,8 +696,22 @@ Environment variables: profile, configPath, nonInteractive: Boolean(opts.yes), + upgrade: Boolean(opts.upgrade), }; + // Fast path: when no steps are skipped and all required components are already + // present and healthy, skip the full setup pipeline. + if (skip.size === 0 && !ctx.upgrade && await isAlreadyConfigured()) { + if (isJsonMode()) { + printJson({ ok: true, alreadyConfigured: true, outcomes: [] }); + } else { + console.log(chalk.green('Already configured, nothing to do.')); + console.log(chalk.dim('Run: switchbot codex doctor — to verify health')); + } + process.exit(0); + return; + } + if (!isJsonMode()) console.log(chalk.bold('Setting up Codex integration...')); if (!isJsonMode()) console.log(''); diff --git a/src/devices/cache.ts b/src/devices/cache.ts index f6847c60..75615304 100644 --- a/src/devices/cache.ts +++ b/src/devices/cache.ts @@ -166,6 +166,7 @@ export function updateCacheFromDeviceList(body: DeviceListBodyShape): void { } for (const d of body.infraredRemoteList) { if (!d.deviceId) continue; + const hub = d.hubDeviceId ? devices[d.hubDeviceId] : undefined; devices[d.deviceId] = { type: d.remoteType, typeSource: 'remoteType', @@ -173,6 +174,9 @@ export function updateCacheFromDeviceList(body: DeviceListBodyShape): void { category: 'ir', hubDeviceId: d.hubDeviceId, controlType: d.controlType, + familyName: hub?.familyName, + roomID: hub?.roomID, + roomName: hub?.roomName, }; } diff --git a/src/install/codex-checks.ts b/src/install/codex-checks.ts index c51ce77d..ad716e67 100644 --- a/src/install/codex-checks.ts +++ b/src/install/codex-checks.ts @@ -43,15 +43,15 @@ function readJsonObject(filePath: string): Record | null { } function resolveMarketplaceName(packageRoot: string): string { - // Check .claude-plugin/marketplace.json (canonical path Codex CLI reads, >=0.1.3) - const claudePluginPath = path.join(packageRoot, '.claude-plugin', 'marketplace.json'); - if (fs.existsSync(claudePluginPath)) { - const manifest = readJsonObject(claudePluginPath); + // .agents/plugins/marketplace.json — Codex CLI primary path (replaces .claude-plugin/ from 0.1.3) + const agentsPluginsPath = path.join(packageRoot, '.agents', 'plugins', 'marketplace.json'); + if (fs.existsSync(agentsPluginsPath)) { + const manifest = readJsonObject(agentsPluginsPath); if (typeof manifest?.name === 'string' && manifest.name) { return manifest.name; } } - // Fall back to root-level marketplace.json (present in pre-0.1.3 local copies) + // Fall back to root-level marketplace.json (pre-0.1.3 local copies) const rootManifestPath = path.join(packageRoot, 'marketplace.json'); if (fs.existsSync(rootManifestPath)) { const manifest = readJsonObject(rootManifestPath); @@ -59,14 +59,6 @@ function resolveMarketplaceName(packageRoot: string): string { return manifest.name; } } - // Fall back to legacy .agents/plugins/marketplace.json - const legacyPath = path.join(packageRoot, '.agents', 'plugins', 'marketplace.json'); - if (fs.existsSync(legacyPath)) { - const marketplace = readJsonObject(legacyPath); - if (typeof marketplace?.name === 'string' && marketplace.name) { - return marketplace.name; - } - } return path.basename(packageRoot); } @@ -330,7 +322,6 @@ export function registerCodexPlugin(): RegisterCodexPluginResult { // ─── Git-based marketplace registration (Route B) ──────────────────────────── export const CODEX_GIT_MARKETPLACE_REPO = 'OpenWonderLabs/switchbot-openapi-cli'; export const CODEX_GIT_MARKETPLACE_SPARSE = 'packages/codex-plugin'; -export const CODEX_GIT_MARKETPLACE_SPARSE2 = '.claude-plugin'; export const CODEX_GIT_MARKETPLACE_REF = 'main'; export const CODEX_PLUGIN_DEFAULT_ID = 'switchbot@switchbot'; // Known IDs from pre-release installs; cleaned up by both Route A and Route B. @@ -341,6 +332,12 @@ export const CODEX_PLUGIN_LEGACY_IDS = ['switchbot@codex-plugin', 'switchbot@swi // causing the next `marketplace add` to say "already added" without recreating the directory. export const CODEX_MARKETPLACE_LEGACY_NAMES = ['switchbot', 'codex-plugin', 'switchbot-skill']; +function isCodexProcessRunning(): boolean { + if (process.platform !== 'win32') return false; + const r = spawnStr('tasklist', ['/FI', 'IMAGENAME eq Codex.exe', '/NH', '/FO', 'CSV'], 5000); + return r.status === 0 && r.stdout.toLowerCase().includes('codex.exe'); +} + export function runCodexPluginRegistrationGit(pluginId: string): RegistrationResult { const ref = process.env['CODEX_GIT_MARKETPLACE_REF'] || CODEX_GIT_MARKETPLACE_REF; const _envTimeout = process.env['CODEX_MARKETPLACE_ADD_TIMEOUT']; @@ -367,14 +364,22 @@ export function runCodexPluginRegistrationGit(pluginId: string): RegistrationRes 'plugin', 'marketplace', 'add', CODEX_GIT_MARKETPLACE_REPO, '--sparse', CODEX_GIT_MARKETPLACE_SPARSE, - '--sparse', CODEX_GIT_MARKETPLACE_SPARSE2, '--ref', ref, ]; let mkt = spawnStr('codex', mktArgs, timeout); - // On Windows, git holds file handles briefly after clone; retry once after 10 s. + // On Windows, git holds file handles briefly after clone. + // Detect if Codex Desktop is running and warn; then retry with exponential backoff. if (mkt.status !== 0 && process.platform === 'win32' && mkt.stderr.includes('os error 32')) { - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10000); - mkt = spawnStr('codex', mktArgs, timeout); + if (isCodexProcessRunning()) { + process.stderr.write( + '[switchbot] Warning: Codex.exe is running. Close Codex Desktop before running setup to avoid file-lock errors.\n', + ); + } + for (const delay of [2000, 5000, 10000]) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delay); + mkt = spawnStr('codex', mktArgs, timeout); + if (mkt.status === 0 || !mkt.stderr.includes('os error 32')) break; + } } if (mkt.status !== 0) { return { ok: false, exitCode: mkt.status, stderr: mkt.stderr, stage: 'marketplace-add' }; @@ -457,6 +462,16 @@ function installCodexPluginGlobally(): { ok: boolean; installed?: boolean; error * environments where @switchbot/codex-plugin is already installed locally. */ export function registerCodexPluginAuto(): RegisterCodexPluginResult { + // Idempotency guard: if the plugin is already registered and healthy, skip all mutation. + const preCheck = checkCodexPluginRegistered(); + if (preCheck.status === 'ok') { + const d = preCheck.detail; + const pluginName = typeof d === 'object' && d !== null && 'pluginName' in d + ? String(d.pluginName) + : undefined; + return { ok: true, pluginId: pluginName ?? CODEX_PLUGIN_DEFAULT_ID, packageRoot: null }; + } + // Route B: git marketplace — no local npm package required const git = registerCodexPluginGit(); if (git.ok) return git; diff --git a/tests/commands/codex.test.ts b/tests/commands/codex.test.ts index 53983269..3dec3e0a 100644 --- a/tests/commands/codex.test.ts +++ b/tests/commands/codex.test.ts @@ -426,21 +426,20 @@ describe('switchbot codex setup', () => { checkCodexPluginRegisteredMock.mockReset(); registerCodexPluginMock.mockReset(); tryLoadConfigMock.mockReset(); + // Default: fast-path bypass for tests that set all other guards to ok. + // Tests that explicitly test the fast path override this with mockReturnValue('ok'). + checkCodexPluginRegisteredMock.mockReturnValueOnce({ name: 'codex-plugin-registered', status: 'warn', detail: 'not yet' }); }); // ── version-aware install-switchbot-cli step ────────────────────────────── - it('already at latest published version → skips npm install', async () => { + it('already installed → skips npm install and npm view (default no-upgrade)', async () => { checkCodexCliMock.mockReturnValueOnce({ name: 'codex-cli', status: 'ok', detail: { path: '/usr/local/bin/codex' }, }); // npm ping succeeds (check-network step) spawnSyncRepairMock.mockReturnValueOnce({ status: 0, stdout: 'Ping success: ...', stderr: '' }); - // npm view returns VERSION as latest → installed version matches → no upgrade - spawnSyncRepairMock.mockReturnValueOnce({ - status: 0, stdout: VERSION + '\n', stderr: '', - }); - // npm list -g: installed at VERSION + // npm list -g: installed at VERSION — no --upgrade, so no npm view call follows spawnSyncRepairMock.mockReturnValueOnce({ status: 0, stdout: JSON.stringify({ dependencies: { '@switchbot/openapi-cli': { version: VERSION } } }), @@ -463,28 +462,33 @@ describe('switchbot codex setup', () => { const step = parsed.data!.outcomes.find((o) => o.step === 'install-switchbot-cli')!; expect(step.status).toBe('ok'); expect(step.message).toMatch(/already installed/); + // Without --upgrade no npm view or install calls should be made + const viewCalls = spawnSyncRepairMock.mock.calls.filter( + (c) => (c[1] as string[]).includes('view'), + ); + expect(viewCalls).toHaveLength(0); const installCalls = spawnSyncRepairMock.mock.calls.filter( (c) => (c[1] as string[]).includes('install'), ); expect(installCalls).toHaveLength(0); }); - it('outdated version (npm view detects newer) → auto-upgrades and reports versions', async () => { + it('--upgrade: outdated version (npm view detects newer) → auto-upgrades and reports versions', async () => { checkCodexCliMock.mockReturnValueOnce({ name: 'codex-cli', status: 'ok', detail: { path: '/usr/local/bin/codex' }, }); // npm ping succeeds (check-network step) spawnSyncRepairMock.mockReturnValueOnce({ status: 0, stdout: 'Ping success: ...', stderr: '' }); - // npm view: latest is 99.0.0 (simulates a future release) - spawnSyncRepairMock.mockReturnValueOnce({ - status: 0, stdout: '99.0.0\n', stderr: '', - }); - // npm list -g: installed at 1.0.0 + // npm list -g: installed at 1.0.0 (checked first before npm view) spawnSyncRepairMock.mockReturnValueOnce({ status: 0, stdout: JSON.stringify({ dependencies: { '@switchbot/openapi-cli': { version: '1.0.0' } } }), stderr: '', }); + // npm view: latest is 99.0.0 (simulates a future release) + spawnSyncRepairMock.mockReturnValueOnce({ + status: 0, stdout: '99.0.0\n', stderr: '', + }); // npm install -g succeeds spawnSyncRepairMock.mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }); // resolveInstalledVersion re-verify after upgrade @@ -498,7 +502,7 @@ describe('switchbot codex setup', () => { checkCodexPluginNpmMock.mockReturnValue({ name: 'codex-plugin-npm', status: 'ok', detail: 'ok' }); checkCodexPluginRegisteredMock.mockReturnValue({ name: 'codex-plugin-registered', status: 'ok', detail: 'ok' }); - const { exitCode, stdout } = await runCli(registerCodexCommand, ['codex', 'setup', '--json']); + const { exitCode, stdout } = await runCli(registerCodexCommand, ['codex', 'setup', '--upgrade', '--json']); expect(exitCode).toBe(0); const parsed = JSON.parse(stdout.join('')) as { data?: { outcomes: Array<{ step: string; status: string; message?: string }> }; @@ -514,20 +518,20 @@ describe('switchbot codex setup', () => { expect(installCalls).toHaveLength(1); }); - it('upgrade failure returns failed outcome with npm stderr', async () => { + it('--upgrade: upgrade failure returns failed outcome with npm stderr', async () => { checkCodexCliMock.mockReturnValueOnce({ name: 'codex-cli', status: 'ok', detail: { path: '/usr/local/bin/codex' }, }); // npm ping succeeds (check-network step) spawnSyncRepairMock.mockReturnValueOnce({ status: 0, stdout: 'Ping success: ...', stderr: '' }); - // npm view: latest is 99.0.0 - spawnSyncRepairMock.mockReturnValueOnce({ status: 0, stdout: '99.0.0\n', stderr: '' }); - // npm list -g: installed at 1.0.0 + // npm list -g: installed at 1.0.0 (checked first) spawnSyncRepairMock.mockReturnValueOnce({ status: 0, stdout: JSON.stringify({ dependencies: { '@switchbot/openapi-cli': { version: '1.0.0' } } }), stderr: '', }); + // npm view: latest is 99.0.0 + spawnSyncRepairMock.mockReturnValueOnce({ status: 0, stdout: '99.0.0\n', stderr: '' }); // npm install -g fails spawnSyncRepairMock.mockReturnValueOnce({ status: 1, stdout: '', stderr: 'EACCES permission denied' }); // pipeline continues past the failed install step @@ -538,7 +542,7 @@ describe('switchbot codex setup', () => { checkCodexPluginNpmMock.mockReturnValue({ name: 'codex-plugin-npm', status: 'ok', detail: 'ok' }); checkCodexPluginRegisteredMock.mockReturnValue({ name: 'codex-plugin-registered', status: 'ok', detail: 'ok' }); - const { exitCode, stdout } = await runCli(registerCodexCommand, ['codex', 'setup', '--yes', '--json']); + const { exitCode, stdout } = await runCli(registerCodexCommand, ['codex', 'setup', '--upgrade', '--yes', '--json']); expect(exitCode).toBe(1); const parsed = JSON.parse(stdout.join('')) as { data?: { outcomes: Array<{ step: string; status: string; message?: string }> }; @@ -548,20 +552,20 @@ describe('switchbot codex setup', () => { expect(step.message).toContain('EACCES'); }); - it('npm view offline → falls back to VERSION as latest, still upgrades if installed is older', async () => { + it('--upgrade: npm view offline → falls back to VERSION as latest, still upgrades if installed is older', async () => { checkCodexCliMock.mockReturnValueOnce({ name: 'codex-cli', status: 'ok', detail: { path: '/usr/local/bin/codex' }, }); // npm ping succeeds (check-network step) spawnSyncRepairMock.mockReturnValueOnce({ status: 0, stdout: 'Ping success: ...', stderr: '' }); - // npm view fails (offline) - spawnSyncRepairMock.mockReturnValueOnce({ status: 1, stdout: '', stderr: 'ENOTFOUND' }); - // npm list -g: installed at 1.0.0 (older than VERSION) + // npm list -g: installed at 1.0.0 (checked first) spawnSyncRepairMock.mockReturnValueOnce({ status: 0, stdout: JSON.stringify({ dependencies: { '@switchbot/openapi-cli': { version: '1.0.0' } } }), stderr: '', }); + // npm view fails (offline) + spawnSyncRepairMock.mockReturnValueOnce({ status: 1, stdout: '', stderr: 'ENOTFOUND' }); // npm install -g succeeds spawnSyncRepairMock.mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }); // resolveInstalledVersion re-verify after upgrade @@ -575,7 +579,7 @@ describe('switchbot codex setup', () => { checkCodexPluginNpmMock.mockReturnValue({ name: 'codex-plugin-npm', status: 'ok', detail: 'ok' }); checkCodexPluginRegisteredMock.mockReturnValue({ name: 'codex-plugin-registered', status: 'ok', detail: 'ok' }); - const { exitCode, stdout } = await runCli(registerCodexCommand, ['codex', 'setup', '--json']); + const { exitCode, stdout } = await runCli(registerCodexCommand, ['codex', 'setup', '--upgrade', '--json']); expect(exitCode).toBe(0); const parsed = JSON.parse(stdout.join('')) as { data?: { outcomes: Array<{ step: string; status: string; message?: string }> }; @@ -586,6 +590,45 @@ describe('switchbot codex setup', () => { expect(step.message).toContain(VERSION); }); + it('--upgrade: package already at latest version → no npm install, fast path bypassed', async () => { + checkCodexCliMock.mockReturnValueOnce({ + name: 'codex-cli', status: 'ok', detail: { path: '/usr/local/bin/codex' }, + }); + // npm ping (check-network) + spawnSyncRepairMock.mockReturnValueOnce({ status: 0, stdout: 'Ping success: ...', stderr: '' }); + // npm list -g: already installed at VERSION + spawnSyncRepairMock.mockReturnValueOnce({ + status: 0, + stdout: JSON.stringify({ dependencies: { '@switchbot/openapi-cli': { version: VERSION } } }), + stderr: '', + }); + // npm view: same VERSION (compareVersions returns 0 → no upgrade) + spawnSyncRepairMock.mockReturnValueOnce({ status: 0, stdout: VERSION + '\n', stderr: '' }); + registerCodexPluginMock.mockReturnValueOnce({ ok: true, pluginId: 'switchbot@codex-plugin', packageRoot: null }); + tryLoadConfigMock.mockReturnValue({ token: 't', secret: 's' }); + runDoctorChecksMock.mockResolvedValueOnce(makeBaseChecks()); + checkCodexCliMock.mockReturnValue({ name: 'codex-cli', status: 'ok', detail: 'ok' }); + checkCodexPluginNpmMock.mockReturnValue({ name: 'codex-plugin-npm', status: 'ok', detail: 'ok' }); + checkCodexPluginRegisteredMock.mockReturnValue({ name: 'codex-plugin-registered', status: 'ok', detail: 'ok' }); + + const { exitCode, stdout } = await runCli(registerCodexCommand, ['codex', 'setup', '--upgrade', '--json']); + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout.join('')) as { + data?: { alreadyConfigured?: boolean; outcomes: Array<{ step: string; status: string; message?: string }> }; + }; + // --upgrade bypasses fast path → alreadyConfigured absent, full pipeline ran + expect(parsed.data?.alreadyConfigured).toBeFalsy(); + expect(parsed.data?.outcomes.length).toBeGreaterThan(0); + const step = parsed.data!.outcomes.find((o) => o.step === 'install-switchbot-cli')!; + expect(step.status).toBe('ok'); + expect(step.message).toMatch(/already installed/); + // No npm install should have been made (version is current) + const installCalls = spawnSyncRepairMock.mock.calls.filter( + (c) => (c[1] as string[]).includes('install'), + ); + expect(installCalls).toHaveLength(0); + }); + it('--dry-run prints the 6-step list without mutating', async () => { const { exitCode, stderr } = await runCli( registerCodexCommand, @@ -641,7 +684,8 @@ describe('switchbot codex setup', () => { }); it('exits 2 when check-codex-cli fails (preflight)', async () => { - checkCodexCliMock.mockReturnValueOnce({ + // Persistent fail so both fast-path check and pipeline step return fail + checkCodexCliMock.mockReturnValue({ name: 'codex-cli', status: 'fail', detail: { message: 'codex CLI not found on PATH' }, }); const { exitCode, stdout } = await runCli( @@ -668,11 +712,7 @@ describe('switchbot codex setup', () => { }); // npm ping succeeds (check-network step) spawnSyncRepairMock.mockReturnValueOnce({ status: 0, stdout: 'Ping success: ...', stderr: '' }); - // npm view: returns current VERSION (no upgrade needed) - spawnSyncRepairMock.mockReturnValueOnce({ - status: 0, stdout: VERSION + '\n', stderr: '', - }); - // install-switchbot-cli step: npm list -g returns the package as already installed + // install-switchbot-cli step: npm list -g (no --upgrade, so no npm view call) spawnSyncRepairMock.mockReturnValueOnce({ status: 0, stdout: JSON.stringify({ dependencies: { '@switchbot/openapi-cli': { version: VERSION } } }), @@ -714,11 +754,7 @@ describe('switchbot codex setup', () => { }); // npm ping succeeds (check-network step) spawnSyncRepairMock.mockReturnValueOnce({ status: 0, stdout: 'Ping success: ...', stderr: '' }); - // npm view: returns current VERSION (no upgrade needed) - spawnSyncRepairMock.mockReturnValueOnce({ - status: 0, stdout: VERSION + '\n', stderr: '', - }); - // install-switchbot-cli: already installed + // install-switchbot-cli: npm list (no --upgrade, so no npm view) spawnSyncRepairMock.mockReturnValueOnce({ status: 0, stdout: JSON.stringify({ dependencies: { '@switchbot/openapi-cli': { version: VERSION } } }), @@ -796,12 +832,10 @@ describe('switchbot codex setup', () => { }); // npm ping succeeds (check-network step) spawnSyncRepairMock.mockReturnValueOnce({ status: 0, stdout: 'Ping success: ...', stderr: '' }); - // npm view: returns current VERSION (no upgrade needed) - spawnSyncRepairMock.mockReturnValueOnce({ - status: 0, stdout: VERSION + '\n', stderr: '', - }); - // npm list -g says not installed + // npm list -g says not installed (no npm view without --upgrade) spawnSyncRepairMock.mockReturnValueOnce({ status: 1, stdout: '{}', stderr: '' }); + // npm view: called because not installed and fetchLatestPublishedVersion is needed + spawnSyncRepairMock.mockReturnValueOnce({ status: 0, stdout: VERSION + '\n', stderr: '' }); // npm install -g fails spawnSyncRepairMock.mockReturnValueOnce({ status: 1, stdout: '', stderr: 'EACCES' }); // register-plugin: Route B succeeds (continues after non-preflight failure) @@ -837,11 +871,7 @@ describe('switchbot codex setup', () => { }); // npm ping succeeds (check-network step) spawnSyncRepairMock.mockReturnValueOnce({ status: 0, stdout: 'Ping success: ...', stderr: '' }); - // npm view: returns current VERSION (no upgrade needed) - spawnSyncRepairMock.mockReturnValueOnce({ - status: 0, stdout: VERSION + '\n', stderr: '', - }); - // install-switchbot-cli: already installed + // install-switchbot-cli: already installed (no npm view without --upgrade) spawnSyncRepairMock.mockReturnValueOnce({ status: 0, stdout: JSON.stringify({ dependencies: { '@switchbot/openapi-cli': { version: VERSION } } }), @@ -881,11 +911,7 @@ describe('switchbot codex setup', () => { }); // npm ping succeeds (check-network step) spawnSyncRepairMock.mockReturnValueOnce({ status: 0, stdout: 'Ping success: ...', stderr: '' }); - // npm view: returns current VERSION (no upgrade needed) - spawnSyncRepairMock.mockReturnValueOnce({ - status: 0, stdout: VERSION + '\n', stderr: '', - }); - // install-switchbot-cli: already installed + // install-switchbot-cli: already installed (no npm view without --upgrade) spawnSyncRepairMock.mockReturnValueOnce({ status: 0, stdout: JSON.stringify({ dependencies: { '@switchbot/openapi-cli': { version: VERSION } } }), @@ -919,9 +945,7 @@ describe('switchbot codex setup', () => { }); // npm ping succeeds spawnSyncRepairMock.mockReturnValueOnce({ status: 0, stdout: 'Ping success: ...', stderr: '' }); - // npm view: current version (install-switchbot-cli step) - spawnSyncRepairMock.mockReturnValueOnce({ status: 0, stdout: VERSION + '\n', stderr: '' }); - // npm list -g: already installed at VERSION + // npm list -g: already installed at VERSION (no npm view without --upgrade) spawnSyncRepairMock.mockReturnValueOnce({ status: 0, stdout: JSON.stringify({ dependencies: { '@switchbot/openapi-cli': { version: VERSION } } }), @@ -978,4 +1002,64 @@ describe('switchbot codex setup', () => { // Fix 3: hasWarnings must be true when any step returned warn expect(parsed.data?.hasWarnings).toBe(true); }); + + it('fast path: already configured → exits 0 with alreadyConfigured:true, no register call', async () => { + checkCodexCliMock.mockReturnValue({ name: 'codex-cli', status: 'ok', detail: { path: '/usr/local/bin/codex' } }); + tryLoadConfigMock.mockReturnValue({ token: 't', secret: 's' }); + checkCodexPluginNpmMock.mockReturnValue({ name: 'codex-plugin-npm', status: 'ok', detail: 'ok' }); + checkCodexPluginRegisteredMock.mockReset(); + checkCodexPluginRegisteredMock.mockReturnValue({ name: 'codex-plugin-registered', status: 'ok', detail: { pluginName: 'switchbot@switchbot' } }); + + const { exitCode, stdout } = await runCli(registerCodexCommand, ['codex', 'setup', '--json']); + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout.join('')) as { + data?: { ok: boolean; alreadyConfigured: boolean; outcomes: unknown[] }; + }; + expect(parsed.data?.ok).toBe(true); + expect(parsed.data?.alreadyConfigured).toBe(true); + expect(parsed.data?.outcomes).toHaveLength(0); + expect(registerCodexPluginMock).not.toHaveBeenCalled(); + }); + + it('fast path: already configured → prints "Already configured" to stdout (non-JSON)', async () => { + checkCodexCliMock.mockReturnValue({ name: 'codex-cli', status: 'ok', detail: { path: '/usr/local/bin/codex' } }); + tryLoadConfigMock.mockReturnValue({ token: 't', secret: 's' }); + checkCodexPluginNpmMock.mockReturnValue({ name: 'codex-plugin-npm', status: 'ok', detail: 'ok' }); + checkCodexPluginRegisteredMock.mockReset(); + checkCodexPluginRegisteredMock.mockReturnValue({ name: 'codex-plugin-registered', status: 'ok', detail: { pluginName: 'switchbot@switchbot' } }); + + const { exitCode, stdout } = await runCli(registerCodexCommand, ['codex', 'setup']); + expect(exitCode).toBe(0); + const out = stdout.join('\n'); + expect(out).toMatch(/already configured/i); + expect(out).toMatch(/switchbot codex doctor/); + }); + + it('fast path bypassed when --skip is non-empty', async () => { + checkCodexCliMock.mockReturnValueOnce({ name: 'codex-cli', status: 'ok', detail: { path: '/usr/local/bin/codex' } }); + // check-network is skipped → no npm ping call + // install-switchbot-cli: npm list -g shows already installed + spawnSyncRepairMock.mockReturnValueOnce({ + status: 0, + stdout: JSON.stringify({ dependencies: { '@switchbot/openapi-cli': { version: VERSION } } }), + stderr: '', + }); + registerCodexPluginMock.mockReturnValueOnce({ ok: true, pluginId: 'switchbot@switchbot', packageRoot: null }); + tryLoadConfigMock.mockReturnValue({ token: 't', secret: 's' }); + runDoctorChecksMock.mockResolvedValueOnce(makeBaseChecks()); + checkCodexCliMock.mockReturnValue({ name: 'codex-cli', status: 'ok', detail: 'ok' }); + checkCodexPluginNpmMock.mockReturnValue({ name: 'codex-plugin-npm', status: 'ok', detail: 'ok' }); + checkCodexPluginRegisteredMock.mockReturnValue({ name: 'codex-plugin-registered', status: 'ok', detail: 'ok' }); + + const { exitCode, stdout } = await runCli( + registerCodexCommand, + ['codex', 'setup', '--json', '--skip', 'check-network'], + ); + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout.join('')) as { + data?: { alreadyConfigured?: boolean; outcomes: Array<{ step: string }> }; + }; + expect(parsed.data?.alreadyConfigured).toBeFalsy(); + expect(parsed.data?.outcomes.length).toBeGreaterThan(0); + }); }); diff --git a/tests/devices/cache.test.ts b/tests/devices/cache.test.ts index a622ad4a..d391544f 100644 --- a/tests/devices/cache.test.ts +++ b/tests/devices/cache.test.ts @@ -165,6 +165,68 @@ describe('device cache', () => { // Default path should NOT have been created. expect(fs.existsSync(path.join(tmpDir, '.switchbot', 'devices.json'))).toBe(false); }); + + it('IR device inherits familyName/roomID/roomName from its hub', () => { + updateCacheFromDeviceList({ + deviceList: [{ + deviceId: 'HUB-1', + deviceName: 'Mini Hub', + deviceType: 'Hub Mini', + familyName: '公司', + roomID: 'ROOM-42', + roomName: 'Office', + }], + infraredRemoteList: [{ + deviceId: 'IR-A', + deviceName: 'Office TV', + remoteType: 'TV', + hubDeviceId: 'HUB-1', + }], + }); + const ir = getCachedDevice('IR-A'); + expect(ir).not.toBeNull(); + expect(ir!.familyName).toBe('公司'); + expect(ir!.roomID).toBe('ROOM-42'); + expect(ir!.roomName).toBe('Office'); + expect(ir!.category).toBe('ir'); + }); + + it('IR device with hubDeviceId not in the device list gets no room/family metadata', () => { + updateCacheFromDeviceList({ + deviceList: [], + infraredRemoteList: [{ + deviceId: 'IR-ORPHAN', + deviceName: 'Orphan AC', + remoteType: 'Air Conditioner', + hubDeviceId: 'HUB-MISSING', + }], + }); + const ir = getCachedDevice('IR-ORPHAN'); + expect(ir).not.toBeNull(); + expect(ir!.familyName).toBeUndefined(); + expect(ir!.roomID).toBeUndefined(); + expect(ir!.roomName).toBeUndefined(); + }); + + it('IR device inherits undefined room/family when hub itself has no metadata', () => { + updateCacheFromDeviceList({ + deviceList: [{ + deviceId: 'HUB-BARE', + deviceName: 'Bare Hub', + deviceType: 'Hub Mini', + }], + infraredRemoteList: [{ + deviceId: 'IR-B', + deviceName: 'Bedroom Fan', + remoteType: 'Fan', + hubDeviceId: 'HUB-BARE', + }], + }); + const ir = getCachedDevice('IR-B'); + expect(ir!.familyName).toBeUndefined(); + expect(ir!.roomID).toBeUndefined(); + expect(ir!.roomName).toBeUndefined(); + }); }); describe('list cache TTL', () => { diff --git a/tests/install/codex-checks.test.ts b/tests/install/codex-checks.test.ts index 46639b10..0438119b 100644 --- a/tests/install/codex-checks.test.ts +++ b/tests/install/codex-checks.test.ts @@ -43,7 +43,6 @@ import { registerCodexPluginGit, registerCodexPluginAuto, CODEX_GIT_MARKETPLACE_SPARSE, - CODEX_GIT_MARKETPLACE_SPARSE2, } from '../../src/install/codex-checks.js'; function makeSpawnResult(status: number, stdout: string, stderr = ''): ReturnType { @@ -283,6 +282,25 @@ describe('resolvePluginId', () => { readFileSyncMock.mockReturnValue('{}'); expect(resolvePluginId('/some/path/codex-plugin')).toBe('switchbot@codex-plugin'); }); + + it('uses root-level marketplace.json when .agents/plugins/ is absent', () => { + existsSyncMock.mockImplementation((p: string) => { + if (p.includes('.agents')) return false; // .agents/plugins/ NOT present + if (p.endsWith('marketplace.json')) return true; // root marketplace.json IS present + if (p.endsWith('plugin.json')) return true; + return false; + }); + readFileSyncMock.mockReturnValue('{"name":"switchbot"}'); + expect(resolvePluginId('/some/path/codex-plugin')).toBe('switchbot@switchbot'); + }); + + it('prefers .agents/plugins/marketplace.json over root marketplace.json', () => { + existsSyncMock.mockImplementation((p: string) => p.endsWith('marketplace.json')); + readFileSyncMock.mockImplementation((p: string) => + p.includes('.agents') ? '{"name":"agents-name"}' : '{"name":"root-name"}' + ); + expect(resolvePluginId('/some/path')).toBe('switchbot@agents-name'); + }); }); describe('resolveMarketplaceSourceRoot', () => { @@ -595,7 +613,7 @@ describe('runCodexPluginRegistrationGit', () => { else process.env['CODEX_GIT_MARKETPLACE_REF'] = origEnv; }); - it('passes both --sparse flags (packages/codex-plugin and .claude-plugin) to marketplace add', () => { + it('passes exactly one --sparse flag (packages/codex-plugin) to marketplace add', () => { spawnSyncMock .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id: switchbot@switchbot) .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id: switchbot@codex-plugin) @@ -610,13 +628,87 @@ describe('runCodexPluginRegistrationGit', () => { const mktCall = calls.find(([cmd, args]) => cmd === 'codex' && args.includes('marketplace') && args.includes('add')); expect(mktCall).toBeDefined(); const mktArgs = mktCall![1]; - // Both sparse paths must be present const sparseIndices = mktArgs .map((a, i) => (a === '--sparse' ? i : -1)) .filter(i => i >= 0); - expect(sparseIndices.length).toBe(2); + expect(sparseIndices.length).toBe(1); expect(mktArgs[sparseIndices[0] + 1]).toBe(CODEX_GIT_MARKETPLACE_SPARSE); - expect(mktArgs[sparseIndices[1] + 1]).toBe(CODEX_GIT_MARKETPLACE_SPARSE2); + }); + + it('retries with exponential backoff on Windows os error 32 and succeeds on second attempt', () => { + const savedPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); + const atomicsSpy = vi.spyOn(Atomics, 'wait').mockReturnValue('ok' as ReturnType); + try { + spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove ×2 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(1, '', 'os error 32 locked')) // marketplace add: fails + .mockReturnValueOnce(makeSpawnResult(1, '', '"Codex.exe" not found')) // tasklist: Codex not running + .mockReturnValueOnce(makeSpawnResult(0, '')) // retry 1 (2s backoff): succeeds + .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add + const r = runCodexPluginRegistrationGit('switchbot@codex-plugin'); + expect(r.ok).toBe(true); + // Atomics.wait called once (2s delay before the successful retry) + expect(atomicsSpy).toHaveBeenCalledTimes(1); + } finally { + atomicsSpy.mockRestore(); + Object.defineProperty(process, 'platform', { value: savedPlatform, configurable: true }); + } + }); + + it('warns to stderr when Codex.exe is detected running during os error 32', () => { + const savedPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); + const atomicsSpy = vi.spyOn(Atomics, 'wait').mockReturnValue('ok' as ReturnType); + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + try { + spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove ×2 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(1, '', 'os error 32')) // marketplace add: fails + .mockReturnValueOnce(makeSpawnResult(0, '"Codex.exe","123","Console","1","10MB"')) // tasklist: Codex running + .mockReturnValueOnce(makeSpawnResult(1, '', 'os error 32')) // retry 1: still fails + .mockReturnValueOnce(makeSpawnResult(1, '', 'os error 32')) // retry 2: still fails + .mockReturnValueOnce(makeSpawnResult(1, '', 'os error 32')); // retry 3: still fails + runCodexPluginRegistrationGit('switchbot@codex-plugin'); + expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('Codex.exe is running')); + } finally { + stderrSpy.mockRestore(); + atomicsSpy.mockRestore(); + Object.defineProperty(process, 'platform', { value: savedPlatform, configurable: true }); + } + }); + + it('stops retrying when error changes from os error 32 to a different error', () => { + const savedPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); + const atomicsSpy = vi.spyOn(Atomics, 'wait').mockReturnValue('ok' as ReturnType); + try { + spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove ×2 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(1, '', 'os error 32 locked')) // first attempt: file lock + .mockReturnValueOnce(makeSpawnResult(1, '', '"Codex.exe" not found')) // tasklist: not running + .mockReturnValueOnce(makeSpawnResult(1, '', 'network timeout')); // retry 1: different error → break + const r = runCodexPluginRegistrationGit('switchbot@codex-plugin'); + expect(r.ok).toBe(false); + expect(r.stderr).toBe('network timeout'); + // Only one Atomics.wait call (before the single retry that changed error type) + expect(atomicsSpy).toHaveBeenCalledTimes(1); + } finally { + atomicsSpy.mockRestore(); + Object.defineProperty(process, 'platform', { value: savedPlatform, configurable: true }); + } }); }); @@ -646,6 +738,7 @@ describe('registerCodexPluginAuto', () => { it('returns git result when Route B succeeds', () => { spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(1, '')) // where/which codex: not found → idempotency guard skips .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (switchbot@switchbot — current) .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (switchbot@codex-plugin — legacy) .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (switchbot@switchbot-skill — legacy) @@ -662,6 +755,7 @@ describe('registerCodexPluginAuto', () => { it('falls back to local npm path when Route B fails', () => { existsSyncMock.mockReturnValue(true); spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(1, '')) // where/which codex: not found → idempotency guard skips .mockReturnValueOnce(makeSpawnResult(0, '')) // Route B: plugin remove ×3 .mockReturnValueOnce(makeSpawnResult(0, '')) .mockReturnValueOnce(makeSpawnResult(0, '')) @@ -712,6 +806,7 @@ describe('registerCodexPluginAuto', () => { it('returns failure when Route B fails, Route A fails, and on-demand install also fails', () => { spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(1, '')) // where/which codex: not found → idempotency guard skips .mockReturnValueOnce(makeSpawnResult(0, '')) // Route B: plugin remove ×3 .mockReturnValueOnce(makeSpawnResult(0, '')) .mockReturnValueOnce(makeSpawnResult(0, '')) @@ -732,6 +827,7 @@ describe('registerCodexPluginAuto', () => { it('returns failure when on-demand install succeeds but Route A retry still fails', () => { const installedJson = JSON.stringify({ dependencies: { '@switchbot/codex-plugin': { version: '1.0.0' } } }); spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(1, '')) // where/which codex: not found → idempotency guard skips .mockReturnValueOnce(makeSpawnResult(0, '')) // Route B: plugin remove ×3 .mockReturnValueOnce(makeSpawnResult(0, '')) .mockReturnValueOnce(makeSpawnResult(0, '')) @@ -810,6 +906,7 @@ describe('registerCodexPluginAuto', () => { it('returns npm-prefix-mismatch error when post-install npm list still shows package absent', () => { spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(1, '')) // where/which codex: not found → idempotency guard skips .mockReturnValueOnce(makeSpawnResult(0, '')) // Route B: plugin remove ×3 .mockReturnValueOnce(makeSpawnResult(0, '')) .mockReturnValueOnce(makeSpawnResult(0, '')) @@ -829,6 +926,7 @@ describe('registerCodexPluginAuto', () => { it('error says "already present" (not "installed") when package existed before and Route A retry fails (Fix 3)', () => { const installedJson = JSON.stringify({ dependencies: { '@switchbot/codex-plugin': { version: '1.0.0' } } }); spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(1, '')) // where/which codex: not found → idempotency guard skips .mockReturnValueOnce(makeSpawnResult(0, '')) // Route B: plugin remove ×3 .mockReturnValueOnce(makeSpawnResult(0, '')) .mockReturnValueOnce(makeSpawnResult(0, '')) @@ -847,6 +945,7 @@ describe('registerCodexPluginAuto', () => { it('returns error when post-install verify spawnSync times out (status null)', () => { spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(1, '')) // where/which codex: not found → idempotency guard skips .mockReturnValueOnce(makeSpawnResult(0, '')) // Route B: plugin remove ×3 .mockReturnValueOnce(makeSpawnResult(0, '')) .mockReturnValueOnce(makeSpawnResult(0, '')) @@ -867,6 +966,7 @@ describe('registerCodexPluginAuto', () => { const installedJson = JSON.stringify({ dependencies: { '@switchbot/codex-plugin': { version: '1.0.0' } } }); existsSyncMock.mockReturnValue(true); spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(1, '')) // where/which codex: not found → idempotency guard skips .mockReturnValueOnce(makeSpawnResult(0, '')) // Route B: plugin remove ×3 .mockReturnValueOnce(makeSpawnResult(0, '')) .mockReturnValueOnce(makeSpawnResult(0, '')) @@ -918,6 +1018,17 @@ describe('registerCodexPluginAuto', () => { const r = registerCodexPluginAuto(); expect(r.ok).toBe(true); }); + + it('returns ok immediately without mutation when plugin is already registered (idempotency)', () => { + spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(0, '/usr/local/bin/codex\n')) // which codex + .mockReturnValueOnce(makeSpawnResult(0, 'switchbot@switchbot installed\n')); // codex plugin list + const r = registerCodexPluginAuto(); + expect(r.ok).toBe(true); + expect(r.pluginId).toContain('switchbot'); + // Only the two check calls — no marketplace or plugin mutation + expect(spawnSyncMock).toHaveBeenCalledTimes(2); + }); }); describe('stepRegisterCodexPlugin', () => { @@ -933,6 +1044,7 @@ describe('stepRegisterCodexPlugin', () => { it('sets codexPluginRegistered and codexPluginIdentifier on success', async () => { spawnSyncMock + .mockReturnValueOnce({ status: 1, stdout: '', stderr: '' }) // where/which codex: not found → idempotency guard skips .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) // plugin remove (switchbot@switchbot — current) .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) // plugin remove (switchbot@codex-plugin — legacy) .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) // plugin remove (switchbot@switchbot-skill — legacy) @@ -950,6 +1062,7 @@ describe('stepRegisterCodexPlugin', () => { it('throws when runCodexPluginRegistration fails', async () => { spawnSyncMock + .mockReturnValueOnce({ status: 1, stdout: '', stderr: '' }) // where/which codex: not found → idempotency guard skips .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) // plugin remove (Route B pre-clean ×3) .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) diff --git a/tests/readme-route-b.test.ts b/tests/readme-route-b.test.ts new file mode 100644 index 00000000..4d12859b --- /dev/null +++ b/tests/readme-route-b.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const readmeContent = readFileSync( + path.join(here, '..', 'README.md'), + 'utf-8', +); + +describe('README.md — Route B documentation', () => { + it('mentions "Route B" to explain marketplace.json purpose', () => { + expect(readmeContent.toLowerCase()).toMatch(/route\s+b/i); + }); + + it('explains marketplace.json in context of Codex or Route B', () => { + expect(readmeContent).toContain('marketplace.json'); + + const term = 'marketplace.json'; + let pos = readmeContent.indexOf(term); + let hasContext = false; + while (pos !== -1) { + const excerpt = readmeContent + .slice(Math.max(0, pos - 300), pos + 300) + .toLowerCase(); + if (excerpt.includes('codex') || excerpt.includes('route')) { + hasContext = true; + break; + } + pos = readmeContent.indexOf(term, pos + 1); + } + expect(hasContext).toBe(true); + }); + + it('clarifies that root marketplace.json is not for Claude Code users', () => { + const term = 'marketplace.json'; + let pos = readmeContent.indexOf(term); + let found = false; + while (pos !== -1) { + const excerpt = readmeContent + .slice(Math.max(0, pos - 300), pos + 300) + .toLowerCase(); + if (excerpt.includes('claude code')) { + found = true; + break; + } + pos = readmeContent.indexOf(term, pos + 1); + } + expect(found).toBe(true); + }); +});